From 24e8635a948b65b6e39120ec7286071356139ac1 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Wed, 1 Jul 2026 13:05:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E4=BF=9D=E7=95=99=E6=8B=86=E8=A7=A3?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/version/AiDecomposeButton.tsx | 250 ++++++++++-------- .../version/DecomposeReportModal.tsx | 34 ++- apps/web/lib/ai-decompose-session.test.ts | 72 +++++ apps/web/lib/ai-decompose-session.ts | 92 +++++++ docs/workflow.md | 2 + 5 files changed, 343 insertions(+), 107 deletions(-) create mode 100644 apps/web/lib/ai-decompose-session.test.ts create mode 100644 apps/web/lib/ai-decompose-session.ts diff --git a/apps/web/components/version/AiDecomposeButton.tsx b/apps/web/components/version/AiDecomposeButton.tsx index 5c317ac..07fb4c3 100644 --- a/apps/web/components/version/AiDecomposeButton.tsx +++ b/apps/web/components/version/AiDecomposeButton.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react'; +import { Sparkles, Loader2, AlertCircle, RotateCw, Eye } from 'lucide-react'; import { appendPlanLog } from '@/lib/version-plan'; import type { VersionPlan } from '@/lib/version-plan'; import type { VersionWithContext } from '@/lib/derive'; @@ -14,6 +14,11 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { api } from '@/lib/api'; import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe'; import { filterDecomposeResultByTarget } from '@/lib/ai-decompose-target'; +import { + aiDecomposeSessionStore, + getAiDecomposeButtonMode, + type AiDecomposeCompletedPayload, +} from '@/lib/ai-decompose-session'; import { DecomposeReportModal } from './DecomposeReportModal'; import type { AgentDecomposeTarget, @@ -27,14 +32,9 @@ interface Props { version: VersionWithContext; } -interface DecomposeModalState { - result: AgentDecomposeResponse; - target: AgentDecomposeTarget; - dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number }; -} - /** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */ const STUCK_THRESHOLD_SEC = 240; +const DECOMPOSE_TARGETS: AgentDecomposeTarget[] = ['dev_tasks', 'test_cases']; function formatElapsed(sec: number): string { if (sec < 60) return `${sec}s`; @@ -51,11 +51,10 @@ export function AiDecomposeButton({ plan, version }: Props) { const categories = useTaskCategoryStore((s) => s.categories); const user = useAuthStore((s) => s.user); - const [activeTarget, setActiveTarget] = useState(null); - const [modalState, setModalState] = useState(null); - const [tick, setTick] = useState(0); - const startedAtRef = useRef(null); - const loading = activeTarget !== null; + const [viewingTarget, setViewingTarget] = useState(null); + const [tick, setTick] = useState(() => Date.now()); + const [, bumpSessionVersion] = useState(0); + const mountedRef = useRef(false); // plan 上的状态(持久化在 localStorage) const persistStatus = plan.aiDecomposeStatus; @@ -63,23 +62,34 @@ export function AiDecomposeButton({ plan, version }: Props) { const persistAt = plan.aiDecomposeAt; const persistTarget = plan.aiDecomposeTarget; - // 计算"已用时" - const elapsedSec = (() => { - if (loading && startedAtRef.current) { - return Math.floor((tick - startedAtRef.current) / 1000); - } + useEffect(() => { + mountedRef.current = true; + const unsubscribe = aiDecomposeSessionStore.subscribe(() => bumpSessionVersion((value) => value + 1)); + return () => { + mountedRef.current = false; + unsubscribe(); + }; + }, []); + + const getSession = (target: AgentDecomposeTarget) => aiDecomposeSessionStore.get(plan.id, target); + const hasSessionGenerating = DECOMPOSE_TARGETS.some((target) => getSession(target).status === 'generating'); + + const persistedElapsedSec = (() => { if (persistStatus === 'in_progress' && persistAt) { const start = new Date(persistAt).getTime(); - return Math.max(0, Math.floor((Date.now() - start) / 1000)); + return Math.max(0, Math.floor((tick - start) / 1000)); } return 0; })(); - const isStaleInProgress = persistStatus === 'in_progress' && elapsedSec > STUCK_THRESHOLD_SEC; - const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress; - const isError = persistStatus === 'error' && !loading; - const wasCompleted = persistStatus === 'completed' && !loading; - const runningTarget = activeTarget ?? (isInProgress ? persistTarget ?? null : null); + const isStaleInProgress = + persistStatus === 'in_progress' && !hasSessionGenerating && persistedElapsedSec > STUCK_THRESHOLD_SEC; + const hasPersistedInProgress = persistStatus === 'in_progress' && !isStaleInProgress; + const isInProgress = hasSessionGenerating || hasPersistedInProgress; + + const currentError = DECOMPOSE_TARGETS.map((target) => getSession(target)) + .find((session) => session.status === 'error'); + const visibleError = currentError?.status === 'error' ? currentError.error : persistStatus === 'error' ? persistError : undefined; // tick 每秒更新一次,让"已用时"实时跳 useEffect(() => { @@ -121,78 +131,85 @@ export function AiDecomposeButton({ plan, version }: Props) { }; const handleClick = async (target: AgentDecomposeTarget) => { - if (loading) return; + const session = getSession(target); + if (session.status === 'completed') { + setViewingTarget(target); + return; + } + + if (hasSessionGenerating) return; // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 if (persistStatus === 'in_progress' && !isStaleInProgress) return; - startedAtRef.current = Date.now(); setTick(Date.now()); - setActiveTarget(target); - updatePlan(plan.id, { - aiDecomposeStatus: 'in_progress', - aiDecomposeBy: user?.name, - aiDecomposeAt: new Date().toISOString(), - aiDecomposeTarget: target, - aiDecomposeError: undefined, - logs: appendAiLog(target, 'started'), - }); + const run = async (): Promise => { + updatePlan(plan.id, { + aiDecomposeStatus: 'in_progress', + aiDecomposeBy: user?.name, + aiDecomposeAt: new Date().toISOString(), + aiDecomposeTarget: target, + aiDecomposeError: undefined, + logs: appendAiLog(target, 'started'), + }); - const members = (version.members ?? []).map((m) => ({ - name: m.name, - role: m.role, - })); + const members = (version.members ?? []).map((m) => ({ + name: m.name, + role: m.role, + })); - const payload: AgentDecomposeRequest = { - prototypeUrl: plan.resultUrl || '', - requirements: linkedReqs, - members, - versionId: version.id, - planId: plan.id, - target, - }; + const payload: AgentDecomposeRequest = { + prototypeUrl: plan.resultUrl || '', + requirements: linkedReqs, + members, + versionId: version.id, + planId: plan.id, + target, + }; - try { const resp = await api.postRaw( '/ai/decompose', payload, 300000, ); + if (!resp.ok) { - updatePlan(plan.id, { - aiDecomposeStatus: 'error', - aiDecomposeTarget: target, - aiDecomposeError: resp.error, - logs: appendAiLog(target, 'error', resp.error), - }); - } else { - const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target); - const requirementIdSet = new Set(linkedReqs.map((req) => req.id)); - const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, { - existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)), - existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id), - categories, - requirements: linkedReqs, - }); - setModalState({ - result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) }, - target, - dedupeSummary: { - removedDevTaskCount: deduped.removedDevTaskCount, - removedTestCaseCount: deduped.removedTestCaseCount, - }, - }); - updatePlan(plan.id, { - aiDecomposeStatus: 'completed', - aiDecomposeTarget: target, - aiDecomposeError: undefined, - logs: appendAiLog( - target, - 'completed', - `生成开发任务 ${deduped.result.devTaskDrafts.length} 条,测试用例 ${deduped.result.testCaseDrafts.length} 条。已过滤重复开发任务 ${deduped.removedDevTaskCount} 条,重复测试用例 ${deduped.removedTestCaseCount} 条。`, - ), - }); + throw new Error(resp.error); } + + const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target); + const requirementIdSet = new Set(linkedReqs.map((req) => req.id)); + const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, { + existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)), + existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id), + categories, + requirements: linkedReqs, + }); + const result = { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) }; + + updatePlan(plan.id, { + aiDecomposeStatus: 'completed', + aiDecomposeTarget: target, + aiDecomposeError: undefined, + logs: appendAiLog( + target, + 'completed', + `生成开发任务 ${deduped.result.devTaskDrafts.length} 条,测试用例 ${deduped.result.testCaseDrafts.length} 条。已过滤重复开发任务 ${deduped.removedDevTaskCount} 条,重复测试用例 ${deduped.removedTestCaseCount} 条。`, + ), + }); + + return { + result, + dedupeSummary: { + removedDevTaskCount: deduped.removedDevTaskCount, + removedTestCaseCount: deduped.removedTestCaseCount, + }, + }; + }; + + try { + await aiDecomposeSessionStore.start(plan.id, target, run); + if (mountedRef.current) setViewingTarget(target); } catch (e: any) { const msg = e?.message || '调用 AI 服务失败'; updatePlan(plan.id, { @@ -201,51 +218,76 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeError: msg, logs: appendAiLog(target, 'error', msg), }); - } finally { - setActiveTarget(null); - startedAtRef.current = null; } }; + const handleResultReviewed = (target: AgentDecomposeTarget) => { + aiDecomposeSessionStore.markReviewed(plan.id, target); + setViewingTarget(null); + }; + if (plan.type !== 'product' || plan.status !== 'completed' || !plan.resultUrl) { return null; } + const viewingSession = viewingTarget ? getSession(viewingTarget) : null; + return ( <> - {(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => { - const showSpinner = isInProgress && runningTarget === target; - const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`; + {DECOMPOSE_TARGETS.map((target) => { + const session = getSession(target); + const mode = getAiDecomposeButtonMode(session); + const showSpinner = mode === 'generating' || (hasPersistedInProgress && persistTarget === target); + const targetElapsedSec = + session.status === 'generating' + ? Math.max(0, Math.floor((tick - session.startedAt) / 1000)) + : persistedElapsedSec; + const targetHasError = + mode === 'retry' || (persistStatus === 'error' && (persistTarget === target || !persistTarget)); + const targetErrorMessage = session.status === 'error' ? session.error : persistError || '未知错误'; + const label = + mode === 'view_result' + ? `查看${targetText(target)}结果` + : persistStatus === 'completed' && persistTarget === target + ? `重新拆解${targetText(target)}` + : `AI 拆解${targetText(target)}`; return ( +