diff --git a/furniture-3d-system/scripts/report-bom-cost-shadow-divergence.ts b/furniture-3d-system/scripts/report-bom-cost-shadow-divergence.ts new file mode 100644 index 00000000..cc53286c --- /dev/null +++ b/furniture-3d-system/scripts/report-bom-cost-shadow-divergence.ts @@ -0,0 +1,326 @@ +import { loadEnvConfig } from '@next/env' +import { Prisma } from '@prisma/client' +import type { DynamicBomRealtimeMarketQuote } from '../src/lib/bom-costing/dynamic-bom-cost-domain' + +loadEnvConfig(process.cwd()) + +/** + * 工程侧 shadow 报表:对比每个 SKU 场景里“整件锚定预估”与“结构拆解预估”的偏差。 + * 数据全部来自 BomCostingScenario.realtimeMarketQuote(计算时已持久化),不改动产品链路, + * 输出只进工程域(stdout / --json),不进入前端工作台。 + */ + +type CliOptions = { + json: boolean + topLimit: number +} + +const DEFAULT_TOP_LIMIT = 15 + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { json: false, topLimit: DEFAULT_TOP_LIMIT } + for (const argument of argv) { + if (argument === '--json') options.json = true + else if (argument.startsWith('--top=')) { + const topLimit = Number(argument.slice('--top='.length)) + if (!Number.isSafeInteger(topLimit) || topLimit < 1 || topLimit > 200) { + throw new Error('--top must be an integer between 1 and 200') + } + options.topLimit = topLimit + } else if (argument === '--help' || argument === '-h') { + console.log([ + '用法: npm run report:bom-cost-shadow-divergence [--json] [--top=N]', + '', + ' --json 以 JSON 输出完整报表(便于入库或对比)', + ' --top=N 偏差排行条数,默认 15', + ].join('\n')) + process.exit(0) + } else { + throw new Error(`未知参数:${argument}(--help 查看用法)`) + } + } + return options +} + +function numberOrNull(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +type ScenarioClassification = + | 'ANCHORED_WITH_STRUCTURE' + | 'ANCHORED_STRUCTURE_MISSING' + | 'SIGNAL_UNAVAILABLE' + | 'SIGNAL_NOT_CONFIGURED' + +type ScenarioRow = { + scenarioId: string + caseId: string + caseStatus: string + sourceHost: string + categorySkill: string + externalSkuId: string + createdAt: string + classification: ScenarioClassification + quoteStatus: string + anchorCost: number | null + structuralCost: number | null + divergencePct: number | null +} + +function classifyScenario(quote: DynamicBomRealtimeMarketQuote): { + classification: ScenarioClassification + anchorCost: number | null + structuralCost: number | null + divergencePct: number | null +} { + const anchorCost = numberOrNull(quote.procurementCost) + const structuralCost = numberOrNull(quote.structuralCost) + if (quote.status === 'AVAILABLE' && anchorCost !== null && anchorCost > 0) { + if (structuralCost === null) { + return { classification: 'ANCHORED_STRUCTURE_MISSING', anchorCost, structuralCost, divergencePct: null } + } + return { + classification: 'ANCHORED_WITH_STRUCTURE', + anchorCost, + structuralCost, + divergencePct: Number((((structuralCost - anchorCost) / anchorCost) * 100).toFixed(2)), + } + } + return { + classification: quote.status === 'NOT_CONFIGURED' ? 'SIGNAL_NOT_CONFIGURED' : 'SIGNAL_UNAVAILABLE', + anchorCost, + structuralCost, + divergencePct: null, + } +} + +function percentile(sortedValues: number[], p: number) { + if (sortedValues.length === 0) return null + const index = Math.min( + sortedValues.length - 1, + Math.max(0, Math.ceil((p / 100) * sortedValues.length) - 1), + ) + return sortedValues[index] +} + +function median(values: number[]) { + return percentile(values, 50) +} + +function buildReport(rows: ScenarioRow[], topLimit: number) { + const byClassification = rows.reduce>((acc, row) => { + acc[row.classification] = (acc[row.classification] ?? 0) + 1 + return acc + }, {}) + const divergences = rows + .filter((row) => row.divergencePct !== null) + .map((row) => row.divergencePct as number) + const absSorted = [...divergences.map(Math.abs)].sort((left, right) => left - right) + const signedSorted = [...divergences].sort((left, right) => left - right) + const buckets = { + within10Pct: absSorted.filter((value) => value <= 10).length, + between10And30Pct: absSorted.filter((value) => value > 10 && value <= 30).length, + over30Pct: absSorted.filter((value) => value > 30).length, + } + const byCase = new Map() + for (const row of rows) { + const entry = byCase.get(row.caseId) ?? { + caseId: row.caseId, + caseStatus: row.caseStatus, + sourceHost: row.sourceHost, + total: 0, + anchored: 0, + divergenceSum: 0, + divergenceCount: 0, + } + entry.total += 1 + if (row.classification === 'ANCHORED_WITH_STRUCTURE' || row.classification === 'ANCHORED_STRUCTURE_MISSING') { + entry.anchored += 1 + } + if (row.divergencePct !== null) { + entry.divergenceSum += Math.abs(row.divergencePct) + entry.divergenceCount += 1 + } + byCase.set(row.caseId, entry) + } + const byCategorySkill = new Map() + for (const row of rows) { + const key = row.categorySkill || '未解析品类' + const group = byCategorySkill.get(key) ?? [] + group.push(row) + byCategorySkill.set(key, group) + } + const categorySkillRollups = Array.from(byCategorySkill.entries()) + .map(([categorySkill, groupRows]) => { + const groupDivergences = groupRows + .flatMap((row) => row.divergencePct === null ? [] : [Math.abs(row.divergencePct)]) + .sort((left, right) => left - right) + return { + categorySkill, + total: groupRows.length, + anchored: groupRows.filter((row) => row.classification === 'ANCHORED_WITH_STRUCTURE').length, + anchoredStructureMissing: groupRows.filter((row) => row.classification === 'ANCHORED_STRUCTURE_MISSING').length, + signalUnavailable: groupRows.filter((row) => row.classification === 'SIGNAL_UNAVAILABLE').length, + signalNotConfigured: groupRows.filter((row) => row.classification === 'SIGNAL_NOT_CONFIGURED').length, + comparableCount: groupDivergences.length, + meanAbsPct: groupDivergences.length === 0 + ? null + : Number((groupDivergences.reduce((sum, value) => sum + value, 0) / groupDivergences.length).toFixed(2)), + medianAbsPct: median(groupDivergences), + } + }) + .sort((left, right) => right.total - left.total) + return { + scenarioCount: rows.length, + byClassification, + byCategorySkill: categorySkillRollups, + divergence: { + comparableCount: divergences.length, + meanAbsPct: divergences.length === 0 + ? null + : Number((absSorted.reduce((sum, value) => sum + value, 0) / absSorted.length).toFixed(2)), + medianAbsPct: median(absSorted), + p80AbsPct: percentile(absSorted, 80), + minSignedPct: signedSorted[0] ?? null, + maxSignedPct: signedSorted[signedSorted.length - 1] ?? null, + buckets, + }, + topDivergences: [...rows] + .filter((row) => row.divergencePct !== null) + .sort((left, right) => Math.abs(right.divergencePct as number) - Math.abs(left.divergencePct as number)) + .slice(0, topLimit), + byCase: [...byCase.values()].sort((left, right) => right.total - left.total), + generatedAt: new Date().toISOString(), + } +} + +function printHumanReport(report: ReturnType) { + console.log(`BOM 成本 shadow 偏差报表(生成于 ${report.generatedAt})`) + console.log('') + console.log(`场景总数:${report.scenarioCount}`) + for (const [classification, count] of Object.entries(report.byClassification)) { + console.log(` ${classification}: ${count}`) + } + const divergence = report.divergence + console.log('') + console.log(`可对比(锚定 + 结构双价齐全):${divergence.comparableCount} 个 SKU`) + if (divergence.comparableCount > 0) { + console.log(` 偏差(结构-锚定)/锚定 均值 |d| ${divergence.meanAbsPct}%,中位 ${divergence.medianAbsPct}%,P80 ${divergence.p80AbsPct}%`) + console.log(` 区间 ${divergence.minSignedPct}% ~ ${divergence.maxSignedPct}%`) + console.log(` 分布 |d|≤10%: ${divergence.buckets.within10Pct},10%<|d|≤30%: ${divergence.buckets.between10And30Pct},|d|>30%: ${divergence.buckets.over30Pct}`) + } + console.log('') + console.log('偏差排行(|d| 降序,前 ' + report.topDivergences.length + ' 条):') + for (const row of report.topDivergences) { + console.log( + ` ${row.createdAt.slice(0, 10)} case=${row.caseId.slice(-8)} sku=${row.externalSkuId} 锚定=${row.anchorCost} 结构=${row.structuralCost} d=${row.divergencePct}%`, + ) + } + console.log('') + console.log(`按品类 Skill 聚合(${report.byCategorySkill.length} 个):`) + console.log(' 品类 | SKU总数 | 双价可比 | 结构缺价靠锚定 | 无实时信号 | 未配置 | 中位|d|') + for (const rollup of report.byCategorySkill) { + console.log( + ` ${rollup.categorySkill} | ${rollup.total} | ${rollup.comparableCount} | ${rollup.anchoredStructureMissing} | ${rollup.signalUnavailable} | ${rollup.signalNotConfigured} | ${rollup.medianAbsPct === null ? '--' : rollup.medianAbsPct + '%'}`, + ) + } + console.log('') + console.log(`按案例聚合(${report.byCase.length} 个案例,前 20):`) + for (const entry of report.byCase.slice(0, 20)) { + const avgAbs = entry.divergenceCount === 0 + ? '--' + : (entry.divergenceSum / entry.divergenceCount).toFixed(2) + console.log( + ` case=${entry.caseId.slice(-8)} [${entry.caseStatus}] ${entry.sourceHost} SKU=${entry.total} 锚定=${entry.anchored} 平均|d|=${avgAbs}%`, + ) + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const { prisma } = await import('../src/lib/prisma') + try { + const scenarios = await prisma.bomCostingScenario.findMany({ + where: { + kind: 'SAME_MATERIAL', + isCurrent: true, + case: { deletedAt: null }, + realtimeMarketQuote: { not: Prisma.DbNull }, + }, + select: { + id: true, + createdAt: true, + realtimeMarketQuote: true, + case: { select: { id: true, status: true, sourceHost: true } }, + sku: { select: { externalSkuId: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + const rows: ScenarioRow[] = [] + let skippedMalformed = 0 + const analysisRuns = await prisma.bomCostingAnalysisRun.findMany({ + where: { caseId: { in: [...new Set(scenarios.map((scenario) => scenario.case.id))] } }, + select: { + caseId: true, + status: true, + resolvedCategorySkillId: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }) + const categorySkillFallback = new Map() + const categorySkillReady = new Map() + for (const run of analysisRuns) { + if (!categorySkillFallback.has(run.caseId)) { + categorySkillFallback.set(run.caseId, run.resolvedCategorySkillId) + } + if (run.status === 'READY' && !categorySkillReady.has(run.caseId)) { + categorySkillReady.set(run.caseId, run.resolvedCategorySkillId) + } + } + const categorySkillByCase = new Map([...categorySkillFallback, ...categorySkillReady]) + for (const scenario of scenarios) { + const quote = scenario.realtimeMarketQuote as unknown as DynamicBomRealtimeMarketQuote | null + if (!quote || typeof quote !== 'object' || typeof quote.status !== 'string') { + skippedMalformed += 1 + continue + } + const classified = classifyScenario(quote) + rows.push({ + scenarioId: scenario.id, + caseId: scenario.case.id, + caseStatus: scenario.case.status, + sourceHost: scenario.case.sourceHost, + categorySkill: categorySkillByCase.get(scenario.case.id) ?? '', + externalSkuId: scenario.sku.externalSkuId, + createdAt: scenario.createdAt.toISOString(), + quoteStatus: quote.status, + ...classified, + }) + } + const report = buildReport(rows, options.topLimit) + if (options.json) { + console.log(JSON.stringify({ ...report, skippedMalformed }, null, 2)) + } else { + if (skippedMalformed > 0) { + console.log(`跳过无法解析的 quote 记录:${skippedMalformed} 条`) + } + printHumanReport(report) + } + } finally { + await prisma.$disconnect() + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/furniture-3d-system/scripts/run-bom-costing-agent-trial.ts b/furniture-3d-system/scripts/run-bom-costing-agent-trial.ts new file mode 100644 index 00000000..2c0c7082 --- /dev/null +++ b/furniture-3d-system/scripts/run-bom-costing-agent-trial.ts @@ -0,0 +1,222 @@ +import { loadEnvConfig } from '@next/env' +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import type { DynamicBomRealtimeMarketQuote } from '../src/lib/bom-costing/dynamic-bom-cost-domain' +import type { BomCostingAgentRunResult } from '../src/server/services/bom-costing/agent/agent-contracts' + +loadEnvConfig(process.cwd()) + +// server 服务模块带 server-only 守卫,脚本进程需要空 stub(与 sync-oa-board 相同模式)。 +async function ensureServerOnlyStub() { + const stubDir = path.join(process.cwd(), 'node_modules', 'server-only') + await mkdir(stubDir, { recursive: true }) + await writeFile(path.join(stubDir, 'index.js'), 'module.exports = {}\n', 'utf8') +} + +/** + * BOM 成本预估 agent 旁路试跑(工程域专用,不写任何业务表、不进管线): + * 对指定案例/SKU 运行 agent 估算,并与现行引擎落库结果对比—— + * 展示价(可能为锚定)、结构拆解价、锚定采购价三方对照。 + * 替换 1688 方案前,必须先在本脚本上完成足够样本的实测验收。 + */ + +type CliOptions = { + caseId: string | null + latestCount: number | null + skuId: string | null + maxImages: number + json: boolean +} + +const DEFAULT_MAX_IMAGES = 8 + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + caseId: null, + latestCount: null, + skuId: null, + maxImages: DEFAULT_MAX_IMAGES, + json: false, + } + for (const argument of argv) { + if (argument === '--help' || argument === '-h') { + console.log([ + '用法: npm run trial:bom-costing-agent -- --case=<案例ID> [--sku=] [--max-images=N] [--json]', + ' 或: npm run trial:bom-costing-agent -- --latest=N (最近 N 个 READY 案例的主 SKU)', + '', + ' --case= 指定案例 ID(与 --latest 二选一)', + ' --sku= 指定 SKU 记录 ID(默认取排序最前的 SKU)', + ' --max-images=N 送入模型的图片上限,默认 8', + ' --json 以 JSON 输出完整结果(含 trace)', + '', + '注意:本脚本会发起真实模型调用(有费用),并读取案例归档图片。', + ].join('\n')) + process.exit(0) + } else if (argument.startsWith('--case=')) { + options.caseId = argument.slice('--case='.length).trim() + } else if (argument.startsWith('--latest=')) { + const count = Number(argument.slice('--latest='.length)) + if (!Number.isSafeInteger(count) || count < 1 || count > 20) { + throw new Error('--latest must be an integer between 1 and 20') + } + options.latestCount = count + } else if (argument.startsWith('--sku=')) { + options.skuId = argument.slice('--sku='.length).trim() + } else if (argument.startsWith('--max-images=')) { + const maxImages = Number(argument.slice('--max-images='.length)) + if (!Number.isSafeInteger(maxImages) || maxImages < 1 || maxImages > 16) { + throw new Error('--max-images must be an integer between 1 and 16') + } + options.maxImages = maxImages + } else if (argument === '--json') { + options.json = true + } else { + throw new Error(`未知参数:${argument}(--help 查看用法)`) + } + } + if (!options.caseId && options.latestCount === null) { + throw new Error('必须指定 --case=<案例ID> 或 --latest=N') + } + return options +} + +function agentTotalFromResult(result: BomCostingAgentRunResult) { + if (!result.categoryTotals) return null + let total = 0 + for (const [key, value] of Object.entries(result.categoryTotals)) { + if (key === 'unresolvedLineCount' || typeof value !== 'number') continue + total += value + } + return Math.round(total * 100) / 100 +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + await ensureServerOnlyStub() + const { prisma } = await import('../src/lib/prisma') + const { runBomCostingAgentTrialForSku } = await import( + '../src/server/services/bom-costing/agent/bom-costing-agent-runner' + ) + try { + let targets: Array<{ caseId: string; skuId: string | null }> + if (options.caseId) { + targets = [{ caseId: options.caseId, skuId: options.skuId }] + } else { + const cases = await prisma.bomCostingCase.findMany({ + where: { status: 'READY', deletedAt: null }, + orderBy: { updatedAt: 'desc' }, + take: options.latestCount!, + select: { id: true, skus: { orderBy: { sortOrder: 'asc' }, take: 1, select: { id: true } } }, + }) + targets = cases.map((sourceCase) => ({ + caseId: sourceCase.id, + skuId: sourceCase.skus[0]?.id ?? null, + })) + } + + const reports: unknown[] = [] + for (const [index, target] of targets.entries()) { + let skuId = target.skuId + if (!skuId) { + skuId = await prisma.bomCostingSku.findFirst({ + where: { caseId: target.caseId }, + orderBy: { sortOrder: 'asc' }, + select: { id: true }, + }).then((sku) => sku?.id ?? null) + } + if (!skuId) { + console.log(`[${index + 1}/${targets.length}] 案例 ${target.caseId} 没有 SKU,跳过`) + continue + } + const scenario = await prisma.bomCostingScenario.findFirst({ + where: { skuId, kind: 'SAME_MATERIAL', isCurrent: true }, + select: { + id: true, + status: true, + totalCost: true, + estimateLow: true, + estimateHigh: true, + realtimeMarketQuote: true, + createdAt: true, + }, + }) + const quote = (scenario?.realtimeMarketQuote ?? null) as unknown as DynamicBomRealtimeMarketQuote | null + const displayedTotal = scenario?.totalCost === null || scenario?.totalCost === undefined + ? null + : Number(scenario.totalCost) + const structuralCost = quote?.structuralCost ?? null + const anchorCost = quote?.procurementCost ?? null + + console.log(`\n[${index + 1}/${targets.length}] case=${target.caseId.slice(-8)} sku=${skuId.slice(-8)} 运行中…`) + try { + const trial = await runBomCostingAgentTrialForSku({ + caseId: target.caseId, + skuId, + maxImages: options.maxImages, + }) + const agentTotal = agentTotalFromResult(trial.result) + const divergence = + agentTotal !== null && displayedTotal !== null && displayedTotal > 0 + ? Number((((agentTotal - displayedTotal) / displayedTotal) * 100).toFixed(2)) + : null + const report = { + caseId: trial.caseId, + skuId: trial.skuId, + externalSkuId: trial.externalSkuId, + skillCode: trial.skillCode, + selectedImageCount: trial.selectedImageCount, + agent: { + status: trial.result.status, + total: agentTotal, + estimateRange: trial.result.estimate?.estimateRange ?? null, + categoryTotals: trial.result.categoryTotals, + warnings: trial.result.warnings, + usage: trial.result.usage, + trace: trial.result.trace, + }, + currentEngine: { + scenarioStatus: scenario?.status ?? null, + displayedTotal, + structuralCost, + anchorCost, + scenarioCreatedAt: scenario?.createdAt?.toISOString() ?? null, + }, + divergenceVsDisplayedPct: divergence, + } + reports.push(report) + if (options.json) continue + console.log(` agent:${trial.result.status},总额 ¥${agentTotal ?? '--'},` + + `图片 ${trial.selectedImageCount} 张,skill=${trial.skillCode ?? '无'}`) + console.log(` agent 分类:${JSON.stringify(trial.result.categoryTotals)}`) + console.log(` 现行引擎:展示 ¥${displayedTotal ?? '--'},结构 ¥${structuralCost ?? '--'},锚定 ¥${anchorCost ?? '--'}`) + console.log(` agent vs 展示价偏差:${divergence === null ? '--' : `${divergence}%`}`) + if (trial.result.warnings.length > 0) { + console.log(` 警告:${trial.result.warnings.join(';').slice(0, 300)}`) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.log(` 运行失败:${message}`) + reports.push({ + caseId: target.caseId, + skuId, + error: message, + currentEngine: { displayedTotal, structuralCost, anchorCost }, + }) + } + } + + if (options.json) { + console.log(JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2)) + } else { + const completed = reports.filter((report) => 'agent' in (report as Record)) + console.log(`\n完成:${completed.length}/${targets.length},明细可用 --json 查看(含 trace)`) + } + } finally { + await prisma.$disconnect() + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx index 86a40708..d6d9015d 100644 --- a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.test.tsx @@ -62,11 +62,11 @@ describe('AI 服务配置模型选择', () => { fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' })) await waitFor(() => expect(modelSelect).toBeEnabled()) + fireEvent.click(modelSelect) expect(screen.getByRole('option', { name: 'GPT-5.6' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'GPT-5.6 Mini' })).toBeInTheDocument() - expect(screen.queryByPlaceholderText('未获取到模型列表,可手动输入')).not.toBeInTheDocument() - fireEvent.change(modelSelect, { target: { value: 'gpt-5.6' } }) + fireEvent.click(screen.getByRole('option', { name: 'GPT-5.6' })) fireEvent.click(screen.getByRole('button', { name: '保存' })) expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', { @@ -117,15 +117,16 @@ describe('AI 服务配置模型选择', () => { fireEvent.change(screen.getByPlaceholderText('如:生产环境、测试 Key'), { target: { value: '通用抠图' } }) fireEvent.change(screen.getByPlaceholderText('https://ai.yydd.ai'), { target: { value: 'https://api.example.com/v1' } }) fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } }) - fireEvent.change(screen.getByRole('combobox', { name: '接口协议' }), { - target: { value: 'openai-compatible-image-edit' }, - }) + + fireEvent.click(screen.getByRole('combobox', { name: '接口协议' })) expect(screen.getByRole('option', { name: '异步媒体任务' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('option', { name: 'OpenAI 兼容图片编辑' })) fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' })) const modelSelect = screen.getByRole('combobox', { name: '模型' }) await waitFor(() => expect(modelSelect).toBeEnabled()) - fireEvent.change(modelSelect, { target: { value: 'image-edit-v3' } }) + fireEvent.click(modelSelect) + fireEvent.click(screen.getByRole('option', { name: 'Image Edit V3' })) fireEvent.click(screen.getByRole('button', { name: '保存' })) expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', expect.objectContaining({ @@ -143,4 +144,59 @@ describe('AI 服务配置模型选择', () => { extra: { apiMode: 'openai-compatible-image-edit' }, }) }) + + it('商品抠图目录合并上游抠图模型,选中后自动切换异步媒体任务协议', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + success: true, + data: { + ok: true, + status: 200, + latency: 150, + models: [ + { value: 'remove-background', label: '智能抠图(remove-background)', source: 'async-matting' }, + { value: 'gpt-image-2', label: 'GPT-Image-2', source: 'gateway' }, + ], + endpointPath: '/api/external/image/models', + error: null, + }, + }), + }) + global.fetch = fetchMock as typeof fetch + const onSave = jest.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('如:生产环境、测试 Key'), { target: { value: '线上抠图' } }) + fireEvent.change(screen.getByPlaceholderText('https://ai.yydd.ai'), { target: { value: 'https://api.vip98k.com' } }) + fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } }) + fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' })) + + const modelSelect = screen.getByRole('combobox', { name: '模型' }) + await waitFor(() => expect(modelSelect).toBeEnabled()) + fireEvent.click(modelSelect) + + const mattingOption = screen.getByRole('option', { name: '【抠图】智能抠图(remove-background)' }) + const gatewayOption = screen.getByRole('option', { name: 'GPT-Image-2' }) + // 抠图模型排在通用模型之前,便于直接选用 + expect(mattingOption.compareDocumentPosition(gatewayOption) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + fireEvent.click(mattingOption) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + + expect(onSave).toHaveBeenCalledWith({ + name: '线上抠图', + baseUrl: 'https://api.vip98k.com', + apiKey: 'secret', + model: 'remove-background', + extra: { apiMode: 'async-media-task' }, + }) + }) }) diff --git a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx index 8b1204a7..a86b2c7d 100644 --- a/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx +++ b/furniture-3d-system/src/app/admin/ai-render/AiRenderConfigClient.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { Button } from '@/components/ui/Button' +import { Select } from '@/components/ui/Select' import { useToast } from '@/components/ui/Toast' import { useAuth } from '@/hooks/useAuth' import { @@ -44,7 +45,7 @@ interface ProviderConfig { type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal' interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string } -interface ModelOption { value: string; label: string } +interface ModelOption { value: string; label: string; source?: 'gateway' | 'async-matting' } type BackgroundRemovalApiMode = | 'external-image-gateway' @@ -176,6 +177,7 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { return typeof record.value === 'string' && typeof record.label === 'string' }) : [] + // 后端已按 抠图优先 排序;这里仅透传来源标记供选型时对齐协议。 setModels(fetchedModels) setModelsLoaded(true) setModel((currentModel) => { @@ -209,10 +211,13 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { {serviceType === 'image-background-removal' && (
- - { setBackgroundRemovalApiMode(event.target.value as BackgroundRemovalApiMode) @@ -221,12 +226,11 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: { setModels([]) setTestResult({ status: 'idle', message: '' }) }} - className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] focus:ring-[#47646b]/40" - > - {(Object.entries(BACKGROUND_REMOVAL_API_MODE_META) as Array<[BackgroundRemovalApiMode, { label: string; desc: string }]>).map(([value, meta]) => ( - - ))} - + options={(Object.entries(BACKGROUND_REMOVAL_API_MODE_META) as Array<[BackgroundRemovalApiMode, { label: string; desc: string }]>).map(([value, meta]) => ({ + value, + label: meta.label, + }))} + />

{BACKGROUND_REMOVAL_API_MODE_META[backgroundRemovalApiMode].desc}

)} @@ -245,23 +249,43 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
- + placeholder={modelsLoaded ? '请选择模型' : '请先测试并获取模型列表'} + options={[ + ...(!modelsLoaded && model ? [{ value: model, label: `当前配置:${model}` }] : []), + ...[...models] + .sort((left, right) => ( + (left.source === 'async-matting' ? 0 : 1) - (right.source === 'async-matting' ? 0 : 1) + )) + .map((item) => ({ + value: item.value, + label: item.source === 'async-matting' ? `【抠图】${item.label}` : item.label, + })), + ]} + /> + {serviceType === 'image-background-removal' && models.some((item) => item.source === 'async-matting') && ( +

标记【抠图】的是上游专用抠图模型,选用后自动切换为异步媒体任务协议。

+ )}
diff --git a/furniture-3d-system/src/components/ui/Select.tsx b/furniture-3d-system/src/components/ui/Select.tsx index 751379d6..b768f881 100644 --- a/furniture-3d-system/src/components/ui/Select.tsx +++ b/furniture-3d-system/src/components/ui/Select.tsx @@ -76,6 +76,10 @@ export const Select = React.forwardRef( value, onChange, children, + // onClick/onKeyDown 由组件内部的触发按钮接管,不透传(其 select 元素 + // 事件签名与 button 不兼容,且外部场景不应覆盖交互实现)。 + onClick: _ignoredOnClick, + onKeyDown: _ignoredOnKeyDown, ...props }, _ref) => { @@ -256,6 +260,7 @@ export const Select = React.forwardRef( aria-expanded={open} aria-haspopup="listbox" aria-label={label} + {...(props as unknown as React.ComponentProps<'button'>)} > run.status === 'READY')?.resolvedCategorySkillId + ?? sourceCase.analysisRuns[0]?.resolvedCategorySkillId + ?? null + if (skillCode === '') skillCode = null + return { + id: sourceCase.id, + title: sourceCase.title, + sourceHost: sourceCase.sourceHost, + status: sourceCase.status, + skillCode, + } + } + + async findSkuForAgentTrial(skuId: string) { + return prisma.bomCostingSku.findUnique({ + where: { id: skuId }, + select: { + id: true, + caseId: true, + externalSkuId: true, + name: true, + lengthMm: true, + widthMm: true, + heightMm: true, + weightKg: true, + packageWeightKg: true, + sortOrder: true, + }, + }) + } + + async listPrimarySkuIdForCase(caseId: string) { + const sku = await prisma.bomCostingSku.findFirst({ + where: { caseId }, + orderBy: { sortOrder: 'asc' }, + select: { id: true }, + }) + return sku?.id ?? null + } + + async listArchivedAssetsForCase(caseId: string) { + const sourceCase = await prisma.bomCostingCase.findUnique({ + where: { id: caseId }, + select: { sourceSnapshotId: true, deletedAt: true }, + }) + if (!sourceCase || sourceCase.deletedAt !== null || !sourceCase.sourceSnapshotId) return [] + return prisma.bomCostingSourceAsset.findMany({ + where: { + sourceSnapshotId: sourceCase.sourceSnapshotId, + status: 'STORED', + }, + select: { + id: true, + kind: true, + status: true, + originalUrl: true, + storedUrl: true, + externalSkuIds: true, + sortOrder: true, + }, + orderBy: { sortOrder: 'asc' }, + }) + } + + async searchReferenceRows(input: { + category?: string + materialKeywords?: string[] + limit?: number + }): Promise> { + const run = await prisma.bomCostingReferenceSyncRun.findFirst({ + where: { status: BomCostingReferenceSyncStatus.SUCCEEDED }, + orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }], + select: { id: true }, + }) + if (!run) return [] + const filters: Prisma.BomCostingReferenceRowWhereInput[] = [] + if (input.category?.trim()) { + const category = input.category.trim() + filters.push({ + OR: [ + { categoryCode: { equals: category, mode: 'insensitive' } }, + { category: { contains: category, mode: 'insensitive' } }, + ], + }) + } + const keywords = (input.materialKeywords ?? []) + .map((keyword) => keyword.trim()) + .filter(Boolean) + if (keywords.length > 0) { + filters.push({ + OR: keywords.flatMap((keyword) => [ + { materialName: { contains: keyword, mode: 'insensitive' } }, + { materialCode: { contains: keyword, mode: 'insensitive' } }, + ]), + }) + } + const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20), 1), 50) + const rows = await prisma.bomCostingReferenceRow.findMany({ + where: { + syncRunId: run.id, + unitPrice: { gt: 0 }, + ...(filters.length > 0 ? { AND: filters } : {}), + }, + select: { + sourceRowId: true, + sourceSku: true, + category: true, + categoryCode: true, + partName: true, + materialName: true, + unit: true, + unitPrice: true, + amount: true, + }, + orderBy: [{ materialName: 'asc' }, { sourceSku: 'asc' }], + take: limit, + }) + return rows.flatMap((row) => row.unitPrice === null ? [] : [{ ...row, unitPrice: row.unitPrice }]) + } + + async listEnabledMaterialOptions() { + return prisma.bomCostingMaterialOption.findMany({ + where: { enabled: true }, + select: { + id: true, + code: true, + name: true, + category: true, + aliases: true, + }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }) + } +} + +export const bomCostingAgentRepository = new BomCostingAgentRepository() diff --git a/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts b/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts index 07b4af07..7f890db0 100644 --- a/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/provider-model-catalog.service.test.ts @@ -188,6 +188,74 @@ describe('ProviderModelCatalogService', () => { ) }) + it('商品抠图默认协议合并网关与异步抠图目录,抠图模型排前且带来源', async () => { + const fetchMock = jest.fn() + .mockResolvedValueOnce(createResponse(200, { + data: { + models: [{ id: 'gpt-image-2', modelId: 'gpt-image-2', displayName: 'GPT Image 2' }], + }, + })) + .mockResolvedValueOnce(createResponse(200, { + data: { + sections: [{ + id: 'task', + models: [{ + id: 'remove-background', + display_name: '智能抠图', + available: true, + input_modes: ['file', 'url'], + output_modes: ['image'], + invocation: { path: '/extensions/v1/tasks' }, + }], + }], + }, + })) + global.fetch = fetchMock as typeof fetch + + const result = await service.fetchCatalog({ + serviceType: 'image-background-removal', + baseUrl: 'https://api.vip98k.com', + apiKey: 'secret', + }) + + expect(result.ok).toBe(true) + expect(result.models).toEqual([ + { value: 'remove-background', label: '智能抠图(remove-background)', source: 'async-matting' }, + { value: 'gpt-image-2', label: 'GPT Image 2', source: 'gateway' }, + ]) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.vip98k.com/api/external/image/models', + expect.any(Object), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.vip98k.com/api/v2/catalog/models', + expect.any(Object), + ) + }) + + it('商品抠图目录密钥无权限时中断后续目录尝试', async () => { + const fetchMock = jest.fn().mockResolvedValue(createResponse(401, { + error: { message: 'unauthorized' }, + })) + global.fetch = fetchMock as typeof fetch + + const result = await service.fetchCatalog({ + serviceType: 'image-background-removal', + baseUrl: 'https://image.example.com', + apiKey: 'bad-secret', + }) + + expect(result).toMatchObject({ + ok: false, + status: 401, + models: [], + error: 'API 密钥无权读取模型列表,请检查密钥权限', + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + it('异步媒体任务目录会返回智能抠图等图片处理模型', async () => { const fetchMock = jest.fn().mockResolvedValue(createResponse(200, { data: { diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-contracts.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-contracts.ts new file mode 100644 index 00000000..3e1623ab --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-contracts.ts @@ -0,0 +1,254 @@ +import type { DynamicBomEstimateRange } from '@/lib/bom-costing/dynamic-bom-cost-domain' + +export const BOM_COSTING_AGENT_PROTOCOL_VERSION = 'bom-costing-agent/v1' as const + +export type BomCostingAgentEstimateCategory = + | 'BOARD' + | 'STEEL' + | 'ACCESSORY' + | 'PACKAGING' + | 'PROCESSING' + | 'LABOR' + +export interface BomCostingAgentEstimateLine { + lineKey: string + category: BomCostingAgentEstimateCategory + partName: string + material: string | null + quantity: number + unit: string + unitPrice: number | null + evidence: string[] +} + +export interface BomCostingAgentEstimate { + structureSummary: string + lines: BomCostingAgentEstimateLine[] + confidence: number + estimateRange: DynamicBomEstimateRange + openQuestions: string[] +} + +export type BomCostingAgentCategoryTotals = Partial< + Record +> & { unresolvedLineCount: number } + +export interface BomCostingAgentActionTool { + type: 'tool' + tool: string + arguments: Record +} + +export interface BomCostingAgentActionFinal { + type: 'final' + estimate: BomCostingAgentEstimate +} + +export type BomCostingAgentAction = BomCostingAgentActionTool | BomCostingAgentActionFinal + +export interface BomCostingAgentBudget { + maxRounds: number + maxModelCalls: number + maxWallClockMs: number +} + +export const DEFAULT_BOM_COSTING_AGENT_BUDGET: BomCostingAgentBudget = { + maxRounds: 12, + maxModelCalls: 16, + maxWallClockMs: 6 * 60_000, +} + +export type BomCostingAgentStepActionType = + | 'tool' + | 'final' + | 'parse_error' + | 'budget_exhausted' + +export interface BomCostingAgentStepTrace { + round: number + actionType: BomCostingAgentStepActionType + tool?: string + toolArguments?: unknown + toolResultSummary?: string + modelCallMs?: number + error?: string +} + +export type BomCostingAgentRunStatus = + | 'COMPLETED' + | 'PARTIAL' + | 'BUDGET_EXHAUSTED' + | 'FAILED' + +export interface BomCostingAgentRunResult { + status: BomCostingAgentRunStatus + estimate: BomCostingAgentEstimate | null + categoryTotals: BomCostingAgentCategoryTotals | null + warnings: string[] + trace: BomCostingAgentStepTrace[] + usage: { + rounds: number + modelCalls: number + elapsedMs: number + } +} + +const ESTIMATE_CATEGORIES: ReadonlySet = new Set([ + 'BOARD', + 'STEEL', + 'ACCESSORY', + 'PACKAGING', + 'PROCESSING', + 'LABOR', +]) + +function extractJsonPayload(content: string) { + const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i) + const candidate = (fenced ? fenced[1] : content).trim() + const start = candidate.indexOf('{') + if (start === -1) return null + // 模型可能在一轮里拼接多个 JSON 对象(网关不总是截断);用括号配平 + // 取第一个完整对象,而不是首 { 到末 } 的非法整体。 + let depth = 0 + let inString = false + let escaped = false + for (let index = start; index < candidate.length; index += 1) { + const char = candidate[index] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === '"') { + inString = !inString + continue + } + if (inString) continue + if (char === '{') depth += 1 + else if (char === '}') { + depth -= 1 + if (depth === 0) return candidate.slice(start, index + 1) + } + } + return null +} + +function parseEstimateLine(value: unknown): BomCostingAgentEstimateLine | null { + if (typeof value !== 'object' || value === null) return null + const record = value as Record + const category = typeof record.category === 'string' ? record.category.toUpperCase() : '' + if (!ESTIMATE_CATEGORIES.has(category)) return null + const quantity = typeof record.quantity === 'number' ? record.quantity : Number.NaN + const unitPrice = + typeof record.unitPrice === 'number' && Number.isFinite(record.unitPrice) && record.unitPrice >= 0 + ? record.unitPrice + : null + if (!Number.isFinite(quantity) || quantity <= 0) return null + return { + lineKey: typeof record.lineKey === 'string' && record.lineKey.trim() ? record.lineKey.trim() : '', + category: category as BomCostingAgentEstimateCategory, + partName: typeof record.partName === 'string' ? record.partName : '', + material: typeof record.material === 'string' && record.material.trim() ? record.material : null, + quantity, + unit: typeof record.unit === 'string' && record.unit.trim() ? record.unit : '', + unitPrice, + evidence: Array.isArray(record.evidence) + ? record.evidence.filter((item): item is string => typeof item === 'string' && item.trim() !== '') + : [], + } +} + +export function parseBomCostingAgentEstimate(value: unknown): BomCostingAgentEstimate | null { + if (typeof value !== 'object' || value === null) return null + const record = value as Record + const confidence = typeof record.confidence === 'number' ? record.confidence : Number.NaN + const range = record.estimateRange + const min = typeof range === 'object' && range !== null ? (range as Record).min : undefined + const max = typeof range === 'object' && range !== null ? (range as Record).max : undefined + if ( + !Number.isFinite(confidence) || confidence < 0 || confidence > 1 || + typeof min !== 'number' || typeof max !== 'number' || !(min <= max) + ) return null + const lines = Array.isArray(record.lines) + ? record.lines.flatMap((line) => { + const parsed = parseEstimateLine(line) + return parsed ? [parsed] : [] + }) + : [] + return { + structureSummary: typeof record.structureSummary === 'string' ? record.structureSummary : '', + lines, + confidence, + estimateRange: { min, max }, + openQuestions: Array.isArray(record.openQuestions) + ? record.openQuestions.filter((item): item is string => typeof item === 'string') + : [], + } +} + +export function parseBomCostingAgentAction(content: string): { + action: BomCostingAgentAction | null + error: string | null +} { + const payload = extractJsonPayload(content) + if (!payload) { + return { action: null, error: '输出中未找到 JSON 对象' } + } + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch (error) { + return { action: null, error: `JSON 解析失败:${error instanceof Error ? error.message : '未知错误'}` } + } + if (typeof parsed !== 'object' || parsed === null) { + return { action: null, error: '动作必须是 JSON 对象' } + } + const record = parsed as Record + if (record.type === 'tool') { + if (typeof record.tool !== 'string' || !record.tool.trim()) { + return { action: null, error: 'tool 动作缺少 tool 字段' } + } + return { + action: { + type: 'tool', + tool: record.tool.trim(), + arguments: + typeof record.arguments === 'object' && record.arguments !== null + ? record.arguments as Record + : {}, + }, + error: null, + } + } + if (record.type === 'final') { + const estimate = parseBomCostingAgentEstimate(record.estimate) + if (!estimate) { + return { action: null, error: 'final 动作的 estimate 未通过契约校验(confidence/区间/行格式)' } + } + return { action: { type: 'final', estimate }, error: null } + } + return { action: null, error: '动作 type 只能是 tool 或 final' } +} + +// 总额由代码从行汇总,模型只给行;缺价行计入 unresolved,不参与总额。 +export function summarizeBomCostingAgentEstimate( + estimate: BomCostingAgentEstimate, +): BomCostingAgentCategoryTotals { + const totals: BomCostingAgentCategoryTotals = { unresolvedLineCount: 0 } + for (const line of estimate.lines) { + if (line.unitPrice === null) { + totals.unresolvedLineCount += 1 + continue + } + const subtotal = line.unitPrice * line.quantity + totals[line.category] = roundMoney((totals[line.category] ?? 0) + subtotal) + } + return totals +} + +function roundMoney(value: number) { + return Math.round(value * 100) / 100 +} diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-model-caller.test.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-model-caller.test.ts new file mode 100644 index 00000000..c7356fe5 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-model-caller.test.ts @@ -0,0 +1,82 @@ +import { + callBomCostingMultimodalModel, + getBomCostingMultimodalProviderConfig, + type BomCostingMultimodalProviderConfig, +} from '../bom-costing-multimodal-gateway' +import { createGatewayBomCostingAgentModelCaller } from './agent-model-caller' + +jest.mock('../bom-costing-multimodal-gateway', () => ({ + callBomCostingMultimodalModel: jest.fn(), + getBomCostingMultimodalProviderConfig: jest.fn(), +})) + +const callMock = jest.mocked(callBomCostingMultimodalModel) +const resolveConfigMock = jest.mocked(getBomCostingMultimodalProviderConfig) + +const fixtureConfig: BomCostingMultimodalProviderConfig = { + baseUrl: 'https://model.internal/v1', + apiKey: 'test-key', + model: 'test-model', +} + +function successResult(content: string) { + return { + id: null, + model: 'test-model', + content, + promptTokens: null, + completionTokens: null, + durationMs: 10, + finishReason: null, + } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createGatewayBomCostingAgentModelCaller', () => { + it('带图片的轮次按 IMAGE_FACTS 调用,纯文本轮次按 SYNTHESIS 调用', async () => { + callMock.mockResolvedValue(successResult('{"type":"final","estimate":{}}')) + const caller = createGatewayBomCostingAgentModelCaller({ config: fixtureConfig }) + await caller({ + systemPrompt: 's', + userContent: [ + { type: 'text', text: '商品资料' }, + { type: 'image_url', image_url: { url: 'https://img' } }, + ], + }) + await caller({ systemPrompt: 's', userContent: [{ type: 'text', text: '工具结果…' }] }) + + expect(callMock).toHaveBeenCalledTimes(2) + expect(callMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ operation: 'IMAGE_FACTS' })) + expect(callMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ operation: 'SYNTHESIS' })) + }) + + it('透传 systemPrompt/userContent,并带默认 maxTokens 与显式超时', async () => { + callMock.mockResolvedValue(successResult('{}')) + const caller = createGatewayBomCostingAgentModelCaller({ config: fixtureConfig }) + const result = await caller({ systemPrompt: 'sys', userContent: 'plain', timeoutMs: 5_000 }) + expect(callMock).toHaveBeenCalledWith(expect.objectContaining({ + config: fixtureConfig, + systemPrompt: 'sys', + userContent: 'plain', + maxTokens: 4096, + timeoutMs: 5_000, + })) + expect(result).toEqual({ content: '{}', durationMs: 10 }) + }) + + it('未显式给 config 时惰性解析,服务未配置则抛错', async () => { + resolveConfigMock.mockResolvedValue(null) + const caller = createGatewayBomCostingAgentModelCaller() + await expect(caller({ systemPrompt: 's', userContent: 't' })) + .rejects.toThrow('多模态分析服务未配置') + + resolveConfigMock.mockResolvedValue(fixtureConfig) + callMock.mockResolvedValue(successResult('{}')) + await caller({ systemPrompt: 's', userContent: 't' }) + expect(resolveConfigMock).toHaveBeenCalledTimes(2) + expect(callMock).toHaveBeenCalledWith(expect.objectContaining({ config: fixtureConfig })) + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-model-caller.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-model-caller.ts new file mode 100644 index 00000000..c2e357a2 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-model-caller.ts @@ -0,0 +1,39 @@ +import { + callBomCostingMultimodalModel, + getBomCostingMultimodalProviderConfig, + type BomCostingMultimodalProviderConfig, + type BomCostingMultimodalUserContent, +} from '../bom-costing-multimodal-gateway' +import type { BomCostingAgentModelCaller } from './bom-costing-agent-loop' + +export const BOM_COSTING_AGENT_MAX_TOKENS = 4096 +export const BOM_COSTING_AGENT_TIMEOUT_MS = 120_000 + +function hasImageContent(userContent: BomCostingMultimodalUserContent) { + return Array.isArray(userContent) && userContent.some((part) => part.type === 'image_url') +} + +// agent 每轮输出是紧凑 JSON 动作;带图片的轮次按 IMAGE_FACTS 语义调用, +// 纯文本(工具观察回填后的续轮)按 SYNTHESIS 语义调用,复用网关的 +// 推理力度与重试策略。 +export function createGatewayBomCostingAgentModelCaller(input?: { + config?: BomCostingMultimodalProviderConfig + maxTokens?: number + defaultTimeoutMs?: number +}): BomCostingAgentModelCaller { + return async ({ systemPrompt, userContent, timeoutMs }) => { + const config = input?.config ?? await getBomCostingMultimodalProviderConfig() + if (!config) { + throw new Error('多模态分析服务未配置,无法运行 agent 估算') + } + const result = await callBomCostingMultimodalModel({ + config, + operation: hasImageContent(userContent) ? 'IMAGE_FACTS' : 'SYNTHESIS', + systemPrompt, + userContent, + maxTokens: input?.maxTokens ?? BOM_COSTING_AGENT_MAX_TOKENS, + timeoutMs: timeoutMs ?? input?.defaultTimeoutMs ?? BOM_COSTING_AGENT_TIMEOUT_MS, + }) + return { content: result.content, durationMs: result.durationMs } + } +} diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-skill-knowledge.test.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-skill-knowledge.test.ts new file mode 100644 index 00000000..aef26c7e --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-skill-knowledge.test.ts @@ -0,0 +1,59 @@ +import { getBomCostingCategorySkill } from '../category-skills' +import { renderBomCostingAgentSkillKnowledge, renderBomCostingAgentSkillKnowledgeByCode } from './agent-skill-knowledge' + +describe('renderBomCostingAgentSkillKnowledge', () => { + it('渲染真实 registry skill 的完整知识块', () => { + const skill = getBomCostingCategorySkill('CABINET_BOOKSHELF') + expect(skill).not.toBeNull() + const knowledge = renderBomCostingAgentSkillKnowledge(skill!) + + expect(knowledge).toContain('【品类】') + expect(knowledge).toContain(skill!.label) + expect(knowledge).toContain(`Skill ${skill!.version}`) + expect(knowledge).toContain('【识别信号】') + expect(knowledge).toContain('【必备组件】') + expect(skill!.requiredComponents.length).toBeGreaterThan(0) + const firstRequired = skill!.requiredComponents[0]! + expect(knowledge).toContain(`${firstRequired.partName}×${firstRequired.stableQuantity}`) + if (skill!.optionalComponents.length > 0) { + expect(knowledge).toContain('【可选组件】') + } + expect(knowledge).toContain('【校验规则】') + }) + + it('必备组件行包含数量与尺寸规则的关键信息', () => { + const skill = getBomCostingCategorySkill('WARDROBE') + expect(skill).not.toBeNull() + const knowledge = renderBomCostingAgentSkillKnowledge(skill!) + const withDimensionRule = skill!.requiredComponents.find((component) => component.dimensionRule) + if (withDimensionRule) { + expect(knowledge).toContain(withDimensionRule.dimensionRule) + } + const withMaterialScope = skill!.requiredComponents.find((component) => component.materialScope) + if (withMaterialScope) { + expect(knowledge).toContain(withMaterialScope.materialScope) + } + }) + + it('结构差异只在存在时渲染', () => { + const skill = getBomCostingCategorySkill('STORAGE_RACK') + expect(skill).not.toBeNull() + const knowledge = renderBomCostingAgentSkillKnowledge(skill!) + if (skill!.differences.some((difference) => difference.groupKind === 'STRUCTURE')) { + expect(knowledge).toContain('【结构差异】') + } else { + expect(knowledge).not.toContain('【结构差异】') + } + }) + + it('未知品类编码返回 null', () => { + expect(renderBomCostingAgentSkillKnowledgeByCode('NOT_A_SKILL')).toBeNull() + expect(renderBomCostingAgentSkillKnowledgeByCode(null)).toBeNull() + }) + + it('按编码渲染与直接传入 skill 等价', () => { + const skill = getBomCostingCategorySkill('TABLE') + expect(renderBomCostingAgentSkillKnowledgeByCode('TABLE')) + .toBe(renderBomCostingAgentSkillKnowledge(skill!)) + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-skill-knowledge.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-skill-knowledge.ts new file mode 100644 index 00000000..07e84da5 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-skill-knowledge.ts @@ -0,0 +1,76 @@ +import { + getBomCostingCategorySkill, + type BomCostingCategorySkill, +} from '../category-skills' + +function renderComponent(skill: BomCostingCategorySkill['requiredComponents'][number]) { + const parts = [ + `- ${skill.partName}×${skill.stableQuantity}${skill.unit ? `(${skill.unit})` : ''}`, + ] + if (skill.quantityRule) parts.push(`数量规则:${skill.quantityRule}`) + if (skill.dimensionRule) parts.push(`尺寸规则:${skill.dimensionRule}`) + if (skill.materialScope) parts.push(`材质:${skill.materialScope}`) + if (skill.primaryMaterialSlot) parts.push('继承主材质槽位') + return ` ${parts.join(';')}` +} + +export function renderBomCostingAgentSkillKnowledge(skill: BomCostingCategorySkill): string { + const sections: string[] = [] + sections.push(`【品类】${skill.label}(${skill.code},Skill ${skill.version})`) + + const positiveTerms = skill.positiveSignals + .flatMap((signal) => signal.terms.slice(0, 4)) + .slice(0, 16) + const exclusionTerms = skill.exclusionSignals + .flatMap((signal) => signal.terms.slice(0, 3)) + .slice(0, 10) + sections.push(`【识别信号】命中示例:${positiveTerms.join('、') || '无'}`) + if (exclusionTerms.length > 0) { + sections.push(`【排除信号】出现即不应判为本品类:${exclusionTerms.join('、')}`) + } + + if (skill.requiredComponents.length > 0) { + sections.push( + ['【必备组件】(缺任一项需在 openQuestions 说明)'] + .concat(skill.requiredComponents.map(renderComponent)) + .join('\n'), + ) + } + if (skill.optionalComponents.length > 0) { + const optionalLines = skill.optionalComponents.map((component) => { + const rendered = renderComponent(component).trim() + const triggers = component.keywords.slice(0, 4).join('、') + return ` ${rendered}${triggers ? `;激活线索:${triggers}` : ''}` + }) + sections.push(['【可选组件】(必须有可见证据或激活线索才计入)'].concat(optionalLines).join('\n')) + } + + const structureDifferences = skill.differences + .filter((difference) => difference.groupKind === 'STRUCTURE') + .slice(0, 12) + if (structureDifferences.length > 0) { + sections.push( + ['【结构差异】(改拓扑的变体,按商品文字/图片确认)'] + .concat(structureDifferences.map((difference) => + ` ${difference.description}${difference.terms.length > 0 ? `(线索:${difference.terms.slice(0, 4).join('、')})` : ''}`, + )) + .join('\n'), + ) + } + + if (skill.validations.length > 0) { + sections.push( + ['【校验规则】(final 前自查)'] + .concat(skill.validations.map((validation) => + ` [${validation.severity}] ${validation.rule}`, + )) + .join('\n'), + ) + } + return sections.join('\n\n') +} + +export function renderBomCostingAgentSkillKnowledgeByCode(code: string | null | undefined) { + const skill = getBomCostingCategorySkill(code) + return skill ? renderBomCostingAgentSkillKnowledge(skill) : null +} diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-tool-providers.test.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-tool-providers.test.ts new file mode 100644 index 00000000..0d17dec2 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-tool-providers.test.ts @@ -0,0 +1,117 @@ +import { Prisma } from '@prisma/client' +import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository' +import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search' +import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers' + +jest.mock('@/server/repositories/bom-costing-agent.repository', () => ({ + bomCostingAgentRepository: { + listArchivedAssetsForCase: jest.fn(), + searchReferenceRows: jest.fn(), + listEnabledMaterialOptions: jest.fn(), + }, +})) + +jest.mock('../bom-costing-1688-image-search', () => ({ + bomCosting1688ImageSearchService: { + findRealtimeQuote: jest.fn(), + }, +})) + +const repositoryMock = jest.mocked(bomCostingAgentRepository) +const realtimeMock = jest.mocked(bomCosting1688ImageSearchService) + +describe('createDefaultBomCostingAgentToolDeps', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('listArchivedImages 映射归档素材,存储地址优先于原始地址并带 SKU 标签', async () => { + repositoryMock.listArchivedAssetsForCase.mockResolvedValue([ + { id: 'a1', kind: 'MAIN' as const, status: 'STORED' as const, originalUrl: 'https://expiring/original', storedUrl: 'https://oss/stored', externalSkuIds: ['SKU-1'], sortOrder: 0 }, + { id: 'a2', kind: 'DETAIL' as const, status: 'STORED' as const, originalUrl: 'https://expiring/only', storedUrl: '', externalSkuIds: [], sortOrder: 1 }, + ]) + const deps = createDefaultBomCostingAgentToolDeps() + const images = await deps.listArchivedImages({ caseId: 'case-1' }) + expect(repositoryMock.listArchivedAssetsForCase).toHaveBeenCalledWith('case-1') + expect(images).toEqual([ + { assetId: 'a1', kind: 'MAIN', url: 'https://oss/stored', status: 'STORED', skuTags: ['SKU-1'] }, + { assetId: 'a2', kind: 'DETAIL', url: 'https://expiring/only', status: 'STORED', skuTags: [] }, + ]) + }) + + it('searchReferencePrices 把 Decimal 映射为数字并保留 null 金额', async () => { + repositoryMock.searchReferenceRows.mockResolvedValue([ + { + sourceRowId: 'row-1', + sourceSku: 'SKU-A', + category: '板材', + categoryCode: '01', + partName: '侧板', + materialName: '颗粒板 18mm', + unit: '块', + unitPrice: new Prisma.Decimal('25.50'), + amount: new Prisma.Decimal('51.00'), + }, + { + sourceRowId: 'row-2', + sourceSku: 'SKU-B', + category: '五金', + categoryCode: '02', + partName: '三合一连接件', + materialName: '锌合金', + unit: '个', + unitPrice: new Prisma.Decimal('0.4'), + amount: null, + }, + ]) + const deps = createDefaultBomCostingAgentToolDeps() + const candidates = await deps.searchReferencePrices({ category: 'BOARD', materialKeywords: ['颗粒板'] }, { caseId: 'c' }) + expect(repositoryMock.searchReferenceRows).toHaveBeenCalledWith({ + category: 'BOARD', + materialKeywords: ['颗粒板'], + limit: undefined, + }) + expect(candidates).toEqual([ + { referenceSku: 'SKU-A', partName: '侧板', materialName: '颗粒板 18mm', unit: '块', unitPrice: 25.5, amount: 51, sourceRowId: 'row-1' }, + { referenceSku: 'SKU-B', partName: '三合一连接件', materialName: '锌合金', unit: '个', unitPrice: 0.4, amount: null, sourceRowId: 'row-2' }, + ]) + }) + + it('listMaterialOptions 过滤掉非字符串别名', async () => { + repositoryMock.listEnabledMaterialOptions.mockResolvedValue([ + { id: 'm1', code: 'PB18', name: '颗粒板 18mm', category: 'BOARD', aliases: ['刨花板', '', 42] }, + ]) + const deps = createDefaultBomCostingAgentToolDeps() + const options = await deps.listMaterialOptions({ caseId: 'c' }) + expect(options).toEqual([{ id: 'm1', name: '颗粒板 18mm', aliases: ['刨花板'] }]) + }) + + it('searchRealtimeMarket 只透出摘要字段,warnings 与 offers 不进转录', async () => { + realtimeMock.findRealtimeQuote.mockResolvedValue({ + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: 'AVAILABLE', + sourceImageUrl: 'https://img', + searchedAt: '2026-08-21T00:00:00.000Z', + procurementRate: 0.9, + marketPrice: 100, + procurementCost: 90, + estimateRange: { min: 85, max: 95 }, + confidence: 0.8, + comparableOfferCount: 5, + returnedOfferCount: 8, + warnings: ['1688 实时同款价格中位数 ¥100'], + offers: [{ supplier: '某店铺' }], + } as never) + const deps = createDefaultBomCostingAgentToolDeps() + const summary = await deps.searchRealtimeMarket({ imageUrl: 'https://img' }, { caseId: 'c' }) + expect(realtimeMock.findRealtimeQuote).toHaveBeenCalledWith({ imageUrl: 'https://img' }) + expect(summary).toEqual({ + status: 'AVAILABLE', + procurementCost: 90, + estimateRange: { min: 85, max: 95 }, + comparableOfferCount: 5, + }) + expect(Object.keys(summary as Record)).not.toContain('warnings') + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-tool-providers.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-tool-providers.ts new file mode 100644 index 00000000..455459c1 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-tool-providers.ts @@ -0,0 +1,56 @@ +import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository' +import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search' +import type { BomCostingAgentToolDeps } from './agent-tools' + +// 工具接线层:repository 只出原始行,这里负责映射成 agent 可消费的摘要。 +// 实时行情工具刻意只透出状态/采购参考价/区间/可比数,warnings 与 offers +// 留在服务端内部,避免进入 agent 转录后流向前端。 +export function createDefaultBomCostingAgentToolDeps(): BomCostingAgentToolDeps { + return { + listArchivedImages: async (context) => { + const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(context.caseId) + return assets.map((asset) => ({ + assetId: asset.id, + kind: asset.kind, + url: asset.storedUrl || asset.originalUrl, + status: asset.status, + skuTags: Array.isArray(asset.externalSkuIds) + ? (asset.externalSkuIds as unknown[]).filter((item): item is string => + typeof item === 'string' && item.trim() !== '') + : [], + })) + }, + searchReferencePrices: async (input) => { + const rows = await bomCostingAgentRepository.searchReferenceRows(input) + return rows.map((row) => ({ + referenceSku: row.sourceSku, + partName: row.partName, + materialName: row.materialName, + unit: row.unit, + unitPrice: Number(row.unitPrice), + amount: row.amount === null ? null : Number(row.amount), + sourceRowId: row.sourceRowId, + })) + }, + listMaterialOptions: async () => { + const options = await bomCostingAgentRepository.listEnabledMaterialOptions() + return options.map((option) => ({ + id: option.id, + name: option.name, + aliases: Array.isArray(option.aliases) + ? (option.aliases as unknown[]).filter((item): item is string => + typeof item === 'string' && item.trim() !== '') + : [], + })) + }, + searchRealtimeMarket: async ({ imageUrl }) => { + const quote = await bomCosting1688ImageSearchService.findRealtimeQuote({ imageUrl }) + return { + status: quote.status, + procurementCost: quote.procurementCost, + estimateRange: quote.estimateRange, + comparableOfferCount: quote.comparableOfferCount, + } + }, + } +} diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/agent-tools.ts b/furniture-3d-system/src/server/services/bom-costing/agent/agent-tools.ts new file mode 100644 index 00000000..c3260982 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/agent-tools.ts @@ -0,0 +1,185 @@ +import { + calculateBoardBatchCuttingPlan, + type BoardBatchCuttingPlan, + type BoardCuttingPartInput, + type BoardSheetInput, +} from '@/lib/bom-costing/board-sheet-cutting' + +export interface BomCostingAgentToolContext { + caseId: string + skuId?: string +} + +export interface BomCostingAgentToolRuntime { + name: string + description: string + execute: (input: unknown, context: BomCostingAgentToolContext) => Promise +} + +export interface BomCostingAgentArchivedImage { + assetId: string + kind: 'MAIN' | 'SKU' | 'DETAIL' + url: string + status: string + skuTags: string[] +} + +export interface BomCostingAgentReferenceCandidate { + referenceSku: string + partName: string + materialName: string + unit: string + unitPrice: number + amount: number | null + sourceRowId: string +} + +export interface BomCostingAgentMaterialOption { + id: string + name: string + aliases: string[] +} + +export interface BomCostingAgentRealtimeQuoteSummary { + status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED' + procurementCost: number | null + estimateRange: { min: number; max: number } | null + comparableOfferCount: number +} + +export interface BomCostingAgentToolDeps { + listArchivedImages: (context: BomCostingAgentToolContext) => Promise + searchReferencePrices: ( + input: { category?: string; materialKeywords?: string[]; limit?: number }, + context: BomCostingAgentToolContext, + ) => Promise + listMaterialOptions: (context: BomCostingAgentToolContext) => Promise + searchRealtimeMarket: ( + input: { imageUrl: string }, + context: BomCostingAgentToolContext, + ) => Promise +} + +interface BoardSheetEstimateInput { + sheet?: { + substrateId?: unknown + materialName?: unknown + sheetLengthMm?: unknown + sheetWidthMm?: unknown + fullSheetPrice?: unknown + } + panels?: Array> +} + +function positiveNumber(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function parseBoardSheetEstimateInput(input: unknown): { + sheet: BoardSheetInput + panels: BoardCuttingPartInput[] +} | null { + if (typeof input !== 'object' || input === null) return null + const record = input as BoardSheetEstimateInput + const sheetRecord = record.sheet + if (typeof sheetRecord !== 'object' || sheetRecord === null) return null + const sheetLengthMm = positiveNumber(sheetRecord.sheetLengthMm) + const sheetWidthMm = positiveNumber(sheetRecord.sheetWidthMm) + const fullSheetPrice = positiveNumber(sheetRecord.fullSheetPrice) + if (sheetLengthMm === null || sheetWidthMm === null || fullSheetPrice === null) return null + const panels = Array.isArray(record.panels) + ? record.panels.flatMap((panel, index) => { + const lengthMm = positiveNumber(panel.lengthMm) + const widthMm = positiveNumber(panel.widthMm) + const quantity = positiveNumber(panel.quantity) + if (lengthMm === null || widthMm === null || quantity === null) return [] + return [{ + lineId: typeof panel.lineId === 'string' && panel.lineId ? panel.lineId : `panel-${index + 1}`, + lengthMm, + widthMm, + quantity, + }] + }) + : [] + if (panels.length === 0) return null + return { + sheet: { + substrateId: typeof sheetRecord.substrateId === 'string' ? sheetRecord.substrateId : '', + materialName: typeof sheetRecord.materialName === 'string' ? sheetRecord.materialName : '', + sheetLengthMm, + sheetWidthMm, + fullSheetPrice, + }, + panels, + } +} + +export function createEstimateBoardSheetsTool(): BomCostingAgentToolRuntime { + return { + name: 'estimate_board_sheets', + description: + '按整张开料估算板材成本。输入 sheet(板材长宽 mm 与整张价格)与 panels(每块板件长宽 mm 与数量),' + + '返回张数、利用率、每行分摊成本。用于 BOARD 类行的定价与交叉验证。', + execute: async (input) => { + const parsed = parseBoardSheetEstimateInput(input) + if (!parsed) { + return { error: '输入不合法:需要 sheet{sheetLengthMm,sheetWidthMm,fullSheetPrice} 与 panels[{lengthMm,widthMm,quantity}]' } + } + const plan: BoardBatchCuttingPlan | null = calculateBoardBatchCuttingPlan(parsed.sheet, parsed.panels) + if (!plan) return { error: '开料计算未返回有效方案(板件过多或尺寸超出安全上限)' } + return { + rulesetVersion: plan.rulesetVersion, + selectedBatchSize: plan.selectedBatchSize, + sheetCount: plan.selectedBatch.sheetCount, + totalSheetCost: plan.selectedBatch.totalSheetCost, + perUnitSheetCost: plan.selectedBatch.perUnitSheetCost, + utilization: plan.selectedBatch.utilization, + allocations: plan.selectedBatch.allocations, + warnings: plan.warnings, + } + }, + } +} + +export function createBomCostingAgentTools(deps: BomCostingAgentToolDeps) { + return [ + { + name: 'list_archived_images', + description: '列出本案例已归档的商品图片(主图/SKU 图/详情图)与对象存储访问地址。', + execute: (_input: unknown, context: BomCostingAgentToolContext) => deps.listArchivedImages(context), + }, + { + name: 'search_reference_prices', + description: + '按分类或材质关键词检索公司历史 BOM 价格候选(来源为本地镜像)。输入 {category?, materialKeywords?, limit?}。', + execute: (input: unknown, context: BomCostingAgentToolContext) => { + const record = typeof input === 'object' && input !== null ? input as Record : {} + return deps.searchReferencePrices({ + category: typeof record.category === 'string' ? record.category : undefined, + materialKeywords: Array.isArray(record.materialKeywords) + ? record.materialKeywords.filter((item): item is string => typeof item === 'string') + : undefined, + limit: typeof record.limit === 'number' ? record.limit : undefined, + }, context) + }, + }, + { + name: 'list_material_options', + description: '列出公司启用材质目录(材质身份的唯一来源,含别名)。', + execute: (_input: unknown, context: BomCostingAgentToolContext) => deps.listMaterialOptions(context), + }, + { + name: 'search_realtime_market', + description: + '按商品图检索实时市场同款行情摘要(状态、整件采购参考价、区间、可比报价数)。仅作为交叉验证信号,不能替代逐行定价。', + execute: (input: unknown, context: BomCostingAgentToolContext) => { + const record = typeof input === 'object' && input !== null ? input as Record : {} + if (typeof record.imageUrl !== 'string' || !record.imageUrl) { + return Promise.resolve({ error: '输入不合法:需要 imageUrl' }) + } + return deps.searchRealtimeMarket({ imageUrl: record.imageUrl }, context) + }, + }, + createEstimateBoardSheetsTool(), + ] +} diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-loop.test.ts b/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-loop.test.ts new file mode 100644 index 00000000..8768def7 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-loop.test.ts @@ -0,0 +1,270 @@ +import { + parseBomCostingAgentAction, + summarizeBomCostingAgentEstimate, + type BomCostingAgentEstimate, +} from './agent-contracts' +import { createBomCostingAgentTools, createEstimateBoardSheetsTool } from './agent-tools' +import { runBomCostingAgentLoop, type BomCostingAgentModelCaller } from './bom-costing-agent-loop' + +function validEstimate(overrides: Partial = {}): BomCostingAgentEstimate { + return { + structureSummary: '三块侧板 + 底板 + 背板的简易柜体', + lines: [ + { + lineKey: 'board-side', + category: 'BOARD', + partName: '侧板', + material: '颗粒板 18mm', + quantity: 2, + unit: '块', + unitPrice: 25, + evidence: ['search_reference_prices 结果第 1 条'], + }, + { + lineKey: 'hardware-cam', + category: 'ACCESSORY', + partName: '三合一连接件', + material: null, + quantity: 12, + unit: '个', + unitPrice: 0.4, + evidence: ['search_reference_prices 结果第 3 条'], + }, + { + lineKey: 'labor-tbd', + category: 'LABOR', + partName: '组装人工', + material: null, + quantity: 1, + unit: '套', + unitPrice: null, + evidence: ['无价格依据,待补'], + }, + ], + confidence: 0.55, + estimateRange: { min: 55, max: 70 }, + openQuestions: ['板厚未能从图片确认'], + ...overrides, + } +} + +describe('parseBomCostingAgentAction', () => { + it('解析带 ```json 围栏的工具动作', () => { + const { action, error } = parseBomCostingAgentAction( + '说明文字\n```json\n{"type":"tool","tool":"search_reference_prices","arguments":{"category":"BOARD"}}\n```', + ) + expect(error).toBeNull() + expect(action).toEqual({ + type: 'tool', + tool: 'search_reference_prices', + arguments: { category: 'BOARD' }, + }) + }) + + it('解析 final 动作并校验 estimate 契约', () => { + const { action } = parseBomCostingAgentAction( + JSON.stringify({ type: 'final', estimate: validEstimate() }), + ) + expect(action?.type).toBe('final') + expect(action?.type === 'final' && action.estimate.lines).toHaveLength(3) + }) + + it('拒绝缺 confidence 或区间颠倒的 estimate', () => { + expect(parseBomCostingAgentAction( + JSON.stringify({ type: 'final', estimate: { ...validEstimate(), confidence: 1.5 } }), + ).action).toBeNull() + expect(parseBomCostingAgentAction( + JSON.stringify({ type: 'final', estimate: { ...validEstimate(), estimateRange: { min: 90, max: 20 } } }), + ).action).toBeNull() + }) + + it('拒绝非 JSON 输出与未知 type', () => { + expect(parseBomCostingAgentAction('我认为成本大概是 60 元').action).toBeNull() + expect(parseBomCostingAgentAction('{"type":"thinking"}').action).toBeNull() + }) + + it('多对象拼接时取第一个完整 JSON 对象', () => { + const { action } = parseBomCostingAgentAction( + '{"type":"tool","tool":"list_material_options","arguments":{}}{"type":"tool","tool":"list_archived_images","arguments":{}}', + ) + expect(action).toEqual({ + type: 'tool', + tool: 'list_material_options', + arguments: {}, + }) + }) + + it('字符串内的花括号不影响配平提取', () => { + const { action } = parseBomCostingAgentAction( + '前置说明 {"type":"tool","tool":"search_reference_prices","arguments":{"materialKeywords":["{特殊}字符"]}} 尾部', + ) + expect(action?.type).toBe('tool') + expect(action?.type === 'tool' && action.arguments.materialKeywords).toEqual(['{特殊}字符']) + }) +}) + +describe('summarizeBomCostingAgentEstimate', () => { + it('按分类汇总有价行,缺价行只计入未解决数', () => { + const totals = summarizeBomCostingAgentEstimate(validEstimate()) + expect(totals.BOARD).toBe(50) + expect(totals.ACCESSORY).toBe(4.8) + expect(totals.LABOR).toBeUndefined() + expect(totals.unresolvedLineCount).toBe(1) + }) +}) + +function fakeModelCaller(responses: string[], calls: { userContent: unknown }[] = []): BomCostingAgentModelCaller { + let index = 0 + return async (input) => { + calls.push({ userContent: input.userContent }) + const content = responses[Math.min(index, responses.length - 1)] + index += 1 + return { content: typeof content === 'string' ? content : JSON.stringify(content), durationMs: 5 } + } +} + +const mission = () => ({ + mission: { userContent: [{ type: 'text' as const, text: '商品图片与文字资料…' }], toolContext: { caseId: 'case-1', skuId: 'sku-1' } }, +}) + +describe('runBomCostingAgentLoop', () => { + it('工具调用后产出 final:完成态、工具收到解析后的参数与上下文、总额由代码汇总', async () => { + const executed: Array<{ input: unknown; context: unknown }> = [] + const tools = createBomCostingAgentTools({ + listArchivedImages: async () => [], + searchReferencePrices: async (query, context) => { + executed.push({ input: query, context }) + return [{ + referenceSku: 'SKU-1', + partName: '侧板', + materialName: '颗粒板', + unit: '块', + unitPrice: 25, + amount: 25, + sourceRowId: 'row-1', + }] + }, + listMaterialOptions: async () => [], + searchRealtimeMarket: async () => ({ + status: 'NOT_CONFIGURED' as const, + procurementCost: null, + estimateRange: null, + comparableOfferCount: 0, + }), + }) + const result = await runBomCostingAgentLoop({ + ...mission(), + toolRuntimes: tools, + callModel: fakeModelCaller([ + { type: 'tool', tool: 'search_reference_prices', arguments: { category: 'BOARD' } }, + { type: 'final', estimate: validEstimate() }, + ]), + }) + expect(result.status).toBe('COMPLETED') + expect(executed).toHaveLength(1) + expect(executed[0]!.input).toEqual({ category: 'BOARD', materialKeywords: undefined, limit: undefined }) + expect(executed[0]!.context).toEqual({ caseId: 'case-1', skuId: 'sku-1' }) + expect(result.categoryTotals?.BOARD).toBe(50) + expect(result.usage.modelCalls).toBe(2) + expect(result.trace.map((step) => step.actionType)).toEqual(['tool', 'final']) + }) + + it('未知工具会以系统提示回填观察,下一轮可继续', async () => { + const result = await runBomCostingAgentLoop({ + ...mission(), + toolRuntimes: [], + callModel: fakeModelCaller([ + { type: 'tool', tool: 'not_a_tool', arguments: {} }, + { type: 'final', estimate: validEstimate() }, + ]), + }) + expect(result.status).toBe('COMPLETED') + expect(result.trace[0]!.actionType).toBe('tool') + expect(result.trace[0]!.tool).toBe('not_a_tool') + }) + + it('连续两轮无法解析则失败', async () => { + const result = await runBomCostingAgentLoop({ + ...mission(), + toolRuntimes: [], + callModel: fakeModelCaller(['我觉得大概 60 元', '还是没法给出 JSON']), + }) + expect(result.status).toBe('FAILED') + expect(result.warnings[0]).toContain('无法解析为合法动作') + }) + + it('预算耗尽返回 BUDGET_EXHAUSTED 并守住轮次上限', async () => { + const result = await runBomCostingAgentLoop({ + mission: { + ...mission().mission, + budget: { maxRounds: 3, maxModelCalls: 3, maxWallClockMs: 60_000 }, + }, + toolRuntimes: [], + callModel: fakeModelCaller([ + { type: 'tool', tool: 'unknown', arguments: {} }, + { type: 'tool', tool: 'unknown', arguments: {} }, + { type: 'tool', tool: 'unknown', arguments: {} }, + ]), + }) + expect(result.status).toBe('BUDGET_EXHAUSTED') + expect(result.usage.rounds).toBe(3) + expect(result.usage.modelCalls).toBe(3) + }) + + it('final 无有效行视为部分结果', async () => { + const result = await runBomCostingAgentLoop({ + ...mission(), + toolRuntimes: [], + callModel: fakeModelCaller([ + { type: 'final', estimate: validEstimate({ lines: [] }) }, + ]), + }) + expect(result.status).toBe('PARTIAL') + expect(result.categoryTotals?.unresolvedLineCount).toBe(0) + }) + + it('工具抛错会以错误观察继续而不是中断', async () => { + const boom = createEstimateBoardSheetsTool() + expect(boom.name).toBe('estimate_board_sheets') + const failing = { + name: 'failing_tool', + description: 'always fails', + execute: async () => { + throw new Error('依赖不可用') + }, + } + const result = await runBomCostingAgentLoop({ + ...mission(), + toolRuntimes: [failing], + callModel: fakeModelCaller([ + { type: 'tool', tool: 'failing_tool', arguments: {} }, + { type: 'final', estimate: validEstimate() }, + ]), + }) + expect(result.status).toBe('COMPLETED') + expect(result.trace[0]!.toolResultSummary).toContain('依赖不可用') + }) +}) + +describe('estimate_board_sheets 工具', () => { + it('合法输入返回开料方案摘要', async () => { + const tool = createEstimateBoardSheetsTool() + const payload = await tool.execute({ + sheet: { substrateId: 'sub-1', materialName: '颗粒板 18mm', sheetLengthMm: 2440, sheetWidthMm: 1220, fullSheetPrice: 120 }, + panels: [ + { lineId: 'p1', lengthMm: 800, widthMm: 400, quantity: 4 }, + { lineId: 'p2', lengthMm: 600, widthMm: 300, quantity: 2 }, + ], + }, { caseId: 'c' }) as Record + expect(payload.error).toBeUndefined() + expect(typeof payload.sheetCount).toBe('number') + expect(typeof payload.perUnitSheetCost).toBe('number') + expect(payload.utilization).toBeGreaterThan(0) + }) + + it('非法输入返回结构化错误而不是抛出', async () => { + const tool = createEstimateBoardSheetsTool() + const payload = await tool.execute({ sheet: { sheetLengthMm: -1 } }, { caseId: 'c' }) as Record + expect(typeof payload.error).toBe('string') + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-loop.ts b/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-loop.ts new file mode 100644 index 00000000..7128f2a0 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-loop.ts @@ -0,0 +1,253 @@ +import type { BomCostingMultimodalUserContent } from '../bom-costing-multimodal-gateway' +import { + DEFAULT_BOM_COSTING_AGENT_BUDGET, + parseBomCostingAgentAction, + summarizeBomCostingAgentEstimate, + type BomCostingAgentBudget, + type BomCostingAgentRunResult, + type BomCostingAgentStepTrace, +} from './agent-contracts' +import type { BomCostingAgentToolContext, BomCostingAgentToolRuntime } from './agent-tools' + +export type BomCostingAgentModelCaller = (input: { + systemPrompt: string + userContent: BomCostingMultimodalUserContent + timeoutMs?: number +}) => Promise<{ content: string; durationMs: number }> + +const MAX_CONSECUTIVE_PARSE_FAILURES = 2 +const MAX_MODEL_CALL_ATTEMPTS = 3 +const MODEL_CALL_RETRY_DELAY_MS = 3_000 + +export interface BomCostingAgentMission { + userContent: BomCostingMultimodalUserContent + toolContext?: BomCostingAgentToolContext + budget?: Partial + signal?: AbortSignal +} + +function renderTranscript( + baseContent: BomCostingMultimodalUserContent, + observations: string[], +): BomCostingMultimodalUserContent { + if (observations.length === 0) return baseContent + if (typeof baseContent === 'string') { + return [baseContent, ...observations].join('\n\n') + } + return [...baseContent, ...observations.map((text) => ({ type: 'text' as const, text }))] +} + +function observationText(tool: string, payload: unknown) { + return `[工具结果 ${tool}] ${safeJsonStringify(payload)}` +} + +// 网关在流中检测到第一个完整 JSON 就会截断返回,但模型仍可能在一轮里 +// 拼接多个动作对象;这里检测"首个对象之后是否还有动作对象",用于回填协议提醒。 +function containsExtraActionObjects(content: string) { + const firstEnd = content.indexOf('}') + if (firstEnd === -1) return false + return /"\s*type"\s*:\s*"(?:tool|final)"/.test(content.slice(firstEnd + 1)) +} + +function safeJsonStringify(payload: unknown) { + try { + return JSON.stringify(payload) + } catch { + return '{"error":"工具结果无法序列化"}' + } +} + +export function buildBomCostingAgentSystemPrompt(input: { + toolRuntimes: BomCostingAgentToolRuntime[] + skillKnowledge?: string +}) { + const toolLines = input.toolRuntimes + .map((tool) => `- ${tool.name}: ${tool.description}`) + .join('\n') + return [ + '你是家具竞品成本预估 agent。目标:基于已归档的商品图片/文字与工具结果,产出同材质复刻的 BOM 成本“预估”(带区间与置信度),不是精确核算。', + '', + '工作方式(严格遵守):', + '- 每一轮回复只输出一个 JSON 对象,前后不允许有其他文字,也不允许输出多个 JSON 对象;', + '- 调用工具:{"type":"tool","tool":"<工具名>","arguments":{...}},然后停下等待下一轮的 [工具结果];', + '- 只有在需要的工具结果都已拿到之后,才输出最终结果:{"type":"final","estimate":{...}};禁止在未看到工具结果时就输出 final;', + '- search_realtime_market 需要 imageUrl 参数:先用 list_archived_images 取得图片地址再调用它。', + '', + 'estimate 契约(必须满足,否则会被拒绝):', + '- structureSummary:字符串,结构拆解的一句话说明', + '- lines:数组,每行 {lineKey, category(BOARD|STEEL|ACCESSORY|PACKAGING|PROCESSING|LABOR), partName, material|null, quantity(>0), unit, unitPrice(数字或 null), evidence[]};unitPrice 只能来自工具结果,没有依据就填 null,禁止编造价格', + '- evidence:每行至少一条,说明价格或数量来自哪个工具结果/图片', + '- confidence:0-1;estimateRange:{min,max} 且 min<=max', + '- openQuestions:字符串数组,列出影响精度但无法从现有信息确认的问题', + '', + '可用工具:', + toolLines, + '', + input.skillKnowledge + ? `品类知识与结构先验(优先按此推断缺失结构,但可见证据优先于先验):\n${input.skillKnowledge}` + : '未注入品类先验:按通用板式家具结构常识推断,并在 openQuestions 中说明置信度折扣。', + '', + '纪律:先看图和文字,再查价格工具,最后才出 final;工具结果不满足时可以多轮检索;总额不需要你计算(系统按行汇总)。', + ].join('\n') +} + +export async function runBomCostingAgentLoop(input: { + mission: BomCostingAgentMission + toolRuntimes: BomCostingAgentToolRuntime[] + callModel: BomCostingAgentModelCaller +}): Promise { + const budget: BomCostingAgentBudget = { + ...DEFAULT_BOM_COSTING_AGENT_BUDGET, + ...input.mission.budget, + } + const startedAt = Date.now() + const trace: BomCostingAgentStepTrace[] = [] + const warnings: string[] = [] + const observations: string[] = [] + const toolsByName = new Map(input.toolRuntimes.map((tool) => [tool.name, tool])) + let rounds = 0 + let modelCalls = 0 + let consecutiveParseFailures = 0 + + while (true) { + const elapsedMs = Date.now() - startedAt + if (rounds >= budget.maxRounds || modelCalls >= budget.maxModelCalls || elapsedMs >= budget.maxWallClockMs) { + warnings.push( + `预算耗尽:rounds=${rounds}/${budget.maxRounds},modelCalls=${modelCalls}/${budget.maxModelCalls},` + + `elapsedMs=${Math.round(elapsedMs)}/${budget.maxWallClockMs},未得到 final 结果`, + ) + trace.push({ round: rounds + 1, actionType: 'budget_exhausted' }) + return { + status: 'BUDGET_EXHAUSTED', + estimate: null, + categoryTotals: null, + warnings, + trace, + usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt }, + } + } + if (input.mission.signal?.aborted) { + warnings.push('任务被外部中止') + return { + status: 'FAILED', + estimate: null, + categoryTotals: null, + warnings, + trace, + usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt }, + } + } + + rounds += 1 + const callStartedAt = Date.now() + let content: string + try { + let attempt = 0 + while (true) { + attempt += 1 + try { + const result = await input.callModel({ + systemPrompt: buildBomCostingAgentSystemPrompt({ toolRuntimes: input.toolRuntimes }), + userContent: renderTranscript(input.mission.userContent, observations), + }) + content = result.content + modelCalls += 1 + break + } catch (error) { + // 上游抓取图片超时等瞬时错误常见(服务商跨境拉存储对象),有界重试。 + if (attempt >= MAX_MODEL_CALL_ATTEMPTS) throw error + await new Promise((resolve) => setTimeout(resolve, MODEL_CALL_RETRY_DELAY_MS)) + } + } + } catch (error) { + warnings.push(`模型调用失败:${error instanceof Error ? error.message : '未知错误'}`) + return { + status: 'FAILED', + estimate: null, + categoryTotals: null, + warnings, + trace, + usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt }, + } + } + const modelCallMs = Date.now() - callStartedAt + const { action, error } = parseBomCostingAgentAction(content) + + if (!action) { + consecutiveParseFailures += 1 + trace.push({ + round: rounds, + actionType: 'parse_error', + modelCallMs, + error: `${error ?? '未知原因'};内容预览:${content.slice(0, 160)}`, + }) + if (consecutiveParseFailures >= MAX_CONSECUTIVE_PARSE_FAILURES) { + warnings.push(`连续 ${consecutiveParseFailures} 轮输出无法解析为合法动作,任务终止`) + return { + status: 'FAILED', + estimate: null, + categoryTotals: null, + warnings, + trace, + usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt }, + } + } + observations.push( + `[系统提示] 上一轮输出无法解析(${error ?? '未知原因'})。请只输出一个 JSON 对象:` + + '{"type":"tool","tool":...,"arguments":{...}} 或 {"type":"final","estimate":{...}}', + ) + continue + } + consecutiveParseFailures = 0 + if (containsExtraActionObjects(content)) { + observations.push('[系统提示] 检测到本轮输出了多个 JSON 对象,已只执行第一个;后续每轮请严格只输出一个动作。') + } + + if (action.type === 'final') { + trace.push({ round: rounds, actionType: 'final', modelCallMs }) + if (action.estimate.lines.length === 0) { + warnings.push('final 结果没有任何有效行,视为部分结果') + return { + status: 'PARTIAL', + estimate: action.estimate, + categoryTotals: summarizeBomCostingAgentEstimate(action.estimate), + warnings, + trace, + usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt }, + } + } + return { + status: 'COMPLETED', + estimate: action.estimate, + categoryTotals: summarizeBomCostingAgentEstimate(action.estimate), + warnings, + trace, + usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt }, + } + } + + trace.push({ + round: rounds, + actionType: 'tool', + tool: action.tool, + toolArguments: action.arguments, + modelCallMs, + }) + const tool = toolsByName.get(action.tool) + if (!tool) { + observations.push( + `[系统提示] 未知工具 "${action.tool}"。可用工具:${[...toolsByName.keys()].join(', ')}`, + ) + continue + } + let payload: unknown + try { + payload = await tool.execute(action.arguments, input.mission.toolContext ?? { caseId: '' }) + } catch (error) { + payload = { error: `工具执行失败:${error instanceof Error ? error.message : '未知错误'}` } + } + const summary = safeJsonStringify(payload) + trace[trace.length - 1]!.toolResultSummary = summary.length > 400 ? `${summary.slice(0, 400)}…` : summary + observations.push(observationText(action.tool, payload)) + } +} diff --git a/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-runner.ts b/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-runner.ts new file mode 100644 index 00000000..bfaf3b4c --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/agent/bom-costing-agent-runner.ts @@ -0,0 +1,138 @@ +import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository' +import { resolveBomCostingImageSearchSourceUrl } from '../bom-costing-1688-image-search' +import type { BomCostingMultimodalUserContent } from '../bom-costing-multimodal-gateway' +import type { BomCostingAgentBudget, BomCostingAgentRunResult } from './agent-contracts' +import { runBomCostingAgentLoop } from './bom-costing-agent-loop' +import { createGatewayBomCostingAgentModelCaller } from './agent-model-caller' +import { renderBomCostingAgentSkillKnowledgeByCode } from './agent-skill-knowledge' +import { createBomCostingAgentTools } from './agent-tools' +import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers' + +// 旁路试验入口:只读案例数据、只调工具与模型,不写任何业务表、 +// 不进处理管线。对比与验收在工程侧脚本里做,替换锚定前必须先过实测。 +const DEFAULT_MAX_IMAGES = 8 + +export interface BomCostingAgentTrialImageAsset { + assetId: string + kind: 'MAIN' | 'SKU' | 'DETAIL' + url: string + externalSkuIds: unknown +} + +function assetSkuTags(asset: BomCostingAgentTrialImageAsset) { + return Array.isArray(asset.externalSkuIds) + ? asset.externalSkuIds.filter((item): item is string => typeof item === 'string') + : [] +} + +// 图片优先级:本 SKU 专属图 > 主图 > 详情图;同优先级保持归档顺序。 +export function selectAgentTrialImages( + assets: readonly BomCostingAgentTrialImageAsset[], + externalSkuId: string, + maxImages: number, +) { + const skuTagged = assets.filter((asset) => assetSkuTags(asset).includes(externalSkuId)) + const main = assets.filter((asset) => asset.kind === 'MAIN') + const detail = assets.filter((asset) => asset.kind === 'DETAIL') + const seen = new Set() + const selected: BomCostingAgentTrialImageAsset[] = [] + for (const asset of [...skuTagged, ...main, ...detail]) { + if (selected.length >= Math.max(1, maxImages)) break + if (seen.has(asset.assetId) || !asset.url) continue + seen.add(asset.assetId) + selected.push(asset) + } + return selected +} + +function decimalText(value: unknown) { + return value === null ? null : Number(value) +} + +// 归档图片是站内相对路径或私有桶地址,模型网关需要可抓取的签名绝对 URL; +// 与生产 1688 图搜使用同一转换,失败时退回原始值由调用方报错。 +function toModelImageSrc(rawValue: string) { + try { + return resolveBomCostingImageSearchSourceUrl(rawValue).toString() + } catch { + return rawValue + } +} + +export async function runBomCostingAgentTrialForSku(input: { + caseId: string + skuId: string + maxImages?: number + budget?: Partial + signal?: AbortSignal +}): Promise<{ + caseId: string + skuId: string + externalSkuId: string + skillCode: string | null + selectedImageCount: number + result: BomCostingAgentRunResult +}> { + const [sourceCase, sku] = await Promise.all([ + bomCostingAgentRepository.findCaseForAgentTrial(input.caseId), + bomCostingAgentRepository.findSkuForAgentTrial(input.skuId), + ]) + if (!sourceCase) throw new Error(`案例不存在或已删除:${input.caseId}`) + if (!sku || sku.caseId !== input.caseId) throw new Error(`SKU 不属于该案例:${input.skuId}`) + + const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(input.caseId) + const images = selectAgentTrialImages( + assets.map((asset) => ({ + assetId: asset.id, + kind: asset.kind, + url: toModelImageSrc(asset.storedUrl || asset.originalUrl), + externalSkuIds: asset.externalSkuIds as unknown, + })), + sku.externalSkuId, + input.maxImages ?? DEFAULT_MAX_IMAGES, + ) + + const dimensionParts = [ + sku.lengthMm !== null ? `长 ${decimalText(sku.lengthMm)}mm` : null, + sku.widthMm !== null ? `宽 ${decimalText(sku.widthMm)}mm` : null, + sku.heightMm !== null ? `高 ${decimalText(sku.heightMm)}mm` : null, + sku.weightKg !== null ? `毛重 ${decimalText(sku.weightKg)}kg` : null, + ].filter((part): part is string => part !== null) + const userText = [ + `商品标题:${sourceCase.title || '(无标题)'}`, + `来源渠道:${sourceCase.sourceHost}`, + `目标 SKU:${sku.externalSkuId}${sku.name ? `(${sku.name})` : ''}`, + dimensionParts.length > 0 ? `规格:${dimensionParts.join(',')}` : null, + '', + '任务:基于以上文字与随附图片,预估该 SKU 的同材质复刻 BOM 成本。' + + '先查看图片与材质目录,再用工具检索公司历史价格,最后给出 final。', + ].filter((part): part is string => part !== null).join('\n') + + const userContent: BomCostingMultimodalUserContent = [ + { type: 'text', text: userText }, + ...images.map((image) => ({ + type: 'image_url' as const, + image_url: { url: image.url, detail: 'auto' as const }, + })), + ] + + const skillKnowledge = renderBomCostingAgentSkillKnowledgeByCode(sourceCase.skillCode) + const result = await runBomCostingAgentLoop({ + mission: { + userContent, + toolContext: { caseId: input.caseId, skuId: input.skuId }, + budget: input.budget, + signal: input.signal, + }, + toolRuntimes: createBomCostingAgentTools(createDefaultBomCostingAgentToolDeps()), + callModel: createGatewayBomCostingAgentModelCaller(), + }) + return { + caseId: input.caseId, + skuId: input.skuId, + externalSkuId: sku.externalSkuId, + skillCode: sourceCase.skillCode, + selectedImageCount: images.length, + result, + } +} diff --git a/furniture-3d-system/src/server/services/provider-model-catalog.service.ts b/furniture-3d-system/src/server/services/provider-model-catalog.service.ts index 0546f09a..493fed92 100644 --- a/furniture-3d-system/src/server/services/provider-model-catalog.service.ts +++ b/furniture-3d-system/src/server/services/provider-model-catalog.service.ts @@ -13,6 +13,8 @@ export type ImageBackgroundRemovalApiMode = export interface ProviderModelOption { value: string label: string + /** 模型目录来源:gateway=通用图像网关目录;async-matting=上游异步媒体任务的专用抠图目录。 */ + source?: 'gateway' | 'async-matting' } export interface ProviderModelCatalogResult { @@ -43,6 +45,7 @@ const MODEL_ID_FIELDS = ['id', 'modelId', 'model', 'value', 'name'] as const const MODEL_LABEL_FIELDS = ['displayName', 'label', 'name', 'id', 'modelId', 'model', 'value'] as const const MODEL_COLLECTION_FIELDS = ['data', 'models', 'items', 'results', 'result'] as const const MAX_COLLECTION_DEPTH = 4 +const MATTING_CATALOG_PATH = '/api/v2/catalog/models' function normalizeRequiredText(value: unknown, label: string): string { if (typeof value !== 'string' || !value.trim()) { @@ -176,17 +179,32 @@ function buildEndpointCandidates( serviceType: ProviderModelServiceType, apiMode?: ImageBackgroundRemovalApiMode, ): Array<{ url: string; path: string }> { - if (serviceType === 'image-background-removal' && apiMode === 'async-media-task') { - return [{ - url: joinUrl(baseUrl, '/api/v2/catalog/models'), - path: '/api/v2/catalog/models', + if (serviceType === 'image-background-removal') { + // 非 async-media-task 协议也要携带专用抠图目录候选:fetchCatalog 会合并 + // 两份目录,让默认协议下同样能看到 remove-background 等抠图模型。 + if (apiMode === 'async-media-task') { + return [{ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH }] + } + if (apiMode === 'openai-compatible-image-edit') { + const openAiCandidates: Array<{ url: string; path: string }> = [] + const parsedUrl = new URL(baseUrl) + const normalizedPath = parsedUrl.pathname.replace(/\/+$/, '') + openAiCandidates.push({ url: joinUrl(baseUrl, '/models'), path: '/models' }) + if (!normalizedPath.endsWith('/v1')) { + openAiCandidates.push({ url: joinUrl(baseUrl, '/v1/models'), path: '/v1/models' }) + } + openAiCandidates.push({ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH }) + return openAiCandidates + } + const candidates = [{ + url: joinUrl(baseUrl, '/api/external/image/models'), + path: '/api/external/image/models', }] + candidates.push({ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH }) + return candidates } - if ( - serviceType === 'ai-render' || - (serviceType === 'image-background-removal' && apiMode !== 'openai-compatible-image-edit') - ) { + if (serviceType === 'ai-render') { return [{ url: joinUrl(baseUrl, '/api/external/image/models'), path: '/api/external/image/models', @@ -248,7 +266,7 @@ async function requestCatalog( } const models = response.ok - ? apiMode === 'async-media-task' + ? endpoint.path === MATTING_CATALOG_PATH ? extractAsyncMediaTaskModelOptions(payload) : extractProviderModelOptions(payload, { preferExternalImageModelId: @@ -286,6 +304,57 @@ export class ProviderModelCatalogService { let lastAttempt: CatalogAttempt | null = null let reachedProvider = false + // 商品抠图需要同时看到两份目录:通用图像网关模型(以文改图)和上游 + // 异步媒体任务的专用抠图模型(remove-background 等)。任一协议模式下 + // 都合并两份目录并标记来源,避免“默认协议看不到抠图模型”的死角。 + if (input.serviceType === 'image-background-removal') { + const merged = new Map() + let firstEndpointWithModels: string | null = null + for (const candidate of candidates) { + const attempt = await requestCatalog(candidate, apiKey, input.serviceType, input.apiMode) + lastAttempt = attempt + reachedProvider ||= attempt.status >= 200 && attempt.status < 500 + if (attempt.models.length > 0 && firstEndpointWithModels === null) { + firstEndpointWithModels = attempt.endpointPath + } + const source: ProviderModelOption['source'] = candidate.path === MATTING_CATALOG_PATH + ? 'async-matting' + : 'gateway' + for (const model of attempt.models) { + if (!merged.has(model.value)) merged.set(model.value, { ...model, source }) + } + if ([401, 403].includes(attempt.status)) break + } + const models = Array.from(merged.values()).sort((left, right) => { + if (left.source !== right.source) return left.source === 'async-matting' ? -1 : 1 + return 0 + }) + const status = lastAttempt?.status ?? 0 + if (models.length > 0) { + return { + ok: true, + status: status === 0 ? 200 : status, + latency: Date.now() - startedAt, + models, + endpointPath: firstEndpointWithModels, + error: null, + } + } + const error = [401, 403].includes(status) + ? 'API 密钥无权读取模型列表,请检查密钥权限' + : reachedProvider + ? '服务可以连接,但没有返回可选择的模型。请确认服务地址包含正确的 API 前缀' + : lastAttempt?.error ?? '无法连接模型服务' + return { + ok: false, + status, + latency: Date.now() - startedAt, + models: [], + endpointPath: lastAttempt?.endpointPath ?? null, + error, + } + } + for (const candidate of candidates) { const attempt = await requestCatalog(candidate, apiKey, input.serviceType, input.apiMode) lastAttempt = attempt