diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index bce2485..7e57b79 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -1,6 +1,6 @@ import { AiService } from './ai.service'; import type { AiGatewayService } from './ai-gateway.service'; -import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; +import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; const missingToolUseMessage = 'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")'; @@ -147,6 +147,43 @@ describe('AiService', () => { expect(testCaseRequired).toContain('categoryCode'); }); + it('allows fine-grained test case category codes for detailed AI decomposition', () => { + const testCaseCategoryEnum = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.properties + .categoryCode.enum; + + expect(testCaseCategoryEnum).toEqual( + expect.arrayContaining([ + 'test_ui_interaction', + 'test_form_validation', + 'test_data_consistency', + 'test_permission', + 'test_boundary', + 'test_state_flow', + 'test_regression', + ]), + ); + }); + + it('allows optional recommended assignee fields without requiring adoption', () => { + const devSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items; + const testCaseSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items; + + expect(devSchema.properties.recommendedAssigneeName).toEqual({ type: 'string' }); + expect(devSchema.properties.recommendedAssigneeReason).toEqual({ type: 'string' }); + expect(testCaseSchema.properties.recommendedAssigneeName).toEqual({ type: 'string' }); + expect(testCaseSchema.properties.recommendedAssigneeReason).toEqual({ type: 'string' }); + expect(devSchema.required).not.toContain('recommendedAssigneeName'); + expect(devSchema.required).not.toContain('recommendedAssigneeReason'); + expect(testCaseSchema.required).not.toContain('recommendedAssigneeName'); + expect(testCaseSchema.required).not.toContain('recommendedAssigneeReason'); + }); + + it('tells the model to recommend assignees only from version member names', () => { + expect(DECOMPOSE_SYSTEM_PROMPT).toContain('recommendedAssigneeName'); + expect(DECOMPOSE_SYSTEM_PROMPT).toContain('members[].name'); + expect(DECOMPOSE_SYSTEM_PROMPT).toContain('omit recommendedAssigneeName'); + }); + it('requires aiEstimateHours for dev task and test case drafts', () => { const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required; const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required; @@ -191,4 +228,46 @@ describe('AiService', () => { expect(userPrompt).toContain('本次只重新拆解开发任务'); expect(userPrompt).toContain('testCaseDrafts 必须返回空数组'); }); + + it('tells the model to match prototype notes by requirement code before summary semantics', async () => { + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_decompose', + toolInput: { + report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] }, + devTaskDrafts: [], + testCaseDrafts: [], + }, + inputTokens: 100, + outputTokens: 20, + rawModel: 'claude-test', + }); + + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('claude-test'), + } as unknown as AiGatewayService; + + const service = new AiService(gateway); + (service as any).fetchPrototype = jest.fn().mockResolvedValue('REA-20260629001:客户批量导入'); + + await service.decompose({ + prototypeUrl: 'https://example.com/prototype', + requirements: [ + { + id: 'req-1', + code: 'REA-20260629001', + title: '客户批量导入', + description: '上传 Excel 后生成客户档案', + }, + ], + members: [{ name: '张三', role: 'frontend' }], + versionId: 'version-1', + planId: 'plan-1', + target: 'dev_tasks', + }); + + const userPrompt = callTool.mock.calls[0][0].userPrompt; + expect(userPrompt).toContain('优先按需求编号'); + expect(userPrompt).toContain('需求概述'); + }); }); diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index fb5f78e..655c8f4 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -65,7 +65,7 @@ export class AiService { let toolResp; for (let attemptIndex = 0; attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length; attemptIndex++) { const contextChars = DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex]; - const prototypeContext = buildPrototypeContext(prototypeContent, contextChars); + const prototypeContext = buildPrototypeContext(prototypeContent, contextChars, { requirements: req.requirements }); const userPrompt = this.buildUserPrompt(req, prototypeContext.text, prototypeContext, attemptIndex > 0); try { @@ -202,7 +202,7 @@ export class AiService { URL: ${req.prototypeUrl} -### 原型内容(已提取 QY 批注相关片段) +### 原型内容(已提取 QY 批注和需求命中相关片段) - 识别到 QY 批注数量:${context?.noteCount ?? 0} - 内容已截断:${context?.truncated ? '是' : '否'} @@ -213,10 +213,19 @@ ${prototypeContent} ${reqList || '(无)'} +## 原型与需求匹配优先级 + +1. 优先按需求编号匹配:原型批注或文本中出现 requirement.id 或 requirement.code 时,必须优先判定为该需求命中。 +2. 如果没有出现需求编号,再按需求概述语义匹配:用需求 title 和 description 与原型批注/文本表达做对应。 +3. 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号;没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃该需求,可只使用 requirement 引用并在 matched.noteIds 返回空数组。 +4. 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous。 + ## 版本成员 ${memberList || '(无)'} +负责人推荐只能使用上方版本成员的 members[].name;没有明确匹配时不要输出 recommendedAssigneeName。 + ## 本次拆解目标 ${targetInstruction} diff --git a/apps/server/src/modules/ai/prompts/decompose.ts b/apps/server/src/modules/ai/prompts/decompose.ts index bedca9d..11590d7 100644 --- a/apps/server/src/modules/ai/prompts/decompose.ts +++ b/apps/server/src/modules/ai/prompts/decompose.ts @@ -19,26 +19,41 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品 - 引用 prototype_note 必须是输入原型里真实存在的 QY 编号 - 不得编造 -2. 任务来源限定 +2. 原型与需求匹配优先级 + - 第一优先级:原型批注或文本里直接出现 requirement.id 或 requirement.code(需求编号)时,必须判定为该需求命中 + - 第二优先级:没有需求编号时,再用需求 title / description(需求概述)与原型批注或文本做语义匹配 + - 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号 + - 没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃;可只引用 requirement,并在 matched.noteIds 返回空数组 + - 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous + +3. 任务来源限定 - 只为以下情况拆任务: a) 同时被需求和原型 QY 命中 - b) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充) + b) 需求编号或需求概述在原型文本中命中,但没有 QY 编号 + c) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充) - 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出 -3. 颗粒度(细颗粒) +4. 颗粒度(细颗粒) - 一条 QY 涉及前后端时,前端任务和后端任务必须分开 - 接口、数据库改动、前端 UI、前端交互、表单校验视为独立任务 - 一条 QY 可能产出 3-6 个 DevTask - - 测试用例:每条 QY 至少 1 条功能用例 + 1 条边界用例 + - 测试用例粒度要和开发任务一样细:每个明确功能点、UI 交互、表单校验、接口、数据保存、权限、状态流转、异常、边界、兼容性或回归点都应拆成独立 TestCase + - 不要用一条"验证 XX 完整流程"覆盖多个交互或多个规则 + - 一条 QY 若同时涉及 UI、接口、数据、异常和状态变化,通常应拆出 3-8 个 TestCase -4. 任务类型 +5. 任务类型 - 每条开发任务和测试用例都必须输出 categoryCode - 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration - - 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility + - 测试用例优先使用 test_functional / test_ui_interaction / test_form_validation / test_api / test_data_consistency / test_permission / test_exception / test_boundary / test_state_flow / test_compatibility / test_regression - 不输出数据库 categoryId - - 不输出推荐负责人(用户后续手填) -5. AI 工时估算(字段名 aiEstimateHours,单位小时) +6. 推荐负责人(可选字段) + - 可以输出 recommendedAssigneeName 和 recommendedAssigneeReason,但 recommendedAssigneeName 只能从输入版本成员清单的 members[].name 中精确选择 + - 开发任务按 categoryCode 优先匹配 frontend/backend 角色;测试用例优先匹配 testing 角色 + - 如果没有明确匹配的版本成员,omit recommendedAssigneeName and recommendedAssigneeReason + - 不要把推荐当作已分配,最终是否采纳由用户确认 + +7. AI 工时估算(字段名 aiEstimateHours,单位小时) - aiEstimateHours 只代表 AI 对工作量的判断,不代表负责人计划排期 - 不要输出 estimateHours、预计开始时间、预计截止时间 - 简单前端字段、文案、展示调整: 0.25-0.5h @@ -47,16 +62,17 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品 - 简单 CRUD 接口: 0.5-1h - 数据库字段/索引调整: 0.25-0.5h - 中等业务规则变更: 1-2h - - 简单功能测试用例执行: 0.1-0.3h - - API/异常测试用例执行: 0.2-0.5h + - 简单功能/UI交互/表单校验/边界测试用例执行: 0.1-0.3h + - API/异常/数据一致性/权限/状态流转测试用例执行: 0.2-0.5h + - 回归测试用例执行: 0.15-0.4h - 兼容性测试用例执行: 0.3-0.75h - 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间 -6. 标题:中文动词开头,简洁 +8. 标题:中文动词开头,简洁 ✓ "在主题列表实现拖拽排序" ✗ "关于 QY0010 主题拖拽排序的优化方案研究与实现" -7. 不凭空补 +9. 不凭空补 - 不要因为"通常应该有"就加"权限校验"任务 - 只拆需求和原型上明确存在的内容 @@ -67,7 +83,8 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品 - ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因 【特殊情况】 -- 若原型内容里看不到任何 QY 编号或类似的批注编号 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组 +- 若原型内容里看不到任何 QY 编号或类似的批注编号,但能匹配到需求编号或需求概述 → 仍按命中的需求拆解,matched.noteIds 返回空数组 +- 若原型内容里看不到任何 QY 编号或类似的批注编号,也匹配不到任何需求编号或需求概述 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组 - 若原型完全无法解析 → 同上处理 【输出通道强制要求】 @@ -137,6 +154,8 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = { }, priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, aiEstimateHours: { type: 'number' }, + recommendedAssigneeName: { type: 'string' }, + recommendedAssigneeReason: { type: 'string' }, references: { type: 'array', items: { @@ -163,10 +182,24 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = { description: { type: 'string' }, categoryCode: { type: 'string', - enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'], + enum: [ + 'test_functional', + 'test_ui_interaction', + 'test_form_validation', + 'test_api', + 'test_data_consistency', + 'test_permission', + 'test_exception', + 'test_boundary', + 'test_state_flow', + 'test_compatibility', + 'test_regression', + ], }, priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, aiEstimateHours: { type: 'number' }, + recommendedAssigneeName: { type: 'string' }, + recommendedAssigneeReason: { type: 'string' }, references: { type: 'array', items: { diff --git a/apps/server/src/modules/ai/prototype-context.spec.ts b/apps/server/src/modules/ai/prototype-context.spec.ts index efe02df..7e7d89c 100644 --- a/apps/server/src/modules/ai/prototype-context.spec.ts +++ b/apps/server/src/modules/ai/prototype-context.spec.ts @@ -24,4 +24,28 @@ describe('buildPrototypeContext', () => { expect(context.text).toContain('QY0002'); expect(context.text.length).toBeLessThan(800); }); + + it('keeps snippets that match requirement code or summary even when they are far from QY notes', () => { + const content = [ + `${'页面噪声'.repeat(600)} QY0001:登录按钮颜色改为蓝色。${'无关内容'.repeat(600)}`, + 'REA-20260629001:客户批量导入,支持上传 Excel 并生成客户档案。', + `${'更多噪声'.repeat(600)}`, + ].join(' '); + + const context = buildPrototypeContext(content, 900, { + requirements: [ + { + id: 'req-1', + code: 'REA-20260629001', + title: '客户批量导入', + description: '上传 Excel 后生成客户档案', + }, + ], + }); + + expect(context.text).toContain('QY0001'); + expect(context.text).toContain('REA-20260629001'); + expect(context.text).toContain('客户批量导入'); + expect(context.text).not.toContain('更多噪声'.repeat(20)); + }); }); diff --git a/apps/server/src/modules/ai/prototype-context.ts b/apps/server/src/modules/ai/prototype-context.ts index 2b7eb61..3085bd2 100644 --- a/apps/server/src/modules/ai/prototype-context.ts +++ b/apps/server/src/modules/ai/prototype-context.ts @@ -4,14 +4,32 @@ export interface PrototypeContext { truncated: boolean; } +export interface PrototypeContextRequirement { + id: string; + code: string; + title: string; + description?: string; +} + +export interface PrototypeContextOptions { + requirements?: PrototypeContextRequirement[]; +} + const QY_NOTE_PATTERN = /QY\d{3,}/gi; const MAX_NOTE_SNIPPETS = 40; +const MAX_REQUIREMENT_SNIPPETS = 30; +type PrototypeAnchor = { index: number; kind: 'note' | 'requirement' }; -export function buildPrototypeContext(content: string, maxChars = 12000): PrototypeContext { +export function buildPrototypeContext( + content: string, + maxChars = 12000, + options: PrototypeContextOptions = {}, +): PrototypeContext { const normalized = content.replace(/\s+/g, ' ').trim(); const matches = Array.from(normalized.matchAll(QY_NOTE_PATTERN)); + const requirementAnchors = findRequirementAnchors(normalized, options.requirements ?? []); - if (matches.length === 0) { + if (matches.length === 0 && requirementAnchors.length === 0) { return { text: normalized.slice(0, maxChars), noteCount: 0, @@ -19,13 +37,17 @@ export function buildPrototypeContext(content: string, maxChars = 12000): Protot }; } - const windowSize = Math.max(280, Math.floor(maxChars / Math.min(matches.length, 12))); - const ranges = matches.slice(0, MAX_NOTE_SNIPPETS) - .map((match) => { - const index = match.index ?? 0; + const anchors: PrototypeAnchor[] = [ + ...matches.slice(0, MAX_NOTE_SNIPPETS).map((match) => ({ index: match.index ?? 0, kind: 'note' as const })), + ...requirementAnchors.slice(0, MAX_REQUIREMENT_SNIPPETS).map((index) => ({ index, kind: 'requirement' as const })), + ].sort((a, b) => a.index - b.index); + + const windowSize = Math.max(280, Math.floor(maxChars / Math.min(anchors.length, 12))); + const ranges = anchors + .map((anchor) => { return { - start: Math.max(0, index - 120), - end: Math.min(normalized.length, index + windowSize), + start: Math.max(0, anchor.index - 120), + end: getAnchorRangeEnd(normalized, anchor, windowSize), }; }) .sort((a, b) => a.start - b.start); @@ -70,3 +92,60 @@ export function buildPrototypeContext(content: string, maxChars = 12000): Protot truncated, }; } + +function findRequirementAnchors(content: string, requirements: PrototypeContextRequirement[]): number[] { + const lowerContent = content.toLowerCase(); + const anchors = new Set(); + + for (const requirement of requirements) { + for (const term of buildRequirementMatchTerms(requirement)) { + const lowerTerm = term.toLowerCase(); + let cursor = 0; + while (cursor < lowerContent.length) { + const index = lowerContent.indexOf(lowerTerm, cursor); + if (index < 0) break; + anchors.add(index); + cursor = index + Math.max(lowerTerm.length, 1); + } + } + } + + return [...anchors].sort((a, b) => a - b); +} + +function buildRequirementMatchTerms(requirement: PrototypeContextRequirement): string[] { + const terms = new Set(); + addMatchTerm(terms, requirement.id); + addMatchTerm(terms, requirement.code); + addMatchTerm(terms, requirement.title); + + for (const part of (requirement.description ?? '').split(/[,,。.;;、\n\r\t]+/)) { + addMatchTerm(terms, part); + } + + return [...terms]; +} + +function addMatchTerm(terms: Set, value?: string): void { + const term = (value ?? '').replace(/\s+/g, ' ').trim(); + if (term.length < 2) return; + terms.add(term); +} + +function getAnchorRangeEnd(content: string, anchor: PrototypeAnchor, windowSize: number): number { + const fallbackEnd = Math.min(content.length, anchor.index + windowSize); + if (anchor.kind === 'note') return fallbackEnd; + + const sentenceEnd = findSentenceEnd(content, anchor.index, Math.min(windowSize, 320)); + return sentenceEnd ?? Math.min(content.length, anchor.index + Math.min(windowSize, 220)); +} + +function findSentenceEnd(content: string, start: number, maxDistance: number): number | null { + const limit = Math.min(content.length, start + maxDistance); + for (let i = start; i < limit; i++) { + if ('。;;!?!?'.includes(content[i])) { + return i + 1; + } + } + return null; +} diff --git a/apps/web/components/version/AiDecomposeButton.tsx b/apps/web/components/version/AiDecomposeButton.tsx index 0d9c6e1..2a518cd 100644 --- a/apps/web/components/version/AiDecomposeButton.tsx +++ b/apps/web/components/version/AiDecomposeButton.tsx @@ -12,6 +12,7 @@ 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 { DecomposeReportModal } from './DecomposeReportModal'; import type { AgentDecomposeTarget, @@ -25,6 +26,12 @@ interface Props { version: VersionWithContext; } +interface DecomposeModalState { + result: AgentDecomposeResponse; + target: AgentDecomposeTarget; + dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number }; +} + /** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */ const STUCK_THRESHOLD_SEC = 240; @@ -44,9 +51,7 @@ export function AiDecomposeButton({ plan, version }: Props) { const user = useAuthStore((s) => s.user); const [activeTarget, setActiveTarget] = useState(null); - const [result, setResult] = useState(null); - const [resultTarget, setResultTarget] = useState('all'); - const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 }); + const [modalState, setModalState] = useState(null); const [tick, setTick] = useState(0); const startedAtRef = useRef(null); const loading = activeTarget !== null; @@ -55,6 +60,7 @@ export function AiDecomposeButton({ plan, version }: Props) { const persistStatus = plan.aiDecomposeStatus; const persistError = plan.aiDecomposeError; const persistAt = plan.aiDecomposeAt; + const persistTarget = plan.aiDecomposeTarget; // 计算"已用时" const elapsedSec = (() => { @@ -72,6 +78,7 @@ export function AiDecomposeButton({ plan, version }: Props) { const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress; const isError = persistStatus === 'error' && !loading; const wasCompleted = persistStatus === 'completed' && !loading; + const runningTarget = activeTarget ?? (isInProgress ? persistTarget ?? null : null); // tick 每秒更新一次,让"已用时"实时跳 useEffect(() => { @@ -108,6 +115,7 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeStatus: 'in_progress', aiDecomposeBy: user?.name, aiDecomposeAt: new Date().toISOString(), + aiDecomposeTarget: target, aiDecomposeError: undefined, }); @@ -134,24 +142,29 @@ export function AiDecomposeButton({ plan, version }: Props) { if (!resp.ok) { updatePlan(plan.id, { aiDecomposeStatus: 'error', + aiDecomposeTarget: target, aiDecomposeError: resp.error, }); } else { + const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target); const requirementIdSet = new Set(linkedReqs.map((req) => req.id)); - const deduped = filterDuplicateDecomposeDrafts(resp.result, { + const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, { existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)), existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id), categories, requirements: linkedReqs, }); - setResult({ ...resp, result: deduped.result }); - setDedupeSummary({ - removedDevTaskCount: deduped.removedDevTaskCount, - removedTestCaseCount: deduped.removedTestCaseCount, + setModalState({ + result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) }, + target, + dedupeSummary: { + removedDevTaskCount: deduped.removedDevTaskCount, + removedTestCaseCount: deduped.removedTestCaseCount, + }, }); - setResultTarget(target); updatePlan(plan.id, { aiDecomposeStatus: 'completed', + aiDecomposeTarget: target, aiDecomposeError: undefined, }); } @@ -159,6 +172,7 @@ export function AiDecomposeButton({ plan, version }: Props) { const msg = e?.message || '调用 AI 服务失败'; updatePlan(plan.id, { aiDecomposeStatus: 'error', + aiDecomposeTarget: target, aiDecomposeError: msg, }); } finally { @@ -175,10 +189,11 @@ export function AiDecomposeButton({ plan, version }: Props) { <> {(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => { - const showSpinner = isInProgress && (!activeTarget || activeTarget === target); + const showSpinner = isInProgress && runningTarget === target; const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`; return (