chore: initial project commit
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# build outputs
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# TypeScript incremental build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# editor and OS files
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<title>SAFe OS — Multi-Agent 敏捷协同指挥中心</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2736
package-lock.json
generated
Normal file
2736
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "safe-os-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.4.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
21
src/App.tsx
Normal file
21
src/App.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import Layout from "./Layout";
|
||||||
|
import PlanningAgent from "./pages/PlanningAgent";
|
||||||
|
import DevOpsAgent from "./pages/DevOpsAgent";
|
||||||
|
import QualityGate from "./pages/QualityGate";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/planning" element={<PlanningAgent />} />
|
||||||
|
<Route path="/devops" element={<DevOpsAgent />} />
|
||||||
|
<Route path="/quality" element={<Navigate to="/quality/dashboard" replace />} />
|
||||||
|
<Route path="/quality/dashboard" element={<QualityGate view="dashboard" />} />
|
||||||
|
<Route path="/quality/pr-list" element={<QualityGate view="pr-list" />} />
|
||||||
|
<Route path="/quality/settings" element={<QualityGate view="settings" />} />
|
||||||
|
<Route path="*" element={<Navigate to="/planning" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/Layout.tsx
Normal file
161
src/Layout.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
const QUALITY_SUB_ITEMS = [
|
||||||
|
{ to: "/quality/dashboard", title: "Overview" },
|
||||||
|
{ to: "/quality/pr-list", title: "PR List" },
|
||||||
|
{ to: "/quality/settings", title: "Settings" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
|
"/planning": "Strategic Planning Workspace",
|
||||||
|
"/devops": "Delivery Execution Workspace",
|
||||||
|
"/quality/dashboard": "Quality Gate Overview",
|
||||||
|
"/quality/pr-list": "Quality Gate · PR List",
|
||||||
|
"/quality/settings": "Quality Gate · Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const inQuality = pathname.startsWith("/quality");
|
||||||
|
const [qualityExpanded, setQualityExpanded] = useState<boolean>(inQuality);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inQuality) {
|
||||||
|
setQualityExpanded(true);
|
||||||
|
}
|
||||||
|
}, [inQuality]);
|
||||||
|
|
||||||
|
const pageTitle = useMemo(() => {
|
||||||
|
if (PAGE_TITLES[pathname]) {
|
||||||
|
return PAGE_TITLES[pathname];
|
||||||
|
}
|
||||||
|
if (inQuality) {
|
||||||
|
return "Quality Gate";
|
||||||
|
}
|
||||||
|
return "SAFe OS";
|
||||||
|
}, [pathname, inQuality]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen bg-[#fafafa]">
|
||||||
|
{/* ─── Sidebar ─── */}
|
||||||
|
<nav className="w-[280px] shrink-0 bg-surface-dark text-txt-inverse flex flex-col py-8 border-r border-white/10">
|
||||||
|
<div className="px-8 mb-9 text-2xl font-extrabold tracking-tight select-none">
|
||||||
|
SAFe <span className="text-magenta">OS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-8 text-[11px] text-gray-500 uppercase font-bold tracking-widest mb-4">
|
||||||
|
Agent Pipeline
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/planning"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`mx-4 rounded-xl flex items-center gap-3 px-4 py-3 border border-transparent transition-all cursor-pointer ${
|
||||||
|
isActive
|
||||||
|
? "border-magenta/40 bg-white/10"
|
||||||
|
: "hover:bg-white/5"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${
|
||||||
|
isActive ? "bg-magenta" : "bg-gray-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-[0.95rem]">1. Planning Council Agent</div>
|
||||||
|
<div className="text-xs text-gray-400">Business and architecture planning</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/devops"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`mx-4 rounded-xl flex items-center gap-3 px-4 py-3 border border-transparent transition-all cursor-pointer ${
|
||||||
|
isActive
|
||||||
|
? "border-magenta/40 bg-white/10"
|
||||||
|
: "hover:bg-white/5"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${
|
||||||
|
isActive ? "bg-magenta" : "bg-gray-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-[0.95rem]">2. DevOps Agent</div>
|
||||||
|
<div className="text-xs text-gray-400">Story breakdown and implementation</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="mx-4 rounded-xl border border-transparent">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQualityExpanded((v) => !v)}
|
||||||
|
className={`w-full rounded-xl flex items-center gap-3 px-4 py-3 transition-all text-left ${
|
||||||
|
inQuality ? "bg-white/10 border border-magenta/40" : "hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${
|
||||||
|
inQuality ? "bg-magenta" : "bg-gray-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-bold text-[0.95rem]">3. Quality Gate Agent</div>
|
||||||
|
<div className="text-xs text-gray-400">Automated validation and review</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs text-gray-400 transition-transform ${qualityExpanded ? "rotate-90" : ""}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{qualityExpanded && (
|
||||||
|
<div className="pl-10 pr-3 pb-3 pt-1 flex flex-col gap-1">
|
||||||
|
{QUALITY_SUB_ITEMS.map((sub) => (
|
||||||
|
<NavLink
|
||||||
|
key={sub.to}
|
||||||
|
to={sub.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-xs font-bold px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-magenta bg-white/10"
|
||||||
|
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto px-8 text-[0.7rem] text-gray-600">
|
||||||
|
T-Systems · SAFe Multi-Agent Demo UI
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ─── Main workspace ─── */}
|
||||||
|
<main className="flex-1 flex flex-col bg-white min-w-0">
|
||||||
|
<header className="h-20 px-10 flex items-center justify-between border-b border-border/80 bg-white/90 backdrop-blur shrink-0">
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight">{pageTitle}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/config.ts
Normal file
15
src/config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* API base path configuration.
|
||||||
|
* In dev mode, Vite proxy rewrites these prefixes to actual backend URLs.
|
||||||
|
* In production, set window.__API_CONFIG__ or use env vars at build time.
|
||||||
|
*/
|
||||||
|
export const API = {
|
||||||
|
/** 铁三角 Agent — planning chat & file upload */
|
||||||
|
planning: "/planning-api",
|
||||||
|
|
||||||
|
/** DevOps Agent — session workflow */
|
||||||
|
devops: "/devops-api",
|
||||||
|
|
||||||
|
/** 质量门禁 Agent — PR scanning & code review */
|
||||||
|
quality: "/quality-api",
|
||||||
|
} as const;
|
||||||
68
src/index.css
Normal file
68
src/index.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "Roboto", system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% -10%, #fff0f7 0%, transparent 35%),
|
||||||
|
radial-gradient(circle at 90% 0%, #f5f5f5 0%, transparent 28%),
|
||||||
|
#fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-magenta {
|
||||||
|
@apply bg-magenta text-white font-bold text-sm px-6 py-2.5 border-none cursor-pointer rounded-xl transition-all duration-200 hover:opacity-95 hover:-translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply bg-white text-txt font-bold text-sm px-6 py-2.5 border border-border cursor-pointer rounded-xl transition-all duration-200 hover:bg-surface-muted hover:-translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white/95 border border-border p-6 rounded-2xl shadow-[0_6px_24px_rgba(0,0,0,0.04)] backdrop-blur;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
@apply w-full bg-white border border-border px-4 py-3 text-sm text-txt rounded-xl outline-none transition-all duration-200 focus:border-magenta focus:ring-2 focus:ring-magenta/10 font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@apply inline-block text-xs font-bold px-2.5 py-1 bg-surface-muted text-txt rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
677
src/pages/DevOpsAgent.tsx
Normal file
677
src/pages/DevOpsAgent.tsx
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { API } from "../config";
|
||||||
|
|
||||||
|
/* ─── Types ─── */
|
||||||
|
type Step = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
type ClarifyMsg = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
|
type PMResult = {
|
||||||
|
summary: string;
|
||||||
|
functional: string[];
|
||||||
|
nonfunctional: string[];
|
||||||
|
acceptance: string[];
|
||||||
|
edge_cases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestCase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
precondition: string;
|
||||||
|
steps: string;
|
||||||
|
expected: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestResult = {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
errors: number;
|
||||||
|
total: number;
|
||||||
|
output: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── API helpers ─── */
|
||||||
|
const BASE = API.devops;
|
||||||
|
|
||||||
|
async function apiPost<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSSE(path: string, onChunk: (text: string) => void, signal?: AbortSignal) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, { signal });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
if (!res.body) return;
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
// process SSE lines
|
||||||
|
let idx = buf.indexOf("\n");
|
||||||
|
while (idx >= 0) {
|
||||||
|
const line = buf.slice(0, idx).trim();
|
||||||
|
buf = buf.slice(idx + 1);
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (payload === "[DONE]") return;
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(payload);
|
||||||
|
if (evt.text) onChunk(evt.text);
|
||||||
|
else if (evt.message) onChunk(evt.message);
|
||||||
|
else if (typeof evt === "string") onChunk(evt);
|
||||||
|
} catch {
|
||||||
|
onChunk(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = buf.indexOf("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step labels ── */
|
||||||
|
const STEPS: { title: string; icon: string }[] = [
|
||||||
|
{ title: "Clarification", icon: "💬" },
|
||||||
|
{ title: "PM Analysis", icon: "📋" },
|
||||||
|
{ title: "QA Cases", icon: "🧪" },
|
||||||
|
{ title: "Dev Output", icon: "💻" },
|
||||||
|
{ title: "Test Run", icon: "▶" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━ */
|
||||||
|
export default function DevOpsAgent() {
|
||||||
|
const [step, setStep] = useState<Step>(0);
|
||||||
|
const [sessionId, setSessionId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Step 0 — Requirement
|
||||||
|
const [requirement, setRequirement] = useState("");
|
||||||
|
const [rawReq, setRawReq] = useState("");
|
||||||
|
const [status, setStatus] = useState<string>("");
|
||||||
|
const [clarifyHistory, setClarifyHistory] = useState<ClarifyMsg[]>([]);
|
||||||
|
const [clarifyInput, setClarifyInput] = useState("");
|
||||||
|
|
||||||
|
// Step 1 — PM analysis
|
||||||
|
const [pmStream, setPmStream] = useState("");
|
||||||
|
const [pmResult, setPmResult] = useState<PMResult | null>(null);
|
||||||
|
const [pmFeedback, setPmFeedback] = useState("");
|
||||||
|
|
||||||
|
// Step 2 — QA test cases
|
||||||
|
const [qaStream, setQaStream] = useState("");
|
||||||
|
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
||||||
|
const [qaFeedback, setQaFeedback] = useState("");
|
||||||
|
|
||||||
|
// Step 3 — Dev code
|
||||||
|
const [devStream, setDevStream] = useState("");
|
||||||
|
const [codeTab, setCodeTab] = useState<"code" | "test" | "notes">("code");
|
||||||
|
const [devCode, setDevCode] = useState("");
|
||||||
|
const [devTest, setDevTest] = useState("");
|
||||||
|
const [devNotes, setDevNotes] = useState("");
|
||||||
|
|
||||||
|
// Step 4 — Test results
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [pmStream, qaStream, devStream]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Step 0: Start session ── */
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
if (!requirement.trim() || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data: any = await apiPost("/session/start", { requirement: requirement.trim() });
|
||||||
|
setSessionId(data.session_id);
|
||||||
|
setRawReq(requirement.trim());
|
||||||
|
setStatus(data.status || "clarifying");
|
||||||
|
if (data.clarify_questions) {
|
||||||
|
setClarifyHistory([{ role: "assistant", content: data.clarify_questions }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [requirement, loading]);
|
||||||
|
|
||||||
|
/* ── Step 0: Clarify ── */
|
||||||
|
const handleClarify = useCallback(async () => {
|
||||||
|
if (!clarifyInput.trim() || loading) return;
|
||||||
|
setClarifyHistory((h) => [...h, { role: "user", content: clarifyInput.trim() }]);
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data: any = await apiPost(`/session/${sessionId}/clarify`, { message: clarifyInput.trim() });
|
||||||
|
setClarifyInput("");
|
||||||
|
setStatus(data.status || status);
|
||||||
|
if (data.clarify_questions) {
|
||||||
|
setClarifyHistory((h) => [...h, { role: "assistant", content: data.clarify_questions }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [clarifyInput, sessionId, loading, status]);
|
||||||
|
|
||||||
|
/* ── Step 1: PM Run ── */
|
||||||
|
const handlePmRun = useCallback(async () => {
|
||||||
|
setStep(1);
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setPmStream("");
|
||||||
|
try {
|
||||||
|
await apiPost(`/session/${sessionId}/pm/run`);
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
let full = "";
|
||||||
|
await readSSE(`/session/${sessionId}/pm/stream`, (text) => {
|
||||||
|
full += text;
|
||||||
|
setPmStream(full);
|
||||||
|
}, ctrl.signal);
|
||||||
|
// Try to parse structured result
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(full);
|
||||||
|
setPmResult(json);
|
||||||
|
} catch {
|
||||||
|
setPmResult({ summary: full, functional: [], nonfunctional: [], acceptance: [], edge_cases: [] });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
/* ── Step 1: PM Refine ── */
|
||||||
|
const handlePmRefine = useCallback(async () => {
|
||||||
|
if (!pmFeedback.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setPmStream("");
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
let full = "";
|
||||||
|
await readSSE(
|
||||||
|
`/session/${sessionId}/pm/refine/stream?feedback=${encodeURIComponent(pmFeedback.trim())}`,
|
||||||
|
(text) => {
|
||||||
|
full += text;
|
||||||
|
setPmStream(full);
|
||||||
|
},
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
setPmFeedback("");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId, pmFeedback]);
|
||||||
|
|
||||||
|
/* ── Step 2: QA Run ── */
|
||||||
|
const handleQaRun = useCallback(async () => {
|
||||||
|
setStep(2);
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setQaStream("");
|
||||||
|
try {
|
||||||
|
await apiPost(`/session/${sessionId}/qa/run`);
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
let full = "";
|
||||||
|
await readSSE(`/session/${sessionId}/qa/stream`, (text) => {
|
||||||
|
full += text;
|
||||||
|
setQaStream(full);
|
||||||
|
}, ctrl.signal);
|
||||||
|
// Try parse
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(full);
|
||||||
|
if (json.test_cases) setTestCases(json.test_cases);
|
||||||
|
} catch {
|
||||||
|
/* raw display */
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
/* ── Step 3: Dev Run ── */
|
||||||
|
const handleDevRun = useCallback(async () => {
|
||||||
|
setStep(3);
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setDevStream("");
|
||||||
|
try {
|
||||||
|
await apiPost(`/session/${sessionId}/dev/run`);
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
let full = "";
|
||||||
|
await readSSE(`/session/${sessionId}/dev/stream`, (text) => {
|
||||||
|
full += text;
|
||||||
|
setDevStream(full);
|
||||||
|
}, ctrl.signal);
|
||||||
|
// Parse code blocks
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(full);
|
||||||
|
setDevCode(json.code || json.implementation || full);
|
||||||
|
setDevTest(json.tests || json.test_code || "");
|
||||||
|
setDevNotes(json.notes || json.implementation_notes || "");
|
||||||
|
} catch {
|
||||||
|
setDevCode(full);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
/* ── Step 4: Test Run ── */
|
||||||
|
const handleTestRun = useCallback(async () => {
|
||||||
|
setStep(4);
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data: any = await apiPost(`/session/${sessionId}/test/run`);
|
||||||
|
setTestResult({
|
||||||
|
passed: data.passed ?? 0,
|
||||||
|
failed: data.failed ?? 0,
|
||||||
|
errors: data.errors ?? 0,
|
||||||
|
total: data.total ?? 0,
|
||||||
|
output: data.output || data.detail || JSON.stringify(data, null, 2),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
/* ── Test Fix ── */
|
||||||
|
const handleTestFix = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
let full = "";
|
||||||
|
await readSSE(`/session/${sessionId}/test/fix/stream`, (text) => {
|
||||||
|
full += text;
|
||||||
|
setDevStream(full);
|
||||||
|
}, ctrl.signal);
|
||||||
|
// Re-run test
|
||||||
|
await handleTestRun();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId, handleTestRun]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Step bar ── */}
|
||||||
|
<div className="flex items-center gap-1 px-10 py-4 border-b border-border bg-white/80 backdrop-blur shrink-0">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (sessionId && i <= step) setStep(i as Step); }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-bold transition-colors ${
|
||||||
|
i === step
|
||||||
|
? "text-magenta"
|
||||||
|
: i < step
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-txt-muted"
|
||||||
|
} ${i <= step ? "cursor-pointer hover:text-magenta" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
<span>{i < step ? "✓" : s.icon}</span>
|
||||||
|
<span>{s.title}</span>
|
||||||
|
</button>
|
||||||
|
{i < STEPS.length - 1 && <span className="text-gray-300 mx-1">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-10 mt-4 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
||||||
|
{error}
|
||||||
|
<button className="font-bold text-red-400 hover:text-red-600" onClick={() => setError("")}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-10 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
{/* ═══ Step 0: Requirement ═══ */}
|
||||||
|
{step === 0 && !sessionId && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-extrabold mb-1">📋 Describe Product Requirement</h2>
|
||||||
|
<p className="text-sm text-txt-muted mb-6">Enter your product requirement, the system will automatically perform requirement clarification and analysis</p>
|
||||||
|
<textarea
|
||||||
|
className="input-field min-h-[160px] resize-y mb-4"
|
||||||
|
value={requirement}
|
||||||
|
onChange={(e) => setRequirement(e.target.value)}
|
||||||
|
placeholder="Example: Build a predictive battery maintenance system for 500k concurrent uploads with end-to-end latency < 3s..."
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn-magenta" onClick={handleStart} disabled={loading || !requirement.trim()}>
|
||||||
|
{loading ? "Creating..." : "Start Analysis →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 0 && sessionId && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-lg">💬</span>
|
||||||
|
<h2 className="text-lg font-extrabold">Requirement Clarification</h2>
|
||||||
|
<span className={`badge ${status === "clarifying" ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"}`}>
|
||||||
|
{status === "clarifying" ? "Clarifying" : "Confirmed"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Original req */}
|
||||||
|
<div className="bg-surface-muted p-4 mb-4 text-sm">
|
||||||
|
<span className="text-xs font-bold text-txt-muted block mb-1">📋 Original Requirement</span>
|
||||||
|
{rawReq}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat history */}
|
||||||
|
{clarifyHistory.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
{clarifyHistory.map((msg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`max-w-[80%] px-4 py-3 text-sm ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "self-end bg-surface-muted"
|
||||||
|
: "self-start border border-border border-l-4 border-l-magenta"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-txt-muted block mb-1">
|
||||||
|
{msg.role === "assistant" ? "🤖 AI" : "👤 You"}
|
||||||
|
</span>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clarify input */}
|
||||||
|
{status === "clarifying" && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<p className="text-sm font-bold text-txt-muted mb-2">💬 Answer the AI follow-up:</p>
|
||||||
|
<textarea
|
||||||
|
className="input-field min-h-[80px] resize-y mb-3"
|
||||||
|
value={clarifyInput}
|
||||||
|
onChange={(e) => setClarifyInput(e.target.value)}
|
||||||
|
placeholder="Add extra context..."
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn-magenta" onClick={handleClarify} disabled={loading}>
|
||||||
|
{loading ? "Sending..." : "Send"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ready for PM */}
|
||||||
|
{status !== "clarifying" && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<div className="bg-green-50 text-green-800 px-4 py-2 text-sm mb-4">
|
||||||
|
✅ Requirement is confirmed and ready for PM analysis.
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn-magenta" onClick={handlePmRun} disabled={loading}>
|
||||||
|
{loading ? "Analyzing..." : "Run PM Analysis →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Step 1: PM Analysis ═══ */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-lg">📋</span>
|
||||||
|
<h2 className="text-lg font-extrabold">PM Requirement Analysis</h2>
|
||||||
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Streaming output...</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Streaming/result display */}
|
||||||
|
<div className="bg-surface-muted p-6 text-sm leading-relaxed whitespace-pre-wrap mb-4 max-h-[50vh] overflow-y-auto">
|
||||||
|
{pmStream || "Waiting for stream..."}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Structured result sections */}
|
||||||
|
{pmResult && pmResult.functional?.length > 0 && (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{pmResult.summary && (
|
||||||
|
<div><span className="text-xs font-bold text-txt-muted">📌 Summary</span><p className="text-sm mt-1">{pmResult.summary}</p></div>
|
||||||
|
)}
|
||||||
|
{pmResult.functional.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-bold text-txt-muted">🔧 Functional Requirements</span>
|
||||||
|
<ul className="text-sm mt-1 list-disc list-inside">{pmResult.functional.map((f, i) => <li key={i}>{f}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4 flex gap-3">
|
||||||
|
<input
|
||||||
|
className="input-field flex-1"
|
||||||
|
value={pmFeedback}
|
||||||
|
onChange={(e) => setPmFeedback(e.target.value)}
|
||||||
|
placeholder="Provide feedback to refine PM output..."
|
||||||
|
/>
|
||||||
|
<button className="btn-outline" onClick={handlePmRefine} disabled={!pmFeedback.trim()}>Refine</button>
|
||||||
|
<button className="btn-magenta" onClick={handleQaRun}>Generate QA Cases →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Step 2: QA Test Cases ═══ */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-lg">🧪</span>
|
||||||
|
<h2 className="text-lg font-extrabold">QA Test Cases</h2>
|
||||||
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Generating...</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test cases table */}
|
||||||
|
{testCases.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="w-full text-sm border border-border">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-muted text-left">
|
||||||
|
<th className="px-3 py-2 border-b border-border font-bold">ID</th>
|
||||||
|
<th className="px-3 py-2 border-b border-border font-bold">Case</th>
|
||||||
|
<th className="px-3 py-2 border-b border-border font-bold">Precondition</th>
|
||||||
|
<th className="px-3 py-2 border-b border-border font-bold">Steps</th>
|
||||||
|
<th className="px-3 py-2 border-b border-border font-bold">Expected</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{testCases.map((tc, i) => (
|
||||||
|
<tr key={tc.id || i} className="border-b border-border">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{tc.id}</td>
|
||||||
|
<td className="px-3 py-2">{tc.name}</td>
|
||||||
|
<td className="px-3 py-2 text-txt-muted">{tc.precondition}</td>
|
||||||
|
<td className="px-3 py-2">{tc.steps}</td>
|
||||||
|
<td className="px-3 py-2">{tc.expected}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface-muted p-6 text-sm leading-relaxed whitespace-pre-wrap mb-4 max-h-[50vh] overflow-y-auto">
|
||||||
|
{qaStream || "Waiting for stream..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4 flex gap-3">
|
||||||
|
<input
|
||||||
|
className="input-field flex-1"
|
||||||
|
value={qaFeedback}
|
||||||
|
onChange={(e) => setQaFeedback(e.target.value)}
|
||||||
|
placeholder="Provide feedback to refine QA output..."
|
||||||
|
/>
|
||||||
|
<button className="btn-outline" disabled={!qaFeedback.trim()}>Refine</button>
|
||||||
|
<button className="btn-magenta" onClick={handleDevRun}>Generate Dev Output →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Step 3: Dev Code ═══ */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-lg">💻</span>
|
||||||
|
<h2 className="text-lg font-extrabold">Dev Code Generation</h2>
|
||||||
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Generating...</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-4 border-b border-border">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["code", "🐍 Service Code"],
|
||||||
|
["test", "🧪 Unit Tests"],
|
||||||
|
["notes", "📄 Notes"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`px-4 py-2 text-sm font-bold border-b-2 transition-colors ${
|
||||||
|
codeTab === key ? "border-magenta text-magenta" : "border-transparent text-txt-muted hover:text-txt"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCodeTab(key)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-dark text-gray-300 p-6 text-[0.85rem] font-mono leading-relaxed max-h-[50vh] overflow-y-auto whitespace-pre-wrap">
|
||||||
|
{loading
|
||||||
|
? devStream || "Waiting for stream..."
|
||||||
|
: codeTab === "code"
|
||||||
|
? devCode || "No service code"
|
||||||
|
: codeTab === "test"
|
||||||
|
? devTest || "No test code"
|
||||||
|
: devNotes || "No notes"
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(codeTab === "code" ? devCode : codeTab === "test" ? devTest : devNotes);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button className="btn-magenta" onClick={handleTestRun}>Run Tests →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Step 4: Test Execution ═══ */}
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-lg">▶</span>
|
||||||
|
<h2 className="text-lg font-extrabold">Test Execution</h2>
|
||||||
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Running...</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult ? (
|
||||||
|
<>
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-green-50 p-4 text-center">
|
||||||
|
<div className="text-2xl font-extrabold text-green-600">{testResult.passed}</div>
|
||||||
|
<div className="text-xs font-bold text-green-700 mt-1">✅ Passed</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 p-4 text-center">
|
||||||
|
<div className="text-2xl font-extrabold text-red-600">{testResult.failed}</div>
|
||||||
|
<div className="text-xs font-bold text-red-700 mt-1">❌ Failed</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 p-4 text-center">
|
||||||
|
<div className="text-2xl font-extrabold text-yellow-600">{testResult.errors}</div>
|
||||||
|
<div className="text-xs font-bold text-yellow-700 mt-1">⚠️ Errors</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-muted p-4 text-center">
|
||||||
|
<div className="text-2xl font-extrabold text-txt">{testResult.total}</div>
|
||||||
|
<div className="text-xs font-bold text-txt-muted mt-1">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output */}
|
||||||
|
<pre className="bg-surface-dark text-gray-300 p-6 text-[0.85rem] font-mono leading-relaxed max-h-[40vh] overflow-y-auto whitespace-pre-wrap">
|
||||||
|
{testResult.output}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div className="border-t border-border pt-4 mt-4 flex justify-end gap-3">
|
||||||
|
<button className="btn-outline" onClick={handleTestRun} disabled={loading}>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
{testResult.failed > 0 && (
|
||||||
|
<button className="btn-magenta" onClick={handleTestFix} disabled={loading}>
|
||||||
|
AI Auto-fix
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-txt-muted text-sm">
|
||||||
|
{loading ? "Running pytest..." : "Waiting for test execution"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/pages/PlanningAgent.tsx
Normal file
361
src/pages/PlanningAgent.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||||
|
import { API } from "../config";
|
||||||
|
|
||||||
|
/* ─── Types ─── */
|
||||||
|
type ChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
ts: number;
|
||||||
|
status?: "sending" | "sent" | "failed";
|
||||||
|
};
|
||||||
|
|
||||||
|
type StreamEvent = {
|
||||||
|
type: "thought" | "tool_call" | "tool_result" | "final" | "error";
|
||||||
|
content: string;
|
||||||
|
step?: number;
|
||||||
|
tool_name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
chatting: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "add-msg"; msg: ChatMessage }
|
||||||
|
| { type: "update-msg"; id: string; patch: Partial<ChatMessage> }
|
||||||
|
| { type: "set-chatting"; v: boolean }
|
||||||
|
| { type: "set-error"; v?: string }
|
||||||
|
| { type: "reset"; sessionId: string; userId: string };
|
||||||
|
|
||||||
|
/* ─── Helpers ─── */
|
||||||
|
function uid() {
|
||||||
|
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeId(prefix: string) {
|
||||||
|
if (crypto.randomUUID) return `${prefix}_${crypto.randomUUID()}`;
|
||||||
|
return `${prefix}_${uid()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("safeos.planning.session");
|
||||||
|
if (raw) {
|
||||||
|
const d = JSON.parse(raw);
|
||||||
|
if (d.sessionId && d.userId) return d as { sessionId: string; userId: string };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
||||||
|
localStorage.setItem("safeos.planning.session", JSON.stringify(fresh));
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "add-msg":
|
||||||
|
return { ...state, messages: [...state.messages, action.msg] };
|
||||||
|
case "update-msg":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
messages: state.messages.map((m) =>
|
||||||
|
m.id === action.id ? { ...m, ...action.patch } : m,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "set-chatting":
|
||||||
|
return { ...state, chatting: action.v };
|
||||||
|
case "set-error":
|
||||||
|
return { ...state, error: action.v };
|
||||||
|
case "reset": {
|
||||||
|
localStorage.setItem(
|
||||||
|
"safeos.planning.session",
|
||||||
|
JSON.stringify({ sessionId: action.sessionId, userId: action.userId }),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
userId: action.userId,
|
||||||
|
messages: [],
|
||||||
|
chatting: false,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SSE streaming ─── */
|
||||||
|
async function streamChat(
|
||||||
|
payload: { text: string; session_id: string; user_id: string; file_ids?: string[] },
|
||||||
|
onEvent: (e: StreamEvent) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${API.planning}/chat/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||||
|
if (!res.body) throw new Error("No response body");
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
let idx = buf.indexOf("\n\n");
|
||||||
|
while (idx >= 0) {
|
||||||
|
const pkt = buf.slice(0, idx);
|
||||||
|
buf = buf.slice(idx + 2);
|
||||||
|
for (const line of pkt.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t.startsWith("data:")) continue;
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(t.slice(5).trim()));
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = buf.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
buf += dec.decode();
|
||||||
|
flush();
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Upload file ─── */
|
||||||
|
async function uploadFile(file: File, sessionId: string, userId: string) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("session_id", sessionId);
|
||||||
|
fd.append("user_id", userId);
|
||||||
|
const res = await fetch(`${API.planning}/upload`, { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error("Upload failed");
|
||||||
|
return (await res.json()) as { file_id: string; file_name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ━━━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||||
|
export default function PlanningAgent() {
|
||||||
|
const sess = useRef(getSession());
|
||||||
|
const [state, dispatch] = useReducer(reducer, {
|
||||||
|
sessionId: sess.current.sessionId,
|
||||||
|
userId: sess.current.userId,
|
||||||
|
messages: [],
|
||||||
|
chatting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [files, setFiles] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [state.messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Send message */
|
||||||
|
const send = useCallback(async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || state.chatting) return;
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = { id: uid(), role: "user", content: text, ts: Date.now(), status: "sending" };
|
||||||
|
const assistMsg: ChatMessage = { id: uid(), role: "assistant", content: "", ts: Date.now(), status: "sending" };
|
||||||
|
dispatch({ type: "add-msg", msg: userMsg });
|
||||||
|
dispatch({ type: "add-msg", msg: assistMsg });
|
||||||
|
dispatch({ type: "set-chatting", v: true });
|
||||||
|
dispatch({ type: "set-error" });
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
const traces: string[] = [];
|
||||||
|
let finalText = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamChat(
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
session_id: state.sessionId,
|
||||||
|
user_id: state.userId,
|
||||||
|
file_ids: files.map((f) => f.id),
|
||||||
|
},
|
||||||
|
(evt) => {
|
||||||
|
if (evt.type === "final") {
|
||||||
|
finalText = evt.content;
|
||||||
|
} else if (evt.type === "error") {
|
||||||
|
traces.push(`[error] ${evt.content}`);
|
||||||
|
} else {
|
||||||
|
const prefix = evt.step !== undefined ? `[step ${evt.step}] ` : "";
|
||||||
|
const tool = evt.tool_name ? `(${evt.tool_name})` : "";
|
||||||
|
traces.push(`${prefix}${evt.type}${tool}: ${evt.content}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (traces.length) parts.push(traces.join("\n"));
|
||||||
|
if (finalText) parts.push(finalText);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "update-msg",
|
||||||
|
id: assistMsg.id,
|
||||||
|
patch: { content: parts.join("\n\n") || "思考中..." },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({ type: "update-msg", id: userMsg.id, patch: { status: "sent" } });
|
||||||
|
dispatch({ type: "update-msg", id: assistMsg.id, patch: { status: "sent" } });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== "AbortError") {
|
||||||
|
dispatch({
|
||||||
|
type: "update-msg",
|
||||||
|
id: assistMsg.id,
|
||||||
|
patch: { content: `Error: ${(err as Error).message}`, status: "failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "set-chatting", v: false });
|
||||||
|
}
|
||||||
|
}, [input, state.chatting, state.sessionId, state.userId, files]);
|
||||||
|
|
||||||
|
/* Upload */
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || uploading) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const data = await uploadFile(file, state.sessionId, state.userId);
|
||||||
|
setFiles((prev) => [{ id: data.file_id, name: data.file_name }, ...prev]);
|
||||||
|
} catch {
|
||||||
|
dispatch({ type: "set-error", v: "文件上传失败" });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Reset */
|
||||||
|
const handleReset = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
||||||
|
dispatch({ type: "reset", ...fresh });
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
{/* ─── Main Chat ─── */}
|
||||||
|
<div className="h-full flex flex-col min-w-0">
|
||||||
|
<div className="flex-1 overflow-y-auto px-10 py-8">
|
||||||
|
<div className="flex flex-col gap-6 max-w-5xl w-full mx-auto">
|
||||||
|
{state.messages.length === 0 && (
|
||||||
|
<div className="text-center py-20 card">
|
||||||
|
<h2 className="text-2xl font-extrabold mb-2">Planning Council Agent</h2>
|
||||||
|
<p className="text-txt-muted text-sm">
|
||||||
|
Share a high-level Epic and the agent will plan through PM, Architect, and RTE perspectives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`max-w-[78%] px-6 py-5 text-[0.95rem] leading-relaxed whitespace-pre-wrap rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "self-end mr-1 bg-surface-muted text-txt border border-border"
|
||||||
|
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.role === "assistant" && (
|
||||||
|
<span className="badge mb-3 block w-fit">SYSTEM / RE-ACT LOOP</span>
|
||||||
|
)}
|
||||||
|
{msg.content || "Thinking..."}
|
||||||
|
{msg.status === "failed" && (
|
||||||
|
<span className="text-red-500 text-xs block mt-2">Failed to send</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{state.error && (
|
||||||
|
<div className="mx-10 mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
||||||
|
{state.error}
|
||||||
|
<button
|
||||||
|
className="text-red-400 hover:text-red-600 font-bold"
|
||||||
|
onClick={() => dispatch({ type: "set-error" })}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="px-10 pb-6 pt-4 border-t border-border">
|
||||||
|
{/* File upload row */}
|
||||||
|
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||||
|
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
||||||
|
{uploading ? "Uploading..." : "Attach File"}
|
||||||
|
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} />
|
||||||
|
</label>
|
||||||
|
{files.map((f) => (
|
||||||
|
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="ml-auto text-xs text-txt-muted hover:text-magenta"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
Reset Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
|
<textarea
|
||||||
|
className="input-field flex-1 min-h-[88px] max-h-56 resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Describe your Epic or provide guidance..."
|
||||||
|
disabled={state.chatting}
|
||||||
|
/>
|
||||||
|
<button className="btn-magenta" onClick={send} disabled={state.chatting}>
|
||||||
|
{state.chatting ? "Planning..." : "Send"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
436
src/pages/QualityGate.tsx
Normal file
436
src/pages/QualityGate.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { API } from "../config";
|
||||||
|
|
||||||
|
type PRScan = {
|
||||||
|
id: number;
|
||||||
|
pr_number: number;
|
||||||
|
repo_name: string;
|
||||||
|
pr_title: string;
|
||||||
|
pr_url: string;
|
||||||
|
source_branch: string;
|
||||||
|
target_branch: string;
|
||||||
|
author: string;
|
||||||
|
state: "open" | "merged" | "closed";
|
||||||
|
scan_status: "pending" | "completed";
|
||||||
|
issues_count: number;
|
||||||
|
security_issues: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PRDetail = PRScan & {
|
||||||
|
scan_result: Record<string, unknown>;
|
||||||
|
scan_details_with_code: unknown[];
|
||||||
|
ai_review: unknown;
|
||||||
|
report_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChangedFile = {
|
||||||
|
filename: string;
|
||||||
|
status: string;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileContent = {
|
||||||
|
content: string;
|
||||||
|
issues: Array<{
|
||||||
|
line: number;
|
||||||
|
severity: string;
|
||||||
|
message: string;
|
||||||
|
rule?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type View = "dashboard" | "pr-list" | "settings";
|
||||||
|
|
||||||
|
type QualityGateProps = {
|
||||||
|
view: View;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE = API.quality;
|
||||||
|
|
||||||
|
async function apiGet<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`);
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QualityGate({ view }: QualityGateProps) {
|
||||||
|
const [prs, setPrs] = useState<PRScan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [filter, setFilter] = useState<"all" | "open" | "merged" | "closed">("all");
|
||||||
|
|
||||||
|
const [selectedPR, setSelectedPR] = useState<PRDetail | null>(null);
|
||||||
|
const [changedFiles, setChangedFiles] = useState<ChangedFile[]>([]);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string>("");
|
||||||
|
const [fileContent, setFileContent] = useState<FileContent | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchPRs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const query = filter === "all" ? "" : `?state=${filter}`;
|
||||||
|
const data = await apiGet<{ prs: PRScan[] } | PRScan[]>(`/prs${query}`);
|
||||||
|
setPrs(Array.isArray(data) ? data : data.prs || []);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPRs();
|
||||||
|
}, [fetchPRs]);
|
||||||
|
|
||||||
|
const openPRDetail = useCallback(async (prId: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [detail, files] = await Promise.all([
|
||||||
|
apiGet<PRDetail>(`/prs/${prId}`),
|
||||||
|
apiGet<{ files: ChangedFile[] } | ChangedFile[]>(`/prs/${prId}/files`),
|
||||||
|
]);
|
||||||
|
setSelectedPR(detail);
|
||||||
|
const fileList = Array.isArray(files) ? files : files.files || [];
|
||||||
|
setChangedFiles(fileList);
|
||||||
|
if (fileList.length > 0) {
|
||||||
|
setSelectedFile(fileList[0].filename);
|
||||||
|
}
|
||||||
|
setModalOpen(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen || !selectedPR || !selectedFile) return;
|
||||||
|
setFileContent(null);
|
||||||
|
apiGet<FileContent>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`)
|
||||||
|
.then(setFileContent)
|
||||||
|
.catch(() => setFileContent({ content: "// Failed to load file", issues: [] }));
|
||||||
|
}, [modalOpen, selectedPR, selectedFile]);
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
if (!selectedPR) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiPost(`/prs/${selectedPR.id}/merge`);
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchPRs();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (!selectedPR) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiPost(`/prs/${selectedPR.id}/close`);
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchPRs();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
pending: prs.filter((p) => p.state === "open").length,
|
||||||
|
passed: prs.filter((p) => p.state === "merged").length,
|
||||||
|
rejected: prs.filter((p) => p.state === "closed").length,
|
||||||
|
totalIssues: prs.reduce((s, p) => s + p.issues_count, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center rounded-xl border border-red-200">
|
||||||
|
{error}
|
||||||
|
<button className="font-bold text-red-400 hover:text-red-600" onClick={() => setError("")}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "dashboard" && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-extrabold mb-6">Overview</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-5 mb-8">
|
||||||
|
{[
|
||||||
|
{ label: "Open PRs", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" },
|
||||||
|
{ label: "Merged", value: stats.passed, color: "text-green-600", bg: "bg-green-50" },
|
||||||
|
{ label: "Rejected", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" },
|
||||||
|
{ label: "Total Issues", value: stats.totalIssues, color: "text-magenta", bg: "bg-magenta-50" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className={`${s.bg} p-6 border border-border rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.04)]`}>
|
||||||
|
<div className={`text-3xl font-extrabold ${s.color}`}>{s.value}</div>
|
||||||
|
<div className="text-sm font-bold text-txt-muted mt-2">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-extrabold mb-4">Recent Pull Requests</h3>
|
||||||
|
<PRTable prs={prs.slice(0, 8)} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "pr-list" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-extrabold">Pull Request List</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["all", "open", "merged", "closed"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-bold border rounded-lg transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? "border-magenta text-magenta bg-magenta-50"
|
||||||
|
: "border-border text-txt-muted hover:border-magenta"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === "all" ? "All" : f === "open" ? "Open" : f === "merged" ? "Merged" : "Closed"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="btn-outline text-xs px-3 py-1.5" onClick={fetchPRs}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PRTable prs={prs} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "settings" && (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<h2 className="text-xl font-extrabold mb-6">Settings</h2>
|
||||||
|
<div className="card mb-4">
|
||||||
|
<h3 className="font-bold mb-2">Webhook Configuration</h3>
|
||||||
|
<p className="text-sm text-txt-muted mb-3">Add this URL to your Gitea repository webhook settings:</p>
|
||||||
|
<code className="block bg-surface-muted p-3 text-sm font-mono break-all rounded-lg">
|
||||||
|
POST {window.location.origin}/quality-api/webhook/gitea
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-bold mb-2">Quick Notes</h3>
|
||||||
|
<ul className="text-sm text-txt-muted list-disc list-inside space-y-1">
|
||||||
|
<li>Supports Gitea Push and Pull Request events</li>
|
||||||
|
<li>Runs Pylint, Flake8, ESLint, and Bandit automatically</li>
|
||||||
|
<li>Optional AI review using DeepSeek-V3</li>
|
||||||
|
<li>Scan summary can be pushed to Feishu channels</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalOpen && selectedPR && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white w-[92vw] h-[88vh] flex flex-col rounded-2xl overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.2)]">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-extrabold">
|
||||||
|
#{selectedPR.pr_number} {selectedPR.pr_title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-txt-muted mt-1">
|
||||||
|
{selectedPR.author} · {selectedPR.source_branch} → {selectedPR.target_branch} · {selectedPR.repo_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-txt-muted hover:text-txt font-bold text-lg"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<div className="w-[260px] shrink-0 border-r border-border overflow-y-auto p-4 bg-surface-muted/35">
|
||||||
|
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">Changed Files</h3>
|
||||||
|
{changedFiles.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.filename}
|
||||||
|
className={`block w-full text-left px-3 py-2 text-sm truncate transition-colors rounded-lg ${
|
||||||
|
selectedFile === f.filename
|
||||||
|
? "bg-magenta-50 text-magenta font-bold"
|
||||||
|
: "text-txt hover:bg-surface-muted"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedFile(f.filename)}
|
||||||
|
>
|
||||||
|
<span className={`inline-block w-4 mr-1 text-xs font-bold ${
|
||||||
|
f.status === "added" ? "text-green-600" : f.status === "removed" ? "text-red-600" : "text-yellow-600"
|
||||||
|
}`}>
|
||||||
|
{f.status === "added" ? "A" : f.status === "removed" ? "D" : "M"}
|
||||||
|
</span>
|
||||||
|
{f.filename.split("/").pop()}
|
||||||
|
<span className="text-xs text-txt-muted ml-1 block truncate">{f.filename}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto bg-white">
|
||||||
|
{fileContent ? (
|
||||||
|
<pre className="text-sm font-mono leading-6 p-4">
|
||||||
|
{fileContent.content.split("\n").map((line, i) => {
|
||||||
|
const lineNum = i + 1;
|
||||||
|
const issues = fileContent.issues.filter((iss) => iss.line === lineNum);
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex ${issues.length > 0 ? "bg-red-50/80" : ""}`}>
|
||||||
|
<span className="w-12 shrink-0 text-right pr-4 text-txt-muted select-none text-xs leading-6">
|
||||||
|
{lineNum}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 whitespace-pre-wrap">{line}</span>
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<span className="shrink-0 px-2 text-xs text-red-600 max-w-xs truncate" title={issues[0].message}>
|
||||||
|
● {issues[0].message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-txt-muted text-sm">
|
||||||
|
{selectedFile ? "Loading file..." : "Select a file to inspect"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[220px] shrink-0 border-l border-border overflow-y-auto p-4 bg-surface-muted/60">
|
||||||
|
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">Issue Panel</h3>
|
||||||
|
{fileContent?.issues.length === 0 && (
|
||||||
|
<p className="text-xs text-txt-muted">No issues</p>
|
||||||
|
)}
|
||||||
|
{fileContent?.issues.map((iss, i) => (
|
||||||
|
<div key={i} className="mb-3 p-2 bg-white border border-border text-xs rounded-lg">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<span className={`font-bold ${
|
||||||
|
iss.severity === "error" ? "text-red-600" : iss.severity === "warning" ? "text-yellow-600" : "text-blue-600"
|
||||||
|
}`}>
|
||||||
|
{iss.severity === "error" ? "❌" : iss.severity === "warning" ? "⚠️" : "ℹ️"}
|
||||||
|
</span>
|
||||||
|
<span className="text-txt-muted">L{iss.line}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-txt">{iss.message}</p>
|
||||||
|
{iss.rule && <p className="text-txt-muted mt-0.5">{iss.rule}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
|
||||||
|
<button className="btn-outline" onClick={() => setModalOpen(false)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bg-red-600 text-white font-bold text-sm px-6 py-2.5 border-none cursor-pointer hover:opacity-90 disabled:opacity-50 rounded-xl"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading || selectedPR.state !== "open"}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-magenta"
|
||||||
|
onClick={handleMerge}
|
||||||
|
disabled={loading || selectedPR.state !== "open"}
|
||||||
|
>
|
||||||
|
Approve & Merge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PRTable({
|
||||||
|
prs,
|
||||||
|
loading,
|
||||||
|
onView,
|
||||||
|
}: {
|
||||||
|
prs: PRScan[];
|
||||||
|
loading: boolean;
|
||||||
|
onView: (pr: PRScan) => void;
|
||||||
|
}) {
|
||||||
|
if (loading && prs.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-txt-muted text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prs.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-txt-muted text-sm">No PR records found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-muted text-left">
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">PR#</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Title</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Repository</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Author</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Branch</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">State</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Issues</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Created</th>
|
||||||
|
<th className="px-4 py-3 border-b border-border font-bold">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{prs.map((pr) => (
|
||||||
|
<tr key={pr.id} className="border-b border-border hover:bg-surface-muted/50 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">#{pr.pr_number}</td>
|
||||||
|
<td className="px-4 py-3 font-bold max-w-[220px] truncate">{pr.pr_title}</td>
|
||||||
|
<td className="px-4 py-3 text-txt-muted text-xs">{pr.repo_name}</td>
|
||||||
|
<td className="px-4 py-3 text-txt-muted">{pr.author}</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono text-txt-muted">
|
||||||
|
{pr.source_branch} → {pr.target_branch}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`badge ${
|
||||||
|
pr.state === "open"
|
||||||
|
? "bg-yellow-100 text-yellow-700"
|
||||||
|
: pr.state === "merged"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-red-100 text-red-700"
|
||||||
|
}`}>
|
||||||
|
{pr.state === "open" ? "Open" : pr.state === "merged" ? "Merged" : "Closed"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`font-bold ${pr.issues_count > 0 ? "text-red-600" : "text-green-600"}`}>
|
||||||
|
{pr.issues_count}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-txt-muted">
|
||||||
|
{new Date(pr.created_at).toLocaleString("en-US")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
className="text-magenta font-bold text-xs hover:underline"
|
||||||
|
onClick={() => onView(pr)}
|
||||||
|
>
|
||||||
|
Inspect →
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
34
tailwind.config.js
Normal file
34
tailwind.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
magenta: {
|
||||||
|
DEFAULT: "#E20074",
|
||||||
|
50: "#FFF0F7",
|
||||||
|
100: "#FFE0EF",
|
||||||
|
600: "#E20074",
|
||||||
|
700: "#B8005E",
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
DEFAULT: "#FFFFFF",
|
||||||
|
muted: "#F2F2F2",
|
||||||
|
dark: "#1A1A1A",
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
DEFAULT: "#E5E5E5",
|
||||||
|
},
|
||||||
|
txt: {
|
||||||
|
DEFAULT: "#1A1A1A",
|
||||||
|
muted: "#666666",
|
||||||
|
inverse: "#FFFFFF",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Inter"', '"Roboto"', "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
21
tsconfig.app.json
Normal file
21
tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
29
vite.config.ts
Normal file
29
vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 4000,
|
||||||
|
proxy: {
|
||||||
|
"/planning-api": {
|
||||||
|
target: env.VITE_PLANNING_API_BASE || "http://localhost:8090",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/planning-api/, "/api"),
|
||||||
|
},
|
||||||
|
"/devops-api": {
|
||||||
|
target: env.VITE_DEVOPS_API_BASE || "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/devops-api/, ""),
|
||||||
|
},
|
||||||
|
"/quality-api": {
|
||||||
|
target: env.VITE_QUALITY_API_BASE || "http://localhost:5000",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/quality-api/, "/api"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user