Files
ftb-project-management/apps/web/components/version/AiDecomposeButton.tsx
2026-07-01 13:33:04 +08:00

333 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

'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<AgentDecomposeTarget | null>(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<AiDecomposeCompletedPayload> => {
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<AgentDecomposeResponse | AgentDecomposeError>(
'/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 (
<>
<span className="inline-flex items-center gap-1.5">
{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 (
<button
type="button"
key={target}
onClick={() => handleClick(target)}
disabled={isInProgress && mode !== 'view_result'}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
targetHasError
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
}`}
title={
targetHasError
? `上次失败:${targetErrorMessage}(点击重试${targetText(target)}`
: mode === 'view_result'
? `查看本次 AI 拆解${targetText(target)}结果`
: persistStatus === 'completed' && persistTarget === target
? `此前已拆解过,再次点击会重新拆解${targetText(target)}`
: `使用 AI 把原型 + 关联需求拆解成${targetText(target)}`
}
>
{showSpinner ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{formatElapsed(targetElapsedSec)}
</>
) : targetHasError ? (
<>
<RotateCw className="h-3 w-3" />
{targetText(target)}
</>
) : mode === 'view_result' ? (
<>
<Eye className="h-3 w-3" />
{label}
</>
) : (
<>
<Sparkles className="h-3 w-3" />
{label}
</>
)}
</button>
);
})}
</span>
{/* 错误信息:在按钮旁悬浮显示 */}
{visibleError && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-red-600 max-w-[260px]" title={visibleError}>
<AlertCircle className="h-3 w-3 shrink-0" />
<span className="truncate">{visibleError}</span>
</span>
)}
{/* 卡死提示persistStatus 是 in_progress 但已超时 */}
{isStaleInProgress && (
<span className="ml-1 text-[11px] text-amber-600" title="上次拆解记录残留,点击按钮重新拆解">
{formatElapsed(persistedElapsedSec)}
</span>
)}
{viewingTarget && viewingSession?.status === 'completed' && (
<DecomposeReportModal
result={viewingSession.payload.result}
version={version}
plan={plan}
requirements={linkedReqs}
target={viewingTarget}
dedupeSummary={viewingSession.payload.dedupeSummary}
onClose={() => setViewingTarget(null)}
onAdopted={() => handleResultReviewed(viewingTarget)}
onRejected={() => handleResultReviewed(viewingTarget)}
/>
)}
</>
);
}