From a5b079a6732968b5471300a6a5262295e293f25d Mon Sep 17 00:00:00 2001 From: renzhiye Date: Thu, 2 Apr 2026 16:28:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=AD=E5=BB=BA=E9=98=B6=E6=AE=B5=20?= =?UTF-8?q?0=20=E4=B8=8E=E9=98=B6=E6=AE=B5=201=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + apps/api/package.json | 19 + apps/api/src/index.ts | 16 + apps/api/src/mock-data.ts | 42 + apps/api/src/server.test.ts | 149 + apps/api/src/server.ts | 119 + apps/api/src/store.ts | 459 ++ apps/api/tsconfig.json | 12 + apps/web/index.html | 12 + apps/web/package.json | 31 + apps/web/src/App.tsx | 610 +++ apps/web/src/components/StatusPill.tsx | 47 + apps/web/src/components/TaskContextHeader.tsx | 18 + apps/web/src/components/TaskSpine.test.tsx | 18 + apps/web/src/components/TaskSpine.tsx | 22 + apps/web/src/main.tsx | 19 + apps/web/src/styles.css | 471 ++ apps/web/src/test/setup.ts | 1 + apps/web/tsconfig.json | 15 + apps/web/vite.config.ts | 10 + package-lock.json | 4509 +++++++++++++++++ package.json | 25 + packages/domain/package.json | 15 + packages/domain/src/enums.ts | 66 + packages/domain/src/index.ts | 5 + packages/domain/src/models.ts | 86 + packages/domain/src/platforms.ts | 33 + packages/domain/src/presentation.ts | 44 + packages/domain/src/state-machine.ts | 60 + packages/domain/test/state-machine.test.ts | 75 + packages/domain/tsconfig.json | 13 + packages/report-schema/package.json | 19 + packages/report-schema/src/index.ts | 101 + .../report-schema/test/report-schema.test.ts | 169 + packages/report-schema/tsconfig.json | 13 + tsconfig.base.json | 19 + turbo.json | 35 + 37 files changed, 7379 insertions(+) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/mock-data.ts create mode 100644 apps/api/src/server.test.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/store.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/StatusPill.tsx create mode 100644 apps/web/src/components/TaskContextHeader.tsx create mode 100644 apps/web/src/components/TaskSpine.test.tsx create mode 100644 apps/web/src/components/TaskSpine.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/test/setup.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/domain/package.json create mode 100644 packages/domain/src/enums.ts create mode 100644 packages/domain/src/index.ts create mode 100644 packages/domain/src/models.ts create mode 100644 packages/domain/src/platforms.ts create mode 100644 packages/domain/src/presentation.ts create mode 100644 packages/domain/src/state-machine.ts create mode 100644 packages/domain/test/state-machine.test.ts create mode 100644 packages/domain/tsconfig.json create mode 100644 packages/report-schema/package.json create mode 100644 packages/report-schema/src/index.ts create mode 100644 packages/report-schema/test/report-schema.test.ts create mode 100644 packages/report-schema/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore index c8701a7..ea5a008 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,6 @@ cython_debug/ # Local tooling artifacts .playwright-mcp/ .codex-config.staged.toml +.turbo/ +node_modules/ diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c5584c6 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,19 @@ +{ + "name": "@cross-ai/api", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --target node22 --out-dir dist --clean", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@cross-ai/domain": "file:../../packages/domain", + "@cross-ai/report-schema": "file:../../packages/report-schema", + "@fastify/cors": "^11.2.0", + "fastify": "^5.8.4" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..bbefa6e --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,16 @@ +import { createServer } from "./server"; + +const port = Number(process.env.PORT ?? 3001); +const host = process.env.HOST ?? "0.0.0.0"; + +const server = createServer(); + +server + .listen({ port, host }) + .then(() => { + console.log(`API server listening on http://${host}:${port}`); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/apps/api/src/mock-data.ts b/apps/api/src/mock-data.ts new file mode 100644 index 0000000..8bd2027 --- /dev/null +++ b/apps/api/src/mock-data.ts @@ -0,0 +1,42 @@ +import type { CandidateRecord, PlatformId } from "@cross-ai/domain"; + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, ""); +} + +export function createMockCandidates( + query: string, + platform: PlatformId +): CandidateRecord[] { + if (query.includes("无结果") || query.toLowerCase().includes("none")) { + return []; + } + + const platformName = platform === "tmall" ? "tmall" : "jd"; + const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营"; + const basePrice = platform === "tmall" ? 7999 : 8099; + const slug = slugify(query); + + return Array.from({ length: 3 }, (_, index) => ({ + candidateId: `${platform}-${slug}-${index + 1}`, + platform, + title: `${query} ${platform === "tmall" ? "旗舰版" : "自营精选"} ${index + 1}`, + price: basePrice + index * 120, + priceLabel: `¥${basePrice + index * 120}`, + storeName, + productUrl: `https://example.com/${platformName}/${slug}-${index + 1}`, + imageUrl: `https://placehold.co/640x480?text=${platformName.toUpperCase()}+${index + 1}`, + salesHint: + platform === "tmall" + ? `近 30 天 ${1200 + index * 180} 人付款` + : `近 30 天 ${900 + index * 160} 人下单`, + specLabel: ["标准版", "大容量", "礼盒套装"][index] ?? "标准版", + highlights: [ + platform === "tmall" ? "强调详情页卖点叙事" : "强调自营与履约体验", + "支持价格与评论样本模拟" + ] + })); +} diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts new file mode 100644 index 0000000..16e3fac --- /dev/null +++ b/apps/api/src/server.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { createServer } from "./server"; + +describe("API server", () => { + it("returns platform readiness with jd blocked by default", async () => { + const app = createServer(); + await app.ready(); + + const response = await app.inject({ + method: "GET", + url: "/api/platforms/readiness" + }); + + expect(response.statusCode).toBe(200); + const payload = response.json(); + expect(payload.platforms).toHaveLength(2); + expect( + payload.platforms.find( + (platform: { platform: string }) => platform.platform === "jd" + ) + ).toMatchObject({ + platform: "jd", + ready: false, + searchRequirement: "required" + }); + + await app.close(); + }); + + it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => { + const app = createServer(); + await app.ready(); + + const response = await app.inject({ + method: "POST", + url: "/api/tasks", + payload: { + query: "iPhone 15 Pro", + perLinkLimit: 100, + taskTotalLimit: 500 + } + }); + + expect(response.statusCode).toBe(201); + const payload = response.json(); + expect(payload.task.taskStatus).toBe("AwaitingConfirmation"); + expect(payload.task.platformRuns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + platform: "tmall", + status: "AwaitingSelection" + }), + expect.objectContaining({ + platform: "jd", + status: "SearchBlocked" + }) + ]) + ); + + await app.close(); + }); + + it("supports NoSelection terminal state when user confirms nothing", async () => { + 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 confirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${taskId}/confirm`, + payload: { + selections: [] + } + }); + + expect(confirmResponse.statusCode).toBe(200); + expect(confirmResponse.json().task.taskStatus).toBe("NoSelection"); + + const reportResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${taskId}/report` + }); + expect(reportResponse.statusCode).toBe(404); + + await app.close(); + }); + + it("generates a completed report after confirming at least one candidate", async () => { + 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 candidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${taskId}/candidates` + }); + const firstCandidateId = + candidatesResponse.json().candidates.tmall[0].candidateId; + + const confirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${taskId}/confirm`, + payload: { + selections: [ + { + platform: "tmall", + candidateIds: [firstCandidateId] + } + ] + } + }); + + expect(confirmResponse.statusCode).toBe(200); + expect(confirmResponse.json().task.taskStatus).toBe("Completed"); + expect(confirmResponse.json().task.defaultReportVersion).toBe(1); + + const reportResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${taskId}/report` + }); + + expect(reportResponse.statusCode).toBe(200); + expect(reportResponse.json().report.task_status).toBe("Completed"); + expect(reportResponse.json().report.platform_insights).toHaveLength(2); + + await app.close(); + }); +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..015c45c --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,119 @@ +import type { + ConfirmTaskPayload, + CreateTaskInput, + PlatformId +} from "@cross-ai/domain"; +import cors from "@fastify/cors"; +import Fastify from "fastify"; + +import { InMemoryTaskStore } from "./store"; + +export function createServer() { + const app = Fastify({ logger: false }); + const store = new InMemoryTaskStore(); + + app.register(cors, { origin: true }); + + app.get("/api/health", async () => ({ + status: "ok", + service: "cross-platform-products-ai-analyst-api" + })); + + app.get("/api/platforms/readiness", async () => ({ + platforms: store.getPlatformReadiness() + })); + + app.post<{ + Params: { platform: PlatformId }; + }>("/api/platforms/:platform/prepare", async (request, reply) => { + const readiness = store.preparePlatform(request.params.platform); + reply.code(200); + return { + platform: readiness.platform, + session_ready: readiness.ready, + last_prepared_at: readiness.lastPreparedAt + }; + }); + + app.post<{ + Body: CreateTaskInput; + }>("/api/tasks", async (request, reply) => { + const task = store.createTask(request.body); + reply.code(201); + return { task }; + }); + + app.get<{ + Params: { taskId: string }; + }>("/api/tasks/:taskId", async (request, reply) => { + const task = store.getTask(request.params.taskId); + if (!task) { + reply.code(404); + return { message: "Task not found." }; + } + return { task }; + }); + + app.get<{ + Params: { taskId: string }; + }>("/api/tasks/:taskId/candidates", async (request, reply) => { + const candidates = store.getCandidates(request.params.taskId); + if (!candidates) { + reply.code(404); + return { message: "Task not found." }; + } + return { candidates }; + }); + + app.post<{ + Params: { taskId: string }; + Body: ConfirmTaskPayload; + }>("/api/tasks/:taskId/confirm", async (request, reply) => { + try { + const task = store.confirmTask(request.params.taskId, request.body); + return { task }; + } catch { + reply.code(404); + return { message: "Task not found." }; + } + }); + + app.get<{ + Params: { taskId: string }; + Querystring: { version?: string }; + }>("/api/tasks/:taskId/report", async (request, reply) => { + const version = request.query.version ? Number(request.query.version) : undefined; + const report = store.getReport(request.params.taskId, version); + if (!report) { + reply.code(404); + return { message: "Report not found." }; + } + return { report }; + }); + + app.get("/api/history", async () => ({ + tasks: store.listHistory() + })); + + app.get<{ + Params: { taskId: string }; + }>("/api/tasks/:taskId/events", async (request, reply) => { + const task = store.getTask(request.params.taskId); + if (!task) { + reply.code(404); + return { message: "Task not found." }; + } + + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive" + }); + reply.raw.write(`event: task.snapshot\n`); + reply.raw.write(`data: ${JSON.stringify(task)}\n\n`); + reply.raw.end(); + return reply; + }); + + return app; +} diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts new file mode 100644 index 0000000..830d991 --- /dev/null +++ b/apps/api/src/store.ts @@ -0,0 +1,459 @@ +import { + deriveTaskStatusFromConfirmedPlatforms, + mapPlatformStatusToExecutionStatus, + platformCatalog, + platformCatalogMap, + platforms, + type CandidateRecord, + type ConfirmTaskPayload, + type CreateTaskInput, + type HistoryTaskRecord, + type PlatformId, + type PlatformRunRecord, + type SessionReadinessRecord, + type TaskEventRecord, + type TaskRecord +} from "@cross-ai/domain"; +import { type ReportSnapshot, parseReportSnapshot } from "@cross-ai/report-schema"; +import { randomUUID } from "node:crypto"; + +import { createMockCandidates } from "./mock-data"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function createPlatformCandidatesRecord(): Record { + return { + tmall: [], + jd: [] + }; +} + +export class InMemoryTaskStore { + private readonly tasks = new Map(); + private readonly reports = new Map(); + private readonly readiness = new Map(); + + constructor() { + this.readiness.set("tmall", { + platform: "tmall", + ready: true, + searchRequirement: "recommended", + lastPreparedAt: nowIso() + }); + this.readiness.set("jd", { + platform: "jd", + ready: false, + searchRequirement: "required" + }); + } + + getPlatformReadiness(): SessionReadinessRecord[] { + return platforms.map((platform) => this.requireReadiness(platform)); + } + + preparePlatform(platform: PlatformId): SessionReadinessRecord { + const readiness = this.requireReadiness(platform); + const next: SessionReadinessRecord = { + ...readiness, + ready: true, + lastPreparedAt: nowIso() + }; + this.readiness.set(platform, next); + return next; + } + + createTask(input: CreateTaskInput): TaskRecord { + const timestamp = nowIso(); + const taskId = randomUUID(); + const task: TaskRecord = { + taskId, + query: input.query.trim(), + createdAt: timestamp, + updatedAt: timestamp, + perLinkLimit: input.perLinkLimit, + taskTotalLimit: input.taskTotalLimit, + taskStatus: "Draft", + taskStage: "precheck", + platformRuns: platformCatalog.map( + (platform): PlatformRunRecord => ({ + platform: platform.id, + searchRequirement: platform.searchRequirement, + status: "Pending", + candidateCount: 0, + selectedCandidateIds: [], + lastUpdatedAt: timestamp + }) + ), + platformCandidates: createPlatformCandidatesRecord(), + events: [], + reportVersions: [] + }; + + this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。"); + task.taskStatus = "Searching"; + task.taskStage = "precheck"; + this.pushEvent(task, "task.searching", "系统已开始执行平台预检查。"); + + this.runSearch(task); + this.tasks.set(task.taskId, task); + return task; + } + + listHistory(): HistoryTaskRecord[] { + return Array.from(this.tasks.values()) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map((task) => ({ + taskId: task.taskId, + query: task.query, + taskStatus: task.taskStatus, + updatedAt: task.updatedAt, + hasReport: (this.reports.get(task.taskId)?.length ?? 0) > 0, + defaultReportVersion: task.defaultReportVersion, + failedPlatforms: task.platformRuns + .filter((run) => run.status === "Failed") + .map((run) => run.platform), + blockedPlatforms: task.platformRuns + .filter( + (run) => run.status === "Blocked" || run.status === "SearchBlocked" + ) + .map((run) => run.platform) + })); + } + + getTask(taskId: string): TaskRecord | undefined { + return this.tasks.get(taskId); + } + + getCandidates(taskId: string): Record | undefined { + return this.tasks.get(taskId)?.platformCandidates; + } + + confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord { + const task = this.requireTask(taskId); + const selectionMap = new Map( + payload.selections.map((selection) => [selection.platform, selection.candidateIds]) + ); + + for (const run of task.platformRuns) { + const selectedCandidateIds = selectionMap.get(run.platform) ?? []; + + if (selectedCandidateIds.length > 0) { + run.status = "Selected"; + run.selectedCandidateIds = selectedCandidateIds; + run.lastUpdatedAt = nowIso(); + } else if (run.status === "AwaitingSelection") { + run.status = "Skipped"; + run.selectedCandidateIds = []; + run.lastUpdatedAt = nowIso(); + } + } + + task.taskStage = "confirmation"; + task.updatedAt = nowIso(); + this.pushEvent(task, "task.confirmed", "候选确认已提交。"); + + const selectedRuns = task.platformRuns.filter( + (run) => run.selectedCandidateIds.length > 0 + ); + + if (selectedRuns.length === 0) { + task.taskStatus = "NoSelection"; + this.pushEvent(task, "task.no_selection", "用户未确认任何商品链接,任务结束。"); + return task; + } + + task.taskStatus = "Running"; + 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"; + 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} 已生成。`); + + return task; + } + + getReport(taskId: string, version?: number): ReportSnapshot | undefined { + const reports = this.reports.get(taskId); + if (!reports || reports.length === 0) { + return undefined; + } + + if (typeof version === "number") { + return reports.find((report) => report.report_version === version); + } + + return reports[reports.length - 1]; + } + + private runSearch(task: TaskRecord): void { + 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} 未返回候选结果。` + ); + } + + task.taskStage = "confirmation"; + task.taskStatus = "AwaitingConfirmation"; + task.updatedAt = nowIso(); + this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。"); + } + + private buildReport(task: TaskRecord): ReportSnapshot { + const reportVersion = (this.reports.get(task.taskId)?.length ?? 0) + 1; + const completedRuns = task.platformRuns.filter((run) => run.status === "Completed"); + const blockedPlatforms = task.platformRuns + .filter((run) => run.status === "Blocked" || run.status === "SearchBlocked") + .map((run) => run.platform); + const failedPlatforms = task.platformRuns + .filter((run) => run.status === "Failed") + .map((run) => run.platform); + const selectedCandidates = task.platformRuns.flatMap((run) => + task.platformCandidates[run.platform].filter((candidate) => + run.selectedCandidateIds.includes(candidate.candidateId) + ) + ); + const reviewSampleCount = Math.min( + selectedCandidates.length * task.perLinkLimit, + task.taskTotalLimit + ); + const evidenceIndex = selectedCandidates.map((candidate, index) => ({ + evidence_id: `evidence-${task.taskId}-${index + 1}`, + platform: candidate.platform, + source_type: "product" as const, + source_url: candidate.productUrl, + review_ref: null, + snippet: `${candidate.title} 在 ${candidate.storeName} 下展示 ${candidate.specLabel},当前价格 ${candidate.priceLabel}。`, + captured_at: nowIso() + })); + + const sharedInsight = (title: string, statement: string, evidenceIds: string[]) => ({ + card_id: randomUUID(), + title, + statement, + confidence: evidenceIds.length > 0 ? ("high" as const) : ("medium" as const), + sample_flag: + evidenceIds.length > 0 ? ("sufficient" as const) : ("insufficient" as const), + source_scope: { + platforms: + completedRuns.length > 0 + ? completedRuns.map((run) => run.platform) + : (["tmall"] as const), + link_count: selectedCandidates.length, + review_count: reviewSampleCount + }, + evidence_ids: evidenceIds + }); + + return parseReportSnapshot({ + report_id: `report-${task.taskId}-${reportVersion}`, + report_version: reportVersion, + task_id: task.taskId, + generated_at: nowIso(), + task_status: task.taskStatus, + summary: { + headline: + completedRuns.length > 1 + ? "双平台已形成可回看报告。" + : "已基于当前可用平台生成首版结构化报告。", + key_points: [ + `已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`, + blockedPlatforms.length > 0 + ? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话。` + : "当前已确认平台全部处理完成。" + ], + limitations: [ + blockedPlatforms.length > 0 + ? "被阻塞平台未进入可发布洞察范围。" + : "当前为第一批开发样板数据,后续将替换为真实采集链路。" + ] + }, + product_snapshot: { + query: task.query, + normalized_product_name: task.query, + platform_count: task.platformRuns.length, + selected_link_count: selectedCandidates.length, + review_sample_count: reviewSampleCount, + analysis_time_range: { + start: task.createdAt, + end: nowIso() + } + }, + platform_insights: task.platformRuns.map((run) => { + const platformEvidenceIds = evidenceIndex + .filter((evidence) => evidence.platform === run.platform) + .map((evidence) => evidence.evidence_id); + const selectedForPlatform = selectedCandidates.filter( + (candidate) => candidate.platform === run.platform + ); + const prices = selectedForPlatform.map((candidate) => candidate.price); + return { + platform: run.platform, + execution_status: mapPlatformStatusToExecutionStatus(run.status), + selected_link_count: run.selectedCandidateIds.length, + price_range: + prices.length > 0 + ? { + min: Math.min(...prices), + max: Math.max(...prices) + } + : null, + selling_points: + selectedForPlatform.length > 0 + ? [ + sharedInsight( + `${platformCatalogMap[run.platform].label} 卖点稳定`, + `${platformCatalogMap[run.platform].label} 当前候选集中强调 ${selectedForPlatform[0]?.highlights[0] ?? "基础卖点"}。`, + platformEvidenceIds + ) + ] + : [], + positive_themes: + selectedForPlatform.length > 0 + ? [ + sharedInsight( + `${platformCatalogMap[run.platform].label} 正向反馈集中`, + "模拟样本显示用户更关注核心功能与履约确定性。", + platformEvidenceIds + ) + ] + : [], + negative_themes: + run.status === "SearchBlocked" + ? [ + sharedInsight( + `${platformCatalogMap[run.platform].label} 当前未进入采集`, + run.reason ?? "当前平台需要先恢复会话。", + [] + ) + ] + : [], + store_diff_notes: + selectedForPlatform.length > 1 + ? [ + sharedInsight( + `${platformCatalogMap[run.platform].label} 候选存在规格差异`, + "同平台已确认多个链接,报告保留差异而不强行合并。", + platformEvidenceIds + ) + ] + : [] + }; + }), + cross_platform_insights: [ + sharedInsight( + "当前报告以平台级摘要为主", + completedRuns.length > 1 + ? "系统保留链接级差异,同时默认从平台级组织结论。" + : "当前仅部分平台完成执行,跨平台对比视角仍然保留但覆盖有限。", + evidenceIndex.map((evidence) => evidence.evidence_id) + ) + ], + recommendations: [ + sharedInsight( + "继续补齐受阻平台会话", + blockedPlatforms.length > 0 + ? `建议优先处理 ${blockedPlatforms + .map((platform) => platformCatalogMap[platform].label) + .join("、")} 的会话准备,再发起下一轮任务。` + : "建议基于当前样板链路继续扩展到真实采集策略。", + evidenceIndex.map((evidence) => evidence.evidence_id) + ) + ], + evidence_index: evidenceIndex, + quality_flags: { + sample_insufficient: reviewSampleCount < 30, + partial_platform_failure: + blockedPlatforms.length > 0 || failedPlatforms.length > 0, + blocked_platforms: blockedPlatforms, + failed_platforms: failedPlatforms + } + }); + } + + private pushEvent(task: TaskRecord, type: string, message: string): void { + const event: TaskEventRecord = { + eventId: randomUUID(), + createdAt: nowIso(), + type, + message + }; + task.events.push(event); + task.updatedAt = event.createdAt; + } + + private requireTask(taskId: string): TaskRecord { + const task = this.tasks.get(taskId); + if (!task) { + throw new Error(`Task ${taskId} not found.`); + } + return task; + } + + private requireReadiness(platform: PlatformId): SessionReadinessRecord { + const readiness = this.readiness.get(platform); + if (!readiness) { + throw new Error(`Platform ${platform} not found.`); + } + return readiness; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..54d2b2a --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": [ + "node", + "vitest/globals" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..6393d2c --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + 跨平台商品聚合与 AI 分析 + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..bccf8d3 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "@cross-ai/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@cross-ai/domain": "file:../../packages/domain", + "@cross-ai/report-schema": "file:../../packages/report-schema", + "@tanstack/react-query": "^5.96.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", + "vite": "^8.0.3" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..d3b762c --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,610 @@ +import { + platformCatalogMap, + type CandidateRecord, + type PlatformId +} from "@cross-ai/domain"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Navigate, + Route, + Routes, + useNavigate, + useParams, + useSearchParams +} from "react-router-dom"; + +import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill"; +import { TaskContextHeader } from "./components/TaskContextHeader"; +import { TaskSpine } from "./components/TaskSpine"; +import { + confirmTask, + createTask, + getHistoryTasks, + getPlatformReadiness, + getTask, + getTaskCandidates, + getTaskReport, + preparePlatform +} from "./lib/api"; + +function Layout(props: { children: React.ReactNode }) { + return ( +
+ +
{props.children}
+
+ ); +} + +function NewTaskPage() { + const navigate = useNavigate(); + const readinessQuery = useQuery({ + queryKey: ["platform-readiness"], + queryFn: getPlatformReadiness + }); + const [query, setQuery] = useState(""); + const [perLinkLimit, setPerLinkLimit] = useState(100); + const [taskTotalLimit, setTaskTotalLimit] = useState(500); + + const createTaskMutation = useMutation({ + mutationFn: createTask, + onSuccess: ({ task }) => { + navigate(`/tasks/${task.taskId}/confirm`); + } + }); + + return ( + +
+
+

Task Composer

+

从一个商品描述开始,先召回,再确认,再生成报告。

+

+ 当前先落地 Phase 0 / 1 基线:统一工作台、共享状态模型、候选确认和结构化报告壳层。 +

+
+
{ + event.preventDefault(); + createTaskMutation.mutate({ + query, + perLinkLimit, + taskTotalLimit + }); + }} + > +