diff --git a/AGENT.md b/AGENT.md index 6caecaf..5d37ce8 100644 --- a/AGENT.md +++ b/AGENT.md @@ -67,6 +67,29 @@ 对 Gemini、Codex 等其他代理,执行同样的文档产出标准与评审门禁。 +## Frontend Verification +前端页面是否“正确”,不能只靠读代码或跑单测判断。只要修改了页面结构、交互流程、状态文案、路由跳转或 API 串联,代理都必须优先用 Playwright MCP 做一次真实页面核查,再决定是否宣称完成。 + +建议按以下顺序执行: + +1. 先启动本地 API 和 Web 服务,再访问真实页面,不要只看静态文件。 +2. 用 Playwright MCP 打开目标页面,至少调用一次页面快照和一次截图。 +3. 先检查浏览器 console 与 network/请求结果,再检查视觉呈现;不要只看截图。 +4. 对照 `docs/PRD.md`、`docs/FeatureSummary.md`、`docs/DevelopmentPlan.md`、`docs/UIDesign.md`、`docs/tdd.md`、`docs/tasks.md` 核对页面状态、按钮文案、空态、异常态和跳转是否一致。 +5. 必须走一遍主流程,而不是只停留在入口页。对于本仓库,至少覆盖:`/tasks/new`、`/tasks/:taskId/confirm`、`/tasks/:taskId/run`、`/tasks/:taskId/report`、`/history`;若涉及会话或阻塞恢复,还要覆盖 `/sessions/:platform/prepare` 或 `/tasks/:taskId/recovery/:platform`。 +6. 每次检查都要保存可回看的截图;如果发现问题,先记录触发路径、页面 URL、截图和报错,再修复。 +7. 修复后必须重新用 Playwright MCP 复测同一路径,确认问题已经消失,而不是仅凭代码推断。 + +最低核查项如下: + +- 页面能正常打开,没有白屏、报错弹窗或关键请求失败。 +- 关键状态文案与状态色一致,例如 `SearchBlocked`、`AwaitingConfirmation`、`Completed`、`PartialCompleted`。 +- 关键按钮可点击且跳转正确,例如创建任务、处理阻塞并重试、查看报告。 +- 主流程数据真的更新到下一页,而不是停留在旧状态。 +- console 中除开发环境常见噪音外,不应出现由当前改动引入的新错误。 + +如果代理没有实际调用 Playwright MCP 并查看截图,就不应声称“前端页面已经确认正确”。 + ## Git Commit Rules 当用户要求提交代码时,代理应在确认变更范围和内容正确后,直接完成 `git add` / `git commit`,不要只停留在提示层。 diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index 16e3fac..f7d2509 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest"; import { createServer } from "./server"; +async function createTask(app: ReturnType, query: string) { + const response = await app.inject({ + method: "POST", + url: "/api/tasks", + payload: { + query, + perLinkLimit: 100, + taskTotalLimit: 500 + } + }); + + return response.json().task; +} + +async function preparePlatform(app: ReturnType, platform: "tmall" | "jd") { + await app.inject({ + method: "POST", + url: `/api/platforms/${platform}/prepare` + }); +} + describe("API server", () => { it("returns platform readiness with jd blocked by default", async () => { const app = createServer(); @@ -65,20 +86,11 @@ describe("API server", () => { const app = createServer(); await app.ready(); - const createResponse = await app.inject({ - method: "POST", - url: "/api/tasks", - payload: { - query: "DJI Pocket 3", - perLinkLimit: 100, - taskTotalLimit: 500 - } - }); - const taskId = createResponse.json().task.taskId; + const createdTask = await createTask(app, "DJI Pocket 3"); const confirmResponse = await app.inject({ method: "POST", - url: `/api/tasks/${taskId}/confirm`, + url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [] } @@ -89,7 +101,7 @@ describe("API server", () => { const reportResponse = await app.inject({ method: "GET", - url: `/api/tasks/${taskId}/report` + url: `/api/tasks/${createdTask.taskId}/report` }); expect(reportResponse.statusCode).toBe(404); @@ -100,27 +112,17 @@ describe("API server", () => { const app = createServer(); await app.ready(); - const createResponse = await app.inject({ - method: "POST", - url: "/api/tasks", - payload: { - query: "Nintendo Switch 2", - perLinkLimit: 80, - taskTotalLimit: 320 - } - }); - const taskId = createResponse.json().task.taskId; + const createdTask = await createTask(app, "Nintendo Switch 2"); const candidatesResponse = await app.inject({ method: "GET", - url: `/api/tasks/${taskId}/candidates` + url: `/api/tasks/${createdTask.taskId}/candidates` }); - const firstCandidateId = - candidatesResponse.json().candidates.tmall[0].candidateId; + const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId; const confirmResponse = await app.inject({ method: "POST", - url: `/api/tasks/${taskId}/confirm`, + url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [ { @@ -137,7 +139,7 @@ describe("API server", () => { const reportResponse = await app.inject({ method: "GET", - url: `/api/tasks/${taskId}/report` + url: `/api/tasks/${createdTask.taskId}/report` }); expect(reportResponse.statusCode).toBe(200); @@ -146,4 +148,141 @@ describe("API server", () => { await app.close(); }); + + it("retries a SearchBlocked platform after session preparation and restores candidates", async () => { + const app = createServer(); + await app.ready(); + + const createdTask = await createTask(app, "iPhone 15 Pro"); + + await preparePlatform(app, "jd"); + + const retryResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` + }); + + expect(retryResponse.statusCode).toBe(200); + expect( + retryResponse + .json() + .task.platformRuns.find((run: { platform: string }) => run.platform === "jd") + ).toMatchObject({ + platform: "jd", + status: "AwaitingSelection" + }); + + const candidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + expect(candidatesResponse.json().candidates.jd).toHaveLength(3); + + await app.close(); + }); + + it("publishes a new report version when a blocked platform recovers successfully", async () => { + const app = createServer(); + await app.ready(); + + await preparePlatform(app, "jd"); + const createdTask = await createTask(app, "Nintendo Switch 2 [jd:blocked-once]"); + + const candidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + const candidates = candidatesResponse.json().candidates; + + const confirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/confirm`, + payload: { + selections: [ + { + platform: "tmall", + candidateIds: [candidates.tmall[0].candidateId] + }, + { + platform: "jd", + candidateIds: [candidates.jd[0].candidateId] + } + ] + } + }); + + expect(confirmResponse.statusCode).toBe(200); + expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted"); + expect(confirmResponse.json().task.reportVersions).toEqual([1]); + + const retryResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` + }); + + expect(retryResponse.statusCode).toBe(200); + expect(retryResponse.json().task.taskStatus).toBe("Completed"); + expect(retryResponse.json().task.reportVersions).toEqual([1, 2]); + expect(retryResponse.json().task.defaultReportVersion).toBe(2); + + const firstReport = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/report?version=1` + }); + const secondReport = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/report?version=2` + }); + + expect(firstReport.json().report.task_status).toBe("PartialCompleted"); + expect(secondReport.json().report.task_status).toBe("Completed"); + + await app.close(); + }); + + it("keeps the current report version when retry does not change the result", async () => { + const app = createServer(); + await app.ready(); + + await preparePlatform(app, "jd"); + const createdTask = await createTask(app, "Nintendo Switch 2 [jd:failed-always]"); + + const candidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + const candidates = candidatesResponse.json().candidates; + + const confirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/confirm`, + payload: { + selections: [ + { + platform: "tmall", + candidateIds: [candidates.tmall[0].candidateId] + }, + { + platform: "jd", + candidateIds: [candidates.jd[0].candidateId] + } + ] + } + }); + + expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted"); + expect(confirmResponse.json().task.reportVersions).toEqual([1]); + + const retryResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` + }); + + expect(retryResponse.statusCode).toBe(200); + expect(retryResponse.json().task.taskStatus).toBe("PartialCompleted"); + expect(retryResponse.json().task.reportVersions).toEqual([1]); + expect(retryResponse.json().task.defaultReportVersion).toBe(1); + + await app.close(); + }); }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 015c45c..604f24c 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -78,6 +78,18 @@ export function createServer() { } }); + app.post<{ + Params: { taskId: string; platform: PlatformId }; + }>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => { + try { + const task = store.retryPlatform(request.params.taskId, request.params.platform); + return { task }; + } catch { + reply.code(404); + return { message: "Task not found." }; + } + }); + app.get<{ Params: { taskId: string }; Querystring: { version?: string }; diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 830d991..4b8911c 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -10,6 +10,7 @@ import { type HistoryTaskRecord, type PlatformId, type PlatformRunRecord, + type PlatformStatus, type SessionReadinessRecord, type TaskEventRecord, type TaskRecord @@ -30,10 +31,58 @@ function createPlatformCandidatesRecord(): Record }; } +type MockExecutionScenario = { + outcome: Extract; + mode: "once" | "always"; +}; + +const mockScenarioPattern = + /\[(?tmall|jd):(?blocked|failed)(?:-(?once|always))?\]/giu; + +function parseMockExecutionScenarios( + query: string +): Partial> { + const scenarios: Partial> = {}; + + for (const match of query.matchAll(mockScenarioPattern)) { + const platform = match.groups?.platform as PlatformId | undefined; + const outcome = match.groups?.outcome; + const mode = match.groups?.mode; + + if (!platform || (outcome !== "blocked" && outcome !== "failed")) { + continue; + } + + scenarios[platform] = { + outcome: outcome === "blocked" ? "Blocked" : "Failed", + mode: mode === "always" ? "always" : "once" + }; + } + + return scenarios; +} + +function isRetryableStatus( + status: PlatformStatus +): status is Extract { + return status === "SearchBlocked" || status === "Blocked" || status === "Failed"; +} + +function isReportableTaskStatus( + status: TaskRecord["taskStatus"] +): status is Extract { + return status === "Completed" || status === "PartialCompleted"; +} + export class InMemoryTaskStore { private readonly tasks = new Map(); private readonly reports = new Map(); + private readonly reportFingerprints = new Map(); private readonly readiness = new Map(); + private readonly executionScenarios = new Map< + string, + Partial> + >(); constructor() { this.readiness.set("tmall", { @@ -67,6 +116,7 @@ export class InMemoryTaskStore { createTask(input: CreateTaskInput): TaskRecord { const timestamp = nowIso(); const taskId = randomUUID(); + const executionScenarios = parseMockExecutionScenarios(input.query); const task: TaskRecord = { taskId, query: input.query.trim(), @@ -91,6 +141,10 @@ export class InMemoryTaskStore { reportVersions: [] }; + if (Object.keys(executionScenarios).length > 0) { + this.executionScenarios.set(taskId, executionScenarios); + } + this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。"); task.taskStatus = "Searching"; task.taskStage = "precheck"; @@ -168,35 +222,78 @@ export class InMemoryTaskStore { task.taskStage = "session_check"; this.pushEvent(task, "task.running", "系统开始执行抓取前校验。"); - task.taskStage = "crawl"; - for (const run of selectedRuns) { - run.status = "Running"; - run.lastUpdatedAt = nowIso(); - this.pushEvent( - task, - `platform.${run.platform}.running`, - `${platformCatalogMap[run.platform].label} 开始抓取商品与评论。` - ); - run.status = "Completed"; - run.lastUpdatedAt = nowIso(); - } - - task.taskStage = "normalize"; - this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。"); - - task.taskStage = "analyze"; - this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。"); - - task.taskStage = "publish"; + this.executeSelectedPlatforms(task, selectedRuns); task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns); task.updatedAt = nowIso(); - const report = this.buildReport(task); - const previousReports = this.reports.get(task.taskId) ?? []; - this.reports.set(task.taskId, [...previousReports, report]); - task.reportVersions = [...task.reportVersions, report.report_version]; - task.defaultReportVersion = report.report_version; - task.latestSuccessfulReportVersion = report.report_version; - this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`); + this.publishReportIfNeeded(task); + + return task; + } + + retryPlatform(taskId: string, platform: PlatformId): TaskRecord { + const task = this.requireTask(taskId); + const run = task.platformRuns.find((item) => item.platform === platform); + + if (!run) { + throw new Error(`Platform ${platform} not found.`); + } + + if (!isRetryableStatus(run.status)) { + return task; + } + + if (run.status === "SearchBlocked") { + const readiness = this.requireReadiness(platform); + if (readiness.searchRequirement === "required" && !readiness.ready) { + this.pushEvent( + task, + `platform.${platform}.retry_blocked`, + `${platformCatalogMap[platform].label} 仍缺少有效会话,无法重新搜索。` + ); + return task; + } + + this.pushEvent( + task, + `platform.${platform}.retry_started`, + `${platformCatalogMap[platform].label} 正在重新获取候选结果。` + ); + this.runSearchForPlatform(task, run); + const recoveredCandidateCount = task.platformCandidates[platform].length; + task.taskStage = "confirmation"; + task.taskStatus = "AwaitingConfirmation"; + task.updatedAt = nowIso(); + this.pushEvent( + task, + `platform.${platform}.retry_finished`, + recoveredCandidateCount > 0 + ? `${platformCatalogMap[platform].label} 已恢复候选确认。` + : `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。` + ); + return task; + } + + if (run.selectedCandidateIds.length === 0) { + this.pushEvent( + task, + `platform.${platform}.retry_skipped`, + `${platformCatalogMap[platform].label} 当前没有已确认链接,无法执行定向重试。` + ); + return task; + } + + task.taskStatus = "Running"; + task.taskStage = "session_check"; + this.pushEvent( + task, + `platform.${platform}.retry_started`, + `${platformCatalogMap[platform].label} 正在执行定向重试。` + ); + + this.executeSelectedPlatforms(task, [run]); + task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns); + task.updatedAt = nowIso(); + this.publishReportIfNeeded(task); return task; } @@ -218,38 +315,7 @@ export class InMemoryTaskStore { task.taskStage = "search"; for (const run of task.platformRuns) { - const readiness = this.requireReadiness(run.platform); - if (readiness.searchRequirement === "required" && !readiness.ready) { - run.status = "SearchBlocked"; - run.reason = platformCatalogMap[run.platform].recoveryHint; - run.candidateCount = 0; - run.lastUpdatedAt = nowIso(); - this.pushEvent( - task, - `platform.${run.platform}.search_blocked`, - `${platformCatalogMap[run.platform].label} 缺少有效会话,搜索被阻塞。` - ); - continue; - } - - run.status = "Searching"; - run.lastUpdatedAt = nowIso(); - const candidates = createMockCandidates(task.query, run.platform); - task.platformCandidates[run.platform] = candidates; - run.candidateCount = candidates.length; - run.status = candidates.length > 0 ? "AwaitingSelection" : "NoResult"; - run.reason = - candidates.length > 0 - ? undefined - : `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`; - run.lastUpdatedAt = nowIso(); - this.pushEvent( - task, - `platform.${run.platform}.searched`, - candidates.length > 0 - ? `${platformCatalogMap[run.platform].label} 返回 ${candidates.length} 个候选。` - : `${platformCatalogMap[run.platform].label} 未返回候选结果。` - ); + this.runSearchForPlatform(task, run); } task.taskStage = "confirmation"; @@ -317,13 +383,13 @@ export class InMemoryTaskStore { : "已基于当前可用平台生成首版结构化报告。", key_points: [ `已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`, - blockedPlatforms.length > 0 - ? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话。` + blockedPlatforms.length + failedPlatforms.length > 0 + ? `仍有 ${blockedPlatforms.length + failedPlatforms.length} 个平台未完成,其中 ${blockedPlatforms.length} 个阻塞、${failedPlatforms.length} 个失败。` : "当前已确认平台全部处理完成。" ], limitations: [ - blockedPlatforms.length > 0 - ? "被阻塞平台未进入可发布洞察范围。" + blockedPlatforms.length + failedPlatforms.length > 0 + ? "未完成平台不进入当前可发布洞察范围。" : "当前为第一批开发样板数据,后续将替换为真实采集链路。" ] }, @@ -378,11 +444,11 @@ export class InMemoryTaskStore { ] : [], negative_themes: - run.status === "SearchBlocked" + run.status === "SearchBlocked" || run.status === "Blocked" || run.status === "Failed" ? [ sharedInsight( - `${platformCatalogMap[run.platform].label} 当前未进入采集`, - run.reason ?? "当前平台需要先恢复会话。", + `${platformCatalogMap[run.platform].label} 当前未完成执行`, + run.reason ?? "当前平台需要先恢复会话或稍后重试。", [] ) ] @@ -411,10 +477,10 @@ export class InMemoryTaskStore { recommendations: [ sharedInsight( "继续补齐受阻平台会话", - blockedPlatforms.length > 0 - ? `建议优先处理 ${blockedPlatforms + blockedPlatforms.length + failedPlatforms.length > 0 + ? `建议优先处理 ${[...blockedPlatforms, ...failedPlatforms] .map((platform) => platformCatalogMap[platform].label) - .join("、")} 的会话准备,再发起下一轮任务。` + .join("、")} 的恢复或重试,再发起下一轮任务。` : "建议基于当前样板链路继续扩展到真实采集策略。", evidenceIndex.map((evidence) => evidence.evidence_id) ) @@ -430,6 +496,193 @@ export class InMemoryTaskStore { }); } + private runSearchForPlatform(task: TaskRecord, run: PlatformRunRecord): void { + const readiness = this.requireReadiness(run.platform); + if (readiness.searchRequirement === "required" && !readiness.ready) { + run.status = "SearchBlocked"; + run.reason = platformCatalogMap[run.platform].recoveryHint; + run.candidateCount = 0; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.search_blocked`, + `${platformCatalogMap[run.platform].label} 缺少有效会话,搜索被阻塞。` + ); + return; + } + + run.status = "Searching"; + run.reason = undefined; + run.lastUpdatedAt = nowIso(); + const candidates = createMockCandidates(task.query, run.platform); + task.platformCandidates[run.platform] = candidates; + run.candidateCount = candidates.length; + run.status = candidates.length > 0 ? "AwaitingSelection" : "NoResult"; + run.reason = + candidates.length > 0 + ? undefined + : `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.searched`, + candidates.length > 0 + ? `${platformCatalogMap[run.platform].label} 返回 ${candidates.length} 个候选。` + : `${platformCatalogMap[run.platform].label} 未返回候选结果。` + ); + } + + private executeSelectedPlatforms( + task: TaskRecord, + runs: PlatformRunRecord[] + ): void { + task.taskStage = "crawl"; + + for (const run of runs) { + const readiness = this.requireReadiness(run.platform); + if (readiness.searchRequirement === "required" && !readiness.ready) { + run.status = "Blocked"; + run.reason = platformCatalogMap[run.platform].recoveryHint; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.blocked`, + `${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。` + ); + continue; + } + + const mockOutcome = this.consumeExecutionScenario(task.taskId, run.platform); + if (mockOutcome === "Blocked") { + run.status = "Blocked"; + run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.blocked`, + `${platformCatalogMap[run.platform].label} 本轮执行被阻塞。` + ); + continue; + } + + run.status = "Running"; + run.reason = undefined; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.running`, + `${platformCatalogMap[run.platform].label} 开始抓取商品与评论。` + ); + + if (mockOutcome === "Failed") { + run.status = "Failed"; + run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.failed`, + `${platformCatalogMap[run.platform].label} 本轮执行失败。` + ); + continue; + } + + run.status = "Completed"; + run.reason = undefined; + run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.completed`, + `${platformCatalogMap[run.platform].label} 已完成当前轮执行。` + ); + } + + const hasCompletedConfirmedPlatform = task.platformRuns.some( + (run) => run.selectedCandidateIds.length > 0 && run.status === "Completed" + ); + + if (!hasCompletedConfirmedPlatform) { + task.taskStage = "publish"; + this.pushEvent(task, "task.publish_skipped", "当前没有可发布结果,暂不生成报告。"); + return; + } + + task.taskStage = "normalize"; + this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。"); + + task.taskStage = "analyze"; + this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。"); + + task.taskStage = "publish"; + } + + private publishReportIfNeeded(task: TaskRecord): boolean { + if (!isReportableTaskStatus(task.taskStatus)) { + this.pushEvent(task, "task.report_skipped", "当前任务状态不满足报告发布条件。"); + return false; + } + + const fingerprint = this.buildReportFingerprint(task); + const previousFingerprints = this.reportFingerprints.get(task.taskId) ?? []; + const lastFingerprint = previousFingerprints[previousFingerprints.length - 1]; + + if (lastFingerprint === fingerprint) { + this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。"); + return false; + } + + const report = this.buildReport(task); + const previousReports = this.reports.get(task.taskId) ?? []; + this.reports.set(task.taskId, [...previousReports, report]); + this.reportFingerprints.set(task.taskId, [...previousFingerprints, fingerprint]); + task.reportVersions = [...task.reportVersions, report.report_version]; + task.defaultReportVersion = report.report_version; + task.latestSuccessfulReportVersion = report.report_version; + this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`); + + return true; + } + + private buildReportFingerprint(task: TaskRecord): string { + const selectedLinkCount = task.platformRuns.reduce( + (sum, run) => sum + run.selectedCandidateIds.length, + 0 + ); + + return JSON.stringify({ + taskStatus: task.taskStatus, + perLinkLimit: task.perLinkLimit, + taskTotalLimit: task.taskTotalLimit, + selectedLinkCount, + platformRuns: task.platformRuns.map((run) => ({ + platform: run.platform, + status: run.status, + reason: run.reason ?? null, + selectedCandidateIds: [...run.selectedCandidateIds].sort() + })) + }); + } + + private consumeExecutionScenario( + taskId: string, + platform: PlatformId + ): MockExecutionScenario["outcome"] | null { + const taskScenarios = this.executionScenarios.get(taskId); + const scenario = taskScenarios?.[platform]; + + if (!scenario) { + return null; + } + + if (scenario.mode === "once") { + delete taskScenarios?.[platform]; + if (taskScenarios && Object.keys(taskScenarios).length === 0) { + this.executionScenarios.delete(taskId); + } + } + + return scenario.outcome; + } + private pushEvent(task: TaskRecord, type: string, message: string): void { const event: TaskEventRecord = { eventId: randomUUID(), diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d3b762c..15051f7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,7 +3,7 @@ import { type CandidateRecord, type PlatformId } from "@cross-ai/domain"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { Navigate, @@ -25,7 +25,8 @@ import { getTask, getTaskCandidates, getTaskReport, - preparePlatform + preparePlatform, + retryTaskPlatform } from "./lib/api"; function Layout(props: { children: React.ReactNode }) { @@ -47,6 +48,10 @@ function Layout(props: { children: React.ReactNode }) { ); } +function formatPlatformNames(platforms: PlatformId[]) { + return platforms.map((platform) => platformCatalogMap[platform].label).join("、"); +} + function NewTaskPage() { const navigate = useNavigate(); const readinessQuery = useQuery({ @@ -100,8 +105,8 @@ function NewTaskPage() {