Files
safe-os-ui/src/pages/DevOpsAgent.tsx
2026-03-13 16:41:31 +08:00

1080 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { API } from "../config";
/* ─── Types ─── */
type Step = 0 | 1 | 2 | 3 | 4;
type ClarifyMsg = { role: "user" | "assistant"; content: string };
type RequirementAnalysis = {
summary: string;
functional_requirements: string[];
non_functional_requirements: string[];
acceptance_criteria: string[];
edge_cases: string[];
};
type TestCase = {
test_id: string;
test_name: string;
precondition: string;
steps: string[] | string;
expected_result: string;
test_type?: string;
};
type CodeGeneration = {
java_code: string;
unit_tests: string;
implementation_notes: string;
};
type TestExecution = {
success: boolean;
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();
}
// SSE 格式data: {"type":"chunk","text":"..."} / {"type":"done",...} / {"type":"error","message":"..."}
async function readSSE(
path: string,
onChunk: (text: string) => void,
onDone?: (data: any) => 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 });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
try {
const evt = JSON.parse(payload);
if (evt.type === "chunk" && evt.text) {
onChunk(evt.text);
} else if (evt.type === "done") {
onDone?.(evt);
return;
} else if (evt.type === "error") {
throw new Error(evt.message || "Stream error");
} else if (evt.text) {
onChunk(evt.text);
} else if (evt.message) {
onChunk(evt.message);
}
} catch (e) {
if (e instanceof Error && !e.message.startsWith("Stream error")) onChunk(payload);
else throw e;
}
}
}
} finally {
reader.releaseLock();
}
}
/* ── 流式 JSON 增量解析工具 ── */
function parseStreamStr(text: string, fieldName: string): { value: string; active: boolean } {
const idx = text.indexOf(`"${fieldName}"`);
if (idx === -1) return { value: "", active: false };
let start = idx + fieldName.length + 2;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== '"') return { value: "", active: false };
start++;
let value = "", i = start, closed = false;
while (i < text.length) {
if (text[i] === "\\" && i + 1 < text.length) {
const e = text[i + 1];
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
i += 2;
} else if (text[i] === '"') { closed = true; break; }
else { value += text[i++]; }
}
return { value, active: !closed };
}
function parseStreamStrArray(text: string, fieldName: string): { items: { value: string; active: boolean }[]; active: boolean } {
const idx = text.indexOf(`"${fieldName}"`);
if (idx === -1) return { items: [], active: false };
let start = idx + fieldName.length + 2;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== "[") return { items: [], active: false };
start++;
const items: { value: string; active: boolean }[] = [];
let i = start, arrayClosed = false;
while (i < text.length) {
if (text[i] === "]") { arrayClosed = true; break; }
if (text[i] === '"') {
i++;
let value = "", itemClosed = false;
while (i < text.length) {
if (text[i] === "\\" && i + 1 < text.length) {
const e = text[i + 1];
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
i += 2;
} else if (text[i] === '"') { itemClosed = true; i++; break; }
else { value += text[i++]; }
}
if (value.trim()) items.push({ value, active: !itemClosed });
} else { i++; }
}
return { items, active: !arrayClosed };
}
function parseStreamTestCaseNames(text: string): { names: string[]; active: boolean } {
const idx = text.indexOf('"test_cases"');
if (idx === -1) return { names: [], active: false };
let start = idx + 12;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== "[") return { names: [], active: false };
let depth = 0, inStr = false, arrayClosed = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inStr) { if (ch === "\\") i++; else if (ch === '"') inStr = false; }
else { if (ch === '"') inStr = true; else if (ch === "[") depth++; else if (ch === "]") { depth--; if (depth === 0) { arrayClosed = true; break; } } }
}
const arrayText = text.slice(start);
const names: string[] = [];
const re = /"test_name"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(arrayText)) !== null) {
names.push(m[1].replace(/\\n/g, " ").replace(/\\"/g, '"'));
}
return { names, active: !arrayClosed };
}
type DevStreamResult = { java_code: string; unit_tests: string; implementation_notes: string; currentField: string | null };
function parseDevStream(text: string): DevStreamResult {
const result: DevStreamResult = {
java_code: "", unit_tests: "", implementation_notes: "", currentField: null,
};
for (const field of ["java_code", "unit_tests", "implementation_notes" ] as const) {
const key = `"${field}"`;
const idx = text.indexOf(key);
if (idx === -1) continue;
let start = idx + key.length;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== '"') continue;
start++;
let value = "", i = start, closed = false;
while (i < text.length) {
if (text[i] === "\\" && i + 1 < text.length) {
const e = text[i + 1];
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
i += 2;
} else if (text[i] === '"') { closed = true; break; }
else { value += text[i++]; }
}
result[field] = value;
if (!closed) result.currentField = field;
}
return result;
}
/* ── 步骤标签 ── */
const STEPS = [
{ title: "Requirements", icon: "💬" },
{ title: "PM Analysis", icon: "📋" },
{ title: "QA Cases", icon: "🧪" },
{ title: "Dev Code", icon: "💻" },
{ title: "Test Run", icon: "▶" },
];
const STATUS_STEP: Record<string, number> = {
clarifying: 0, pm_ready: 0,
pm_done: 1, qa_ready: 1,
qa_done: 2, dev_ready: 2,
dev_done: 3, test_done: 4,
};
const STATUS_LABEL: Record<string, string> = {
clarifying: "Clarifying", pm_ready: "Ready",
pm_done: "PM Done", qa_ready: "PM Done",
qa_done: "QA Done", dev_ready: "QA Done",
dev_done: "Code Ready", test_done: "Tests Done",
};
const TYPE_EMOJI: Record<string, string> = {
"Functional": "🧩", "Performance": "⚡", "Security": "🔒",
"功能测试": "🧩", "性能测试": "⚡", "安全测试": "🔒",
};
const TYPE_LABEL: Record<string, string> = {
"功能测试": "Functional", "性能测试": "Performance",
"安全测试": "Security", "边界测试": "Boundary",
"异常测试": "Exception", "集成测试": "Integration",
};
/* ━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━ */
export default function DevOpsAgent() {
const [step, setStep] = useState<Step>(0);
const [sessionId, setSessionId] = useState("");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState("");
const [streamText, setStreamText] = useState("");
// Step 0
const [requirement, setRequirement] = useState("");
const [rawRequirement, setRawRequirement] = useState("");
const [clarifyHistory, setClarifyHistory] = useState<ClarifyMsg[]>([]);
const [clarifyInput, setClarifyInput] = useState("");
// Step 1
const [requirementAnalysis, setRequirementAnalysis] = useState<RequirementAnalysis | null>(null);
const [pmFeedback, setPmFeedback] = useState("");
// Step 2
const [testCases, setTestCases] = useState<TestCase[]>([]);
const [testStrategy, setTestStrategy] = useState("");
const [coveragePlan, setCoveragePlan] = useState("");
const [qaFeedback, setQaFeedback] = useState("");
// Step 3
const [codeGeneration, setCodeGeneration] = useState<CodeGeneration | null>(null);
const [codeTab, setCodeTab] = useState<"java_code" | "unit_tests" | "implementation_notes">("java_code");
const [devStreamTab, setDevStreamTab] = useState<"java_code" | "unit_tests" | "implementation_notes">("java_code");
// Step 4
const [testExecution, setTestExecution] = useState<TestExecution | null>(null);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const pmStreamRef = useRef<HTMLDivElement>(null);
const qaStreamRef = useRef<HTMLDivElement>(null);
const devStreamRef = useRef<HTMLPreElement>(null);
/* ── 流式增量解析 ── */
const pmStreamParsed = useMemo(() => {
if (!streaming || step !== 1) return null;
const t = streamText;
const fr = parseStreamStrArray(t, "functional_requirements");
const nfr = parseStreamStrArray(t, "non_functional_requirements");
const ac = parseStreamStrArray(t, "acceptance_criteria");
const ec = parseStreamStrArray(t, "edge_cases");
const sum = parseStreamStr(t, "summary");
let currentSection: string | null = null;
for (const [key, val] of [["functional_requirements", fr], ["non_functional_requirements", nfr], ["acceptance_criteria", ac], ["edge_cases", ec]] as const) {
if (val.active || val.items.some((i) => i.active)) { currentSection = key; break; }
}
if (!currentSection && sum.active) currentSection = "summary";
return { functional_requirements: fr, non_functional_requirements: nfr, acceptance_criteria: ac, edge_cases: ec, summary: sum, currentSection };
}, [streaming, step, streamText]);
const qaStreamParsed = useMemo(() => {
if (!streaming || step !== 2) return null;
const t = streamText;
const cases = parseStreamTestCaseNames(t);
const strategy = parseStreamStr(t, "test_strategy");
const coverage = parseStreamStr(t, "coverage_plan");
let currentSection: string | null = null;
if (cases.active) currentSection = "test_cases";
else if (strategy.active) currentSection = "test_strategy";
else if (coverage.active) currentSection = "coverage_plan";
else if (cases.names.length) currentSection = "test_cases";
return { testCases: cases, testStrategy: strategy, coveragePlan: coverage, currentSection };
}, [streaming, step, streamText]);
const devStreamParsed = useMemo(() => {
if (!streaming || step !== 3) return null;
return parseDevStream(streamText);
}, [streaming, step, streamText]);
// Dev 流式时自动切换 Tab
useEffect(() => {
if (devStreamParsed?.currentField) {
const f = devStreamParsed.currentField as typeof devStreamTab;
if (["java_code", "unit_tests", "implementation_notes"].includes(f)) setDevStreamTab(f);
}
}, [devStreamParsed?.currentField]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
// 同时滚动各流式容器到底部
if (pmStreamRef.current) pmStreamRef.current.scrollTop = pmStreamRef.current.scrollHeight;
if (qaStreamRef.current) qaStreamRef.current.scrollTop = qaStreamRef.current.scrollHeight;
if (devStreamRef.current) devStreamRef.current.scrollTop = devStreamRef.current.scrollHeight;
}, [streamText]);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
function applySessionData(data: any) {
if (data.status) setStatus(data.status);
if (data.data?.requirement_analysis) setRequirementAnalysis(data.data.requirement_analysis);
if (data.data?.test_cases) {
const tc = data.data.test_cases;
if (tc.test_cases?.length) setTestCases(tc.test_cases);
if (tc.test_strategy) setTestStrategy(tc.test_strategy);
if (tc.coverage_plan) setCoveragePlan(tc.coverage_plan);
}
if (data.data?.code_generation) setCodeGeneration(data.data.code_generation);
if (data.data?.test_execution) setTestExecution(data.data.test_execution);
}
/* ── 流式通用封装 ── */
const withStreaming = useCallback(async (
streamPath: string,
targetStep: Step,
runPath?: string,
) => {
setStreamText("");
setStreaming(true);
setStep(targetStep);
setError("");
if (targetStep === 3) setDevStreamTab("java_code");
try {
if (runPath) await apiPost(runPath);
const ctrl = new AbortController();
abortRef.current = ctrl;
await readSSE(
streamPath,
(text) => setStreamText((prev) => prev + text),
(doneData) => applySessionData(doneData),
ctrl.signal,
);
} catch (e) {
setError((e as Error).message);
} finally {
setStreaming(false);
}
}, []);
/* ── Step 0: 创建会话 ── */
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);
setRawRequirement(requirement.trim());
setStatus(data.status || "clarifying");
const q = data.clarify_questions || data.question;
if (q) setClarifyHistory([{ role: "assistant", content: q }]);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [requirement, loading]);
/* ── Step 0: 需求澄清 ── */
const handleClarify = useCallback(async () => {
if (!clarifyInput.trim() || loading) return;
const msg = clarifyInput.trim();
setClarifyHistory((h) => [...h, { role: "user", content: msg }]);
setClarifyInput("");
setLoading(true);
setError("");
try {
const data: any = await apiPost(`/session/${sessionId}/clarify`, { message: msg });
setStatus(data.status || status);
const q = data.clarify_questions || data.question;
if (q) setClarifyHistory((h) => [...h, { role: "assistant", content: q }]);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [clarifyInput, sessionId, loading, status]);
const handlePmRun = useCallback(() =>
withStreaming(`/session/${sessionId}/pm/stream`, 1),
[sessionId, withStreaming]);
const handlePmRefine = useCallback(async () => {
if (!pmFeedback.trim()) return;
const fb = pmFeedback.trim();
setPmFeedback("");
await withStreaming(`/session/${sessionId}/pm/refine/stream?feedback=${encodeURIComponent(fb)}`, 1);
}, [sessionId, pmFeedback, withStreaming]);
const handleQaRun = useCallback(() =>
withStreaming(`/session/${sessionId}/qa/stream`, 2),
[sessionId, withStreaming]);
const handleQaRefine = useCallback(async () => {
if (!qaFeedback.trim()) return;
const fb = qaFeedback.trim();
setQaFeedback("");
await withStreaming(`/session/${sessionId}/qa/refine/stream?feedback=${encodeURIComponent(fb)}`, 2);
}, [sessionId, qaFeedback, withStreaming]);
const handleDevRun = useCallback(() =>
withStreaming(`/session/${sessionId}/dev/stream`, 3),
[sessionId, withStreaming]);
/* ── Step 4: 测试执行 ── */
const handleTestRun = useCallback(async () => {
setStep(4);
setLoading(true);
setError("");
try {
const data: any = await apiPost(`/session/${sessionId}/test/run`);
const te = data?.data?.test_execution ?? data;
setTestExecution({
success: te.success ?? ((te.failed ?? 0) === 0 && (te.errors ?? 0) === 0),
passed: te.passed ?? 0,
failed: te.failed ?? 0,
errors: te.errors ?? 0,
total: te.total ?? 0,
output: te.output || data.detail || JSON.stringify(data, null, 2),
});
if (data.status) setStatus(data.status);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [sessionId]);
/* ── AI 自动修复 ── */
const handleTestFix = useCallback(async () => {
setTestExecution(null);
await withStreaming(`/session/${sessionId}/test/fix/stream`, 3);
await handleTestRun();
}, [sessionId, withStreaming, handleTestRun]);
/* ── 用例分组 ── */
const groupedTestCases = useMemo(() => {
const groups: Record<string, TestCase[]> = {};
for (const tc of testCases) {
const t = tc.test_type || "Functional";
if (!groups[t]) groups[t] = [];
groups[t].push(tc);
}
return groups;
}, [testCases]);
function parseNotes(text: string): string[] {
if (!text) return [];
return text.split("\n").map((l) => l.replace(/^\d+\.\s*/, "").trim()).filter((l) => l.length > 0);
}
const currentStep = STATUS_STEP[status] ?? step;
const statusLabel = STATUS_LABEL[status] || status;
return (
<div className="flex flex-col h-full overflow-hidden bg-[#f7f7f9]">
{/* ── 步骤栏 ── */}
<div className="shrink-0 border-b border-border bg-white shadow-[0_1px_8px_rgba(0,0,0,0.04)]">
<div className="flex items-center gap-0 px-[60px] py-0">
{STEPS.map((s, i) => (
<div key={i} className="flex items-center">
<button
onClick={() => { if (sessionId && i <= step) setStep(i as Step); }}
className={`flex items-center gap-2.5 px-5 py-4 text-sm font-semibold border-b-2 transition-all duration-200 ${
i === step
? "border-magenta text-magenta"
: i < step
? "border-transparent text-green-600"
: "border-transparent text-txt-muted"
} ${i <= step ? "cursor-pointer" : "cursor-default"}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors ${
i === step
? "bg-magenta text-white"
: i < step
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-400"
}`}>
{i < step ? "✓" : i + 1}
</span>
<span className="hidden sm:block">{s.title}</span>
</button>
{i < STEPS.length - 1 && (
<span className={`mx-1 text-sm ${ i < step ? "text-green-400" : "text-gray-300" }`}></span>
)}
</div>
))}
{sessionId && statusLabel && (
<div className={`ml-auto mr-2 px-3 py-1 text-xs font-bold rounded-full ${
status === "clarifying" ? "bg-amber-50 text-amber-600 border border-amber-200" :
status === "test_done" ? "bg-green-50 text-green-600 border border-green-200" :
"bg-magenta-50 text-magenta border border-magenta/20"
}`}>
{statusLabel}
</div>
)}
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center justify-between px-4 py-2.5 mx-10 mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-xl">
<span className="flex items-center gap-2"><span></span>{error}</span>
<button className="ml-4 font-bold text-red-300 hover:text-red-500" onClick={() => setError("")}></button>
</div>
)}
{/* ── 内容区 ── */}
<div className="flex-1 px-[60px] py-6 overflow-y-auto">
<div className="space-y-0">
{/* ═══ Step 0: Requirement Input ═══ */}
{step === 0 && !sessionId && (
<div className="card">
<div className="flex items-start gap-4 mb-6">
<div className="flex items-center justify-center w-10 h-10 text-xl rounded-2xl bg-magenta/10 shrink-0">📋</div>
<div>
<h2 className="text-base font-bold text-txt mb-0.5">Enter Requirements</h2>
<p className="text-sm text-txt-muted">Describe your product requirements. AI will complete analysis test cases code generation automatically.</p>
</div>
</div>
<textarea
className="input-field min-h-[160px] resize-y mb-5"
value={requirement}
onChange={(e) => setRequirement(e.target.value)}
placeholder="e.g. Implement a user login feature supporting username/password, log all login events, target 1000 QPS concurrency…"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-txt-muted">{requirement.length > 0 ? `${requirement.length} chars` : ""}</span>
<button className="btn-magenta" onClick={handleStart} disabled={loading || !requirement.trim()}>
{loading ? (
<span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>Analyzing</span>
) : "Start AI Analysis →"}
</button>
</div>
</div>
)}
{/* ═══ Step 0: Clarification ═══ */}
{step === 0 && sessionId && (
<div className="card">
<div className="flex items-center gap-3 mb-5">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-magenta/10">💬</div>
<h2 className="text-base font-bold">Requirement Clarification</h2>
</div>
<div className="p-4 mb-5 text-sm bg-[#f9f9f9] border border-border rounded-xl">
<span className="block mb-1.5 text-[11px] font-bold text-txt-muted uppercase tracking-wide">Original Requirement</span>
<p className="leading-relaxed text-txt">{rawRequirement}</p>
</div>
{clarifyHistory.length > 0 && (
<div className="flex flex-col gap-3 mb-5">
{clarifyHistory.map((msg, i) => (
<div
key={i}
className={`max-w-[85%] px-4 py-3 text-sm rounded-2xl ${
msg.role === "user"
? "self-end bg-gray-100 text-txt"
: "self-start bg-white border border-border shadow-sm"
}`}
>
<span className="block mb-1 text-[11px] font-bold text-txt-muted">
{msg.role === "assistant" ? "🤖 AI Assistant" : "👤 You"}
</span>
<span className="leading-relaxed whitespace-pre-wrap">{msg.content}</span>
</div>
))}
</div>
)}
{status === "clarifying" && (
<div className="pt-4 mt-2 border-t border-border">
<p className="mb-2 text-xs font-semibold tracking-wide uppercase text-txt-muted">Reply to AI</p>
<textarea
className="input-field min-h-[80px] resize-y mb-3"
value={clarifyInput}
onChange={(e) => setClarifyInput(e.target.value)}
placeholder="Enter your additional details…"
disabled={loading}
/>
<div className="flex justify-end">
<button className="btn-magenta" onClick={handleClarify} disabled={loading}>
{loading ? <span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>Sending</span> : "Send"}
</button>
</div>
</div>
)}
{status !== "clarifying" && status !== "" && (
<div className="pt-4 mt-2 border-t border-border">
<div className="flex items-center gap-2 px-4 py-3 mb-4 text-sm text-green-700 border border-green-200 bg-green-50 rounded-xl">
<span></span><span>Requirements confirmed. Ready to start PM analysis.</span>
</div>
<div className="flex justify-end">
<button className="btn-magenta" onClick={handlePmRun} disabled={loading || streaming}>
{loading ? "Analyzing…" : "Start PM Analysis →"}
</button>
</div>
</div>
)}
</div>
)}
{/* ═══ Step 1: PM Analysis ═══ */}
{step === 1 && (
<div className="card">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-orange-50">📋</div>
<h2 className="text-base font-bold">PM Analysis</h2>
</div>
{streaming && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
Analyzing {streamText.length} chars
</span>
)}
</div>
{/* Streaming preview */}
{streaming && pmStreamParsed && (
<div ref={pmStreamRef} className="rounded-xl border border-border bg-[#fafafa] p-4 mb-4 space-y-3 max-h-[55vh] overflow-y-auto">
{([
["functional_requirements", "🔧", "Functional Requirements", "bg-blue-50 text-blue-700"],
["non_functional_requirements", "⚙️", "Non-Functional Requirements", "bg-purple-50 text-purple-700"],
["acceptance_criteria", "✅", "Acceptance Criteria", "bg-green-50 text-green-700"],
["edge_cases", "🚧", "Edge Cases", "bg-amber-50 text-amber-700"],
] as const).map(([key, icon, label, color]) => {
const sec = pmStreamParsed[key];
if (!sec.items.length && pmStreamParsed.currentSection !== key) return null;
return (
<div key={key} className={`transition-opacity ${ pmStreamParsed.currentSection === key ? "opacity-100" : "opacity-50" }`}>
<div className={`inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-2 ${color}`}>{icon} {label}</div>
<ul className="pl-1 space-y-1">
{sec.items.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 w-1 h-1 rounded-full bg-current shrink-0 opacity-40"></span>
<span>{item.value}{item.active && <span className="text-magenta animate-pulse ml-0.5">|</span>}</span>
</li>
))}
</ul>
</div>
);
})}
{(pmStreamParsed.summary.value || pmStreamParsed.currentSection === "summary") && (
<div className={`transition-opacity ${ pmStreamParsed.currentSection === "summary" ? "opacity-100" : "opacity-50" }`}>
<div className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-2 bg-gray-100 text-gray-600">📌 Summary</div>
<p className="text-sm leading-relaxed">{pmStreamParsed.summary.value}{pmStreamParsed.summary.active && <span className="text-magenta animate-pulse">|</span>}</p>
</div>
)}
{!pmStreamParsed.currentSection && (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-magenta/30 border-t-magenta animate-spin"></span>
Waiting for AI output
</div>
)}
</div>
)}
{/* Final result — clean list layout */}
{!streaming && requirementAnalysis && (
<div className="mb-4 divide-y divide-border">
{requirementAnalysis.summary && (
<div className="pb-4 mb-1">
<div className="text-[11px] font-bold text-txt-muted uppercase tracking-wide mb-1.5">Summary</div>
<p className="text-sm leading-relaxed text-txt">{requirementAnalysis.summary}</p>
</div>
)}
{[
{ key: "functional_requirements" as const, label: "Functional Requirements" },
{ key: "non_functional_requirements" as const, label: "Non-Functional Requirements" },
{ key: "acceptance_criteria" as const, label: "Acceptance Criteria" },
{ key: "edge_cases" as const, label: "Edge Cases" },
].map(({ key, label }) => {
const items = requirementAnalysis[key];
if (!items?.length) return null;
return (
<div key={key} className="py-4">
<div className="text-[11px] font-bold text-txt-muted uppercase tracking-wide mb-2">{label}</div>
<ul className="space-y-1.5">
{items.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="w-1 h-1 mt-2 rounded-full bg-txt-muted shrink-0"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
{!streaming && !requirementAnalysis && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-border border-t-magenta animate-spin"></span>
Waiting for results
</div>
)}
{!streaming && (
<div className="flex gap-2 pt-4 mt-4 border-t border-border">
<input
className="flex-1 input-field"
value={pmFeedback}
onChange={(e) => setPmFeedback(e.target.value)}
placeholder="Have feedback? Enter here to regenerate…"
/>
<button className="btn-outline" onClick={handlePmRefine} disabled={!pmFeedback.trim() || loading}>Regenerate</button>
<button className="btn-magenta" onClick={handleQaRun} disabled={loading || !!pmFeedback.trim()}>Generate QA Cases </button>
</div>
)}
</div>
)}
{/* ═══ Step 2: QA Test Cases ═══ */}
{step === 2 && (
<div className="card">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-green-50">🧪</div>
<h2 className="text-base font-bold">QA Test Cases</h2>
</div>
{streaming && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
{qaStreamParsed?.testCases.names.length ?? 0} cases generated
</span>
)}
</div>
{/* Streaming preview */}
{streaming && qaStreamParsed && (
<div ref={qaStreamRef} className="rounded-xl border border-border bg-[#fafafa] p-4 mb-4 max-h-[55vh] overflow-y-auto space-y-3">
<div className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-1 bg-green-50 text-green-700">🧪 Test Cases</div>
<div className="space-y-1.5">
{qaStreamParsed.testCases.names.map((name, i) => (
<div key={i} className="flex items-center gap-2.5 py-1.5 px-3 bg-white border border-border rounded-lg text-sm">
<span className="font-mono text-[11px] font-bold text-magenta bg-magenta/10 px-1.5 py-0.5 rounded shrink-0">
TC{String(i + 1).padStart(3, "0")}
</span>
<span className="text-txt">{name}</span>
</div>
))}
{qaStreamParsed.testCases.active && (
<div className="flex items-center gap-2 text-sm text-txt-muted py-1.5 px-3">
<span className="w-3.5 h-3.5 border-2 border-green-200 border-t-green-500 rounded-full animate-spin inline-block"></span>
Generating next case
</div>
)}
{!qaStreamParsed.testCases.names.length && (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-magenta/30 border-t-magenta animate-spin"></span>
Waiting
</div>
)}
</div>
{qaStreamParsed.testStrategy.value && (
<div className="pt-3 border-t border-border">
<div className="text-[11px] font-bold text-txt-muted mb-1">📋 Test Strategy</div>
<p className="text-sm">{qaStreamParsed.testStrategy.value}{qaStreamParsed.testStrategy.active && <span className="text-magenta animate-pulse">|</span>}</p>
</div>
)}
{qaStreamParsed.coveragePlan.value && (
<div className="pt-3 border-t border-border">
<div className="text-[11px] font-bold text-txt-muted mb-1">📊 Coverage Plan</div>
<p className="text-sm">{qaStreamParsed.coveragePlan.value}{qaStreamParsed.coveragePlan.active && <span className="text-magenta animate-pulse">|</span>}</p>
</div>
)}
</div>
)}
{/* Final result */}
{!streaming && testCases.length > 0 && (
<div className="mb-4 space-y-5">
{Object.entries(groupedTestCases).map(([type, cases]) => (
<div key={type}>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-bold">{TYPE_EMOJI[type] || "🧪"} {TYPE_LABEL[type] || type}</span>
<span className="text-xs text-txt-muted bg-surface-muted px-2 py-0.5 rounded-full">{cases.length}</span>
</div>
<div className="overflow-x-auto border rounded-xl border-border">
<table className="w-full text-sm">
<thead>
<tr className="text-left bg-[#f9f9f9] border-b border-border">
{["ID", "Name", "Precondition", "Steps", "Expected Result"].map(h => (
<th key={h} className="px-3 py-2.5 font-semibold text-xs text-txt-muted whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{cases.map((tc, i) => (
<tr key={tc.test_id || i} className={`border-b border-border last:border-0 ${ i % 2 === 0 ? "bg-white" : "bg-[#fdfdfd]" }`}>
<td className="px-3 py-2.5 font-mono text-[11px] font-bold text-magenta whitespace-nowrap">{tc.test_id}</td>
<td className="px-3 py-2.5 font-medium">{tc.test_name}</td>
<td className="px-3 py-2.5 text-txt-muted text-xs">{tc.precondition}</td>
<td className="px-3 py-2.5">
{Array.isArray(tc.steps) ? (
<ol className="list-decimal list-inside space-y-0.5 text-xs">
{tc.steps.map((s, j) => <li key={j}>{s}</li>)}
</ol>
) : <span className="text-xs">{tc.steps}</span>}
</td>
<td className="px-3 py-2.5 text-xs">{tc.expected_result}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
{(testStrategy || coveragePlan) && (
<div className="grid grid-cols-2 gap-3 pt-1">
{testStrategy && (
<div className="p-4 bg-white border border-border rounded-xl">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">📋</span>
<span className="text-xs font-bold tracking-wide uppercase text-txt">Test Strategy</span>
</div>
<p className="text-sm leading-relaxed text-txt-muted">{testStrategy}</p>
</div>
)}
{coveragePlan && (
<div className="p-4 bg-white border border-border rounded-xl">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">📊</span>
<span className="text-xs font-bold tracking-wide uppercase text-txt">Coverage Plan</span>
</div>
<p className="text-sm leading-relaxed text-txt-muted">{coveragePlan}</p>
</div>
)}
</div>
)}
</div>
)}
{!streaming && testCases.length === 0 && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-border border-t-magenta animate-spin"></span>
Waiting for test cases
</div>
)}
{!streaming && (
<div className="flex gap-2 pt-4 mt-4 border-t border-border">
<input
className="flex-1 input-field"
value={qaFeedback}
onChange={(e) => setQaFeedback(e.target.value)}
placeholder="Have feedback? Enter here to regenerate…"
/>
<button className="btn-outline" onClick={handleQaRefine} disabled={!qaFeedback.trim() || loading}>Regenerate</button>
<button className="btn-magenta" onClick={handleDevRun} disabled={loading || !!qaFeedback.trim()}>Generate Dev Code </button>
</div>
)}
</div>
)}
{/* ═══ Step 3: Dev Code Generation ═══ */}
{step === 3 && (
<div className="card">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base bg-gray-100 rounded-xl">💻</div>
<h2 className="text-base font-bold">Dev Code Generation</h2>
</div>
{streaming && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
Generating {streamText.length} chars
</span>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-[#f3f3f3] rounded-xl mb-4">
{([
["java_code", "🐍 Business Code"],
["unit_tests", "🧪 Unit Tests"],
["implementation_notes", "📄 Implementation Notes"],
] as const).map(([key, label]) => (
<button
key={key}
className={`flex-1 px-3 py-1.5 text-sm font-semibold rounded-lg transition-all ${
(streaming ? devStreamTab : codeTab) === key
? "bg-white text-magenta shadow-sm"
: "text-txt-muted hover:text-txt"
}`}
onClick={() => streaming ? setDevStreamTab(key) : setCodeTab(key)}
>
{label}
</button>
))}
</div>
<div className="relative rounded-xl overflow-hidden border border-[#2a2a2a]">
<div className="flex items-center justify-between px-4 py-2 bg-[#252525] border-b border-[#333]">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" />
<span className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
<span className="w-2.5 h-2.5 rounded-full bg-[#28c840]" />
</div>
<span className="text-[11px] text-gray-500 font-mono">
{(streaming ? devStreamTab : codeTab) === "implementation_notes" ? "notes.txt" :
(streaming ? devStreamTab : codeTab) === "unit_tests" ? "test_service.py" : "service.py"}
</span>
{!streaming && (
<button
className="text-[11px] text-gray-400 hover:text-gray-200 transition-colors"
onClick={() => {
const code = codeTab === "java_code" ? codeGeneration?.java_code
: codeTab === "unit_tests" ? codeGeneration?.unit_tests
: codeGeneration?.implementation_notes;
if (code) navigator.clipboard.writeText(code);
}}
>
Copy
</button>
)}
</div>
<pre ref={devStreamRef} className="bg-[#1a1a1a] text-gray-300 px-5 py-4 text-[0.82rem] font-mono leading-relaxed max-h-[50vh] overflow-y-auto whitespace-pre-wrap">
{streaming
? devStreamParsed?.[devStreamTab] || "Waiting for Dev Agent output…"
: codeTab === "java_code"
? codeGeneration?.java_code || "No code yet"
: codeTab === "unit_tests"
? codeGeneration?.unit_tests || "No tests yet"
: parseNotes(codeGeneration?.implementation_notes || "").join("\n") || "No notes yet"
}
</pre>
</div>
{!streaming && (
<div className="flex justify-end pt-4 mt-4 border-t border-border">
<button className="btn-magenta" onClick={handleTestRun} disabled={loading}>
Run Unit Tests
</button>
</div>
)}
</div>
)}
{/* ═══ Step 4: Test Execution ═══ */}
{step === 4 && (
<div className="card">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-purple-50"></div>
<h2 className="text-base font-bold">Test Execution</h2>
</div>
{loading && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-3.5 h-3.5 border-2 border-magenta/30 border-t-magenta rounded-full animate-spin inline-block"></span>
Running
</span>
)}
</div>
{testExecution ? (
<>
{/* 状态横幅 */}
<div className={`flex items-center gap-3 px-5 py-4 rounded-xl mb-5 ${
testExecution.success
? "bg-green-50 border border-green-200"
: "bg-red-50 border border-red-200"
}`}>
<span className="text-2xl">{testExecution.success ? "✅" : "❌"}</span>
<div>
<div className={`text-sm font-bold ${ testExecution.success ? "text-green-800" : "text-red-800" }`}>
{testExecution.success
? `All ${testExecution.total} tests passed!`
: `${testExecution.failed + testExecution.errors} test(s) failed`
}
</div>
{!testExecution.success && (
<div className="text-xs text-red-600 mt-0.5">Click "AI Auto Fix" to automatically fix the failing tests.</div>
)}
</div>
</div>
{/* 数据统计 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{[
{ num: testExecution.passed, label: "Passed", color: "text-green-600", bg: "bg-green-50 border-green-100", icon: "✅" },
{ num: testExecution.failed, label: "Failed", color: "text-red-500", bg: "bg-red-50 border-red-100", icon: "❌" },
{ num: testExecution.errors, label: "Errors", color: "text-amber-500", bg: "bg-amber-50 border-amber-100", icon: "⚠️" },
{ num: testExecution.total, label: "Total", color: "text-txt", bg: "bg-[#f9f9f9] border-border", icon: "📊" },
].map(({ num, label, color, bg, icon }) => (
<div key={label} className={`border rounded-xl p-4 text-center ${bg}`}>
<div className={`text-3xl font-extrabold mb-1 ${color}`}>{num}</div>
<div className="text-[11px] font-semibold text-txt-muted">{icon} {label}</div>
</div>
))}
</div>
{/* 输出 */}
<div className="relative rounded-xl overflow-hidden border border-[#2a2a2a]">
<div className="flex items-center justify-between px-4 py-2 bg-[#252525] border-b border-[#333]">
<span className="text-[11px] text-gray-400 font-semibold">🖥 pytest output</span>
<button
className="text-[11px] text-gray-400 hover:text-gray-200 transition-colors"
onClick={() => navigator.clipboard.writeText(testExecution.output)}
>
Copy
</button>
</div>
<pre className="bg-[#1a1a1a] text-gray-300 px-5 py-4 text-[0.82rem] font-mono leading-relaxed max-h-[40vh] overflow-y-auto whitespace-pre-wrap">
{testExecution.output}
</pre>
</div>
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-border">
<button className="btn-outline" onClick={handleTestRun} disabled={loading}>🔄 Re-run</button>
{!testExecution.success && (
<button className="btn-magenta" onClick={handleTestFix} disabled={loading || streaming}>
🔧 AI Auto Fix
</button>
)}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center gap-3 py-14">
{loading ? (
<>
<span className="w-8 h-8 border-2 rounded-full border-magenta/20 border-t-magenta animate-spin"></span>
<span className="text-sm text-txt-muted">Running pytest</span>
</>
) : (
<>
<span className="text-3xl">🧪</span>
<span className="text-sm text-txt-muted">Ready. Click the button below to run tests.</span>
<button className="mt-2 btn-magenta" onClick={handleTestRun}> Run Unit Tests</button>
</>
)}
</div>
)}
</div>
)}
<div ref={bottomRef} />
</div>
</div>
</div>
);
}