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(path: string, body?: unknown): Promise { 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 = { 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 = { 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 = { "Functional": "🧩", "Performance": "⚡", "Security": "🔒", "功能测试": "🧩", "性能测试": "⚡", "安全测试": "🔒", }; const TYPE_LABEL: Record = { "功能测试": "Functional", "性能测试": "Performance", "安全测试": "Security", "边界测试": "Boundary", "异常测试": "Exception", "集成测试": "Integration", }; /* ━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━ */ export default function DevOpsAgent() { const [step, setStep] = useState(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([]); const [clarifyInput, setClarifyInput] = useState(""); // Step 1 const [requirementAnalysis, setRequirementAnalysis] = useState(null); const [pmFeedback, setPmFeedback] = useState(""); // Step 2 const [testCases, setTestCases] = useState([]); const [testStrategy, setTestStrategy] = useState(""); const [coveragePlan, setCoveragePlan] = useState(""); const [qaFeedback, setQaFeedback] = useState(""); // Step 3 const [codeGeneration, setCodeGeneration] = useState(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(null); const abortRef = useRef(null); const bottomRef = useRef(null); const pmStreamRef = useRef(null); const qaStreamRef = useRef(null); const devStreamRef = useRef(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 = {}; 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 (
{/* ── 步骤栏 ── */}
{STEPS.map((s, i) => (
{i < STEPS.length - 1 && ( )}
))} {sessionId && statusLabel && (
{statusLabel}
)}
{/* Error */} {error && (
⚠️{error}
)} {/* ── 内容区 ── */}
{/* ═══ Step 0: Requirement Input ═══ */} {step === 0 && !sessionId && (
📋

Enter Requirements

Describe your product requirements. AI will complete analysis → test cases → code generation automatically.