'use client'; import { useEffect, useRef, useState } from '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'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; 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, AgentDecomposeRequest, AgentDecomposeResponse, AgentDecomposeError, } from '@ftb/shared'; interface Props { plan: VersionPlan; version: VersionWithContext; } /** 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`; const m = Math.floor(sec / 60); const s = sec % 60; return `${m}m${s}s`; } export function AiDecomposeButton({ plan, version }: Props) { const { updatePlan } = useVersionPlanStore(); const { requirements } = useRequirementStore(); const devTasks = useDevTaskStore((s) => s.tasks); const testCases = useTestCaseStore((s) => s.testCases); const categories = useTaskCategoryStore((s) => s.categories); const user = useAuthStore((s) => s.user); 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; const persistError = plan.aiDecomposeError; const persistAt = plan.aiDecomposeAt; const persistTarget = plan.aiDecomposeTarget; 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((tick - start) / 1000)); } return 0; })(); 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(() => { if (!isInProgress) return; const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); }, [isInProgress]); const linkedReqs = requirements .filter((r) => r.versionId === version.id) .map((r) => ({ id: r.id, code: r.code, title: r.title, description: r.description, })); const targetText = (target: AgentDecomposeTarget) => { if (target === 'dev_tasks') return '开发任务'; if (target === 'test_cases') return '测试用例'; return '任务和用例'; }; const appendAiLog = ( target: AgentDecomposeTarget, status: 'started' | 'completed' | 'error', detail?: string, ) => { const currentPlan = useVersionPlanStore.getState().plans.find((item) => item.id === plan.id) ?? plan; const statusText = status === 'started' ? '已触发' : status === 'completed' ? '已完成' : '失败'; return appendPlanLog(currentPlan, { type: 'ai_decompose', actor: user?.name ?? plan.owner, title: `AI 拆解${targetText(target)}${statusText}`, detail, aiTarget: target, aiStatus: status, }); }; const handleClick = async (target: AgentDecomposeTarget) => { const session = getSession(target); if (session.status === 'completed') { setViewingTarget(target); return; } if (hasSessionGenerating) return; // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 if (persistStatus === 'in_progress' && !isStaleInProgress) return; setTick(Date.now()); 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 payload: AgentDecomposeRequest = { prototypeUrl: plan.resultUrl || '', requirements: linkedReqs, members, versionId: version.id, planId: plan.id, target, }; const resp = await api.postRaw( '/ai/decompose', payload, 300000, ); if (!resp.ok) { 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) => task.versionId === version.id || (!task.versionId && 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, { aiDecomposeStatus: 'error', aiDecomposeTarget: target, aiDecomposeError: msg, logs: appendAiLog(target, 'error', msg), }); } }; 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 ( <> {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 ( ); })} {/* 错误信息:在按钮旁悬浮显示 */} {visibleError && ( {visibleError} )} {/* 卡死提示:persistStatus 是 in_progress 但已超时 */} {isStaleInProgress && ( 上次未完成({formatElapsed(persistedElapsedSec)}) )} {viewingTarget && viewingSession?.status === 'completed' && ( setViewingTarget(null)} onAdopted={() => handleResultReviewed(viewingTarget)} onRejected={() => handleResultReviewed(viewingTarget)} /> )} ); }