diff --git a/src/pages/DevOpsAgent.tsx b/src/pages/DevOpsAgent.tsx index c8249bf..7c061b5 100644 --- a/src/pages/DevOpsAgent.tsx +++ b/src/pages/DevOpsAgent.tsx @@ -1,28 +1,35 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +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 PMResult = { +type RequirementAnalysis = { summary: string; - functional: string[]; - nonfunctional: string[]; - acceptance: string[]; + functional_requirements: string[]; + non_functional_requirements: string[]; + acceptance_criteria: string[]; edge_cases: string[]; }; type TestCase = { - id: string; - name: string; + test_id: string; + test_name: string; precondition: string; - steps: string; - expected: string; + steps: string[] | string; + expected_result: string; + test_type?: string; }; -type TestResult = { +type CodeGeneration = { + java_code: string; + unit_tests: string; + implementation_notes: string; +}; + +type TestExecution = { + success: boolean; passed: number; failed: number; errors: number; @@ -43,7 +50,13 @@ async function apiPost(path: string, body?: unknown): Promise { return res.json(); } -async function readSSE(path: string, onChunk: (text: string) => void, signal?: AbortSignal) { +// 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; @@ -55,24 +68,31 @@ async function readSSE(path: string, onChunk: (text: string) => void, signal?: A 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); + 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; } - idx = buf.indexOf("\n"); } } } finally { @@ -80,61 +100,277 @@ async function readSSE(path: string, onChunk: (text: string) => void, signal?: A } } -/* ── Step labels ── */ -const STEPS: { title: string; icon: string }[] = [ - { title: "Clarification", icon: "💬" }, +/* ── 流式 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 Output", 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 — Requirement + // Step 0 const [requirement, setRequirement] = useState(""); - const [rawReq, setRawReq] = useState(""); - const [status, setStatus] = useState(""); + const [rawRequirement, setRawRequirement] = useState(""); const [clarifyHistory, setClarifyHistory] = useState([]); const [clarifyInput, setClarifyInput] = useState(""); - // Step 1 — PM analysis - const [pmStream, setPmStream] = useState(""); - const [pmResult, setPmResult] = useState(null); + // Step 1 + const [requirementAnalysis, setRequirementAnalysis] = useState(null); const [pmFeedback, setPmFeedback] = useState(""); - // Step 2 — QA test cases - const [qaStream, setQaStream] = useState(""); + // Step 2 const [testCases, setTestCases] = useState([]); + const [testStrategy, setTestStrategy] = useState(""); + const [coveragePlan, setCoveragePlan] = useState(""); 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 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 — Test results - const [testResult, setTestResult] = useState(null); + // 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" }); - }, [pmStream, qaStream, devStream]); + // 同时滚动各流式容器到底部 + 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(); }, []); - /* ── Step 0: Start session ── */ + 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); @@ -142,11 +378,10 @@ export default function DevOpsAgent() { try { const data: any = await apiPost("/session/start", { requirement: requirement.trim() }); setSessionId(data.session_id); - setRawReq(requirement.trim()); + setRawRequirement(requirement.trim()); setStatus(data.status || "clarifying"); - if (data.clarify_questions) { - setClarifyHistory([{ role: "assistant", content: data.clarify_questions }]); - } + const q = data.clarify_questions || data.question; + if (q) setClarifyHistory([{ role: "assistant", content: q }]); } catch (e) { setError((e as Error).message); } finally { @@ -154,19 +389,19 @@ export default function DevOpsAgent() { } }, [requirement, loading]); - /* ── Step 0: Clarify ── */ + /* ── Step 0: 需求澄清 ── */ const handleClarify = useCallback(async () => { if (!clarifyInput.trim() || loading) return; - setClarifyHistory((h) => [...h, { role: "user", content: clarifyInput.trim() }]); + 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: clarifyInput.trim() }); - setClarifyInput(""); + const data: any = await apiPost(`/session/${sessionId}/clarify`, { message: msg }); setStatus(data.status || status); - if (data.clarify_questions) { - setClarifyHistory((h) => [...h, { role: "assistant", content: data.clarify_questions }]); - } + const q = data.clarify_questions || data.question; + if (q) setClarifyHistory((h) => [...h, { role: "assistant", content: q }]); } catch (e) { setError((e as Error).message); } finally { @@ -174,134 +409,49 @@ export default function DevOpsAgent() { } }, [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]); + const handlePmRun = useCallback(() => + withStreaming(`/session/${sessionId}/pm/stream`, 1), + [sessionId, withStreaming]); - /* ── 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]); + const fb = pmFeedback.trim(); + setPmFeedback(""); + await withStreaming(`/session/${sessionId}/pm/refine/stream?feedback=${encodeURIComponent(fb)}`, 1); + }, [sessionId, pmFeedback, withStreaming]); - /* ── 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]); + const handleQaRun = useCallback(() => + withStreaming(`/session/${sessionId}/qa/stream`, 2), + [sessionId, withStreaming]); - /* ── 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]); + 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]); - /* ── Step 4: Test Run ── */ + 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`); - 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), + 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 { @@ -309,146 +459,175 @@ export default function DevOpsAgent() { } }, [sessionId]); - /* ── Test Fix ── */ + /* ── AI 自动修复 ── */ 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); + 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); } - }, [sessionId, handleTestRun]); + 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 ( -
- {/* ── Step bar ── */} -
- {STEPS.map((s, i) => ( -
- - {i < STEPS.length - 1 && } -
- ))} +
+ {/* ── 步骤栏 ── */} +
+
+ {STEPS.map((s, i) => ( +
+ + {i < STEPS.length - 1 && ( + + )} +
+ ))} + {sessionId && statusLabel && ( +
+ {statusLabel} +
+ )} +
- {/* Error banner */} + {/* Error */} {error && ( -
- {error} - +
+ ⚠️{error} +
)} - {/* ── Content ── */} -
-
+ {/* ── 内容区 ── */} +
+
- {/* ═══ Step 0: Requirement ═══ */} + {/* ═══ Step 0: Requirement Input ═══ */} {step === 0 && !sessionId && (
-

📋 Describe Product Requirement

-

Enter your product requirement, the system will automatically perform requirement clarification and analysis

+
+
📋
+
+

Enter Requirements

+

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

+
+