From c58f1c1352ac3fb314e11c1d82c91927c1146525 Mon Sep 17 00:00:00 2001 From: 3494036618-eng <252820799+3494036618-eng@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:57:53 +0800 Subject: [PATCH] feat(demohouse/car-decision-assistant): add car decision assistant --- README.md | 1 + demohouse/car-decision-assistant/.env.example | 22 + .../.github/workflows/ci.yml | 29 + demohouse/car-decision-assistant/.gitignore | 54 + demohouse/car-decision-assistant/CHANGELOG.md | 24 + .../car-decision-assistant/CONTRIBUTING.md | 21 + demohouse/car-decision-assistant/LICENSE | 201 + demohouse/car-decision-assistant/PRIVACY.md | 25 + demohouse/car-decision-assistant/README.md | 126 + demohouse/car-decision-assistant/SECURITY.md | 29 + demohouse/car-decision-assistant/SUPPORT.md | 24 + .../car-decision-assistant/UPSTREAM.json | 6 + .../app/api/health/route.ts | 31 + .../app/api/project/evaluate/route.ts | 40 + .../app/api/project/recover/route.ts | 49 + .../app/api/project/route.ts | 132 + .../app/decision-app.tsx | 2405 +++++++ .../car-decision-assistant/app/globals.css | 3277 +++++++++ .../car-decision-assistant/app/layout.tsx | 46 + demohouse/car-decision-assistant/app/page.tsx | 6 + .../build/sites-vite-plugin.ts | 39 + .../docs/data-and-evidence-policy.md | 32 + .../docs/requirements-engineering.md | 88 + .../car-decision-assistant/eslint.config.mjs | 41 + .../lib/decision/demo.ts | 303 + .../lib/decision/engine.ts | 543 ++ .../lib/decision/index.ts | 4 + .../lib/decision/location.ts | 12 + .../lib/decision/types.ts | 346 + .../lib/harness/agent-plan.ts | 850 +++ .../lib/harness/datapro.ts | 917 +++ .../lib/harness/health.ts | 95 + .../lib/harness/index.ts | 64 + .../lib/harness/retry.ts | 76 + .../lib/harness/runtime.ts | 259 + .../lib/harness/types.ts | 69 + .../lib/project-errors.ts | 178 + .../lib/project-form-state.ts | 38 + .../lib/project-service.ts | 5519 +++++++++++++++ .../lib/requirements.ts | 675 ++ .../lib/storage/index.ts | 39 + .../lib/storage/project-store.ts | 36 + .../supabase-decision-project-store.ts | 873 +++ .../lib/storage/tokens.ts | 42 + .../lib/storage/types.ts | 292 + .../lib/supabase/server.ts | 35 + .../lib/vehicle-sales.ts | 727 ++ .../car-decision-assistant/next.config.ts | 7 + .../car-decision-assistant/package-lock.json | 6246 +++++++++++++++++ demohouse/car-decision-assistant/package.json | 78 + .../car-decision-assistant/postcss.config.mjs | 7 + .../public/assets/decision-data-mark.png | Bin 0 -> 26058 bytes .../public/assets/decision-hero-field.png | Bin 0 -> 379282 bytes .../car-decision-assistant/public/og.png | Bin 0 -> 759084 bytes .../scripts/agent-plan-key.mjs | 63 + .../scripts/dev-supabase.mjs | 40 + .../scripts/install-agent-skill.mjs | 65 + .../scripts/supabase-runtime.mjs | 60 + .../scripts/test-skill-installer.mjs | 39 + .../scripts/validate-public-release.mjs | 174 + .../scripts/validate-skill-package.mjs | 48 + .../scripts/verify-live-scenarios.mjs | 273 + .../scripts/verify-supabase-runtime.mjs | 196 + .../skills/car-decision-assistant/SKILL.md | 185 + .../car-decision-assistant/agents/openai.yaml | 4 + .../references/acceptance.md | 35 + .../references/evidence-policy.md | 12 + .../references/setup.md | 38 + .../references/troubleshooting.md | 25 + .../scripts/acceptance.mjs | 25 + .../scripts/configure.mjs | 45 + .../car-decision-assistant/scripts/doctor.mjs | 30 + .../scripts/install.mjs | 6 + .../car-decision-assistant/scripts/lib.mjs | 90 + .../scripts/setup-supabase.mjs | 55 + .../car-decision-assistant/scripts/start.mjs | 58 + .../car-decision-assistant/scripts/status.mjs | 36 + .../car-decision-assistant/scripts/stop.mjs | 14 + .../supabase/001_initial_schema.sql | 612 ++ .../supabase/002_smoke_test.sql | 204 + .../car-decision-assistant/supabase/README.md | 78 + .../tests/decision-engine.test.mjs | 389 + .../tests/dev-runtime.test.mjs | 22 + .../tests/harness-clients.test.ts | 670 ++ .../tests/project-service.test.ts | 2963 ++++++++ .../tests/rendered-html.test.mjs | 78 + .../tests/supabase-storage.test.ts | 146 + .../tests/vehicle-sales.test.ts | 654 ++ .../tests/vite-config.test.ts | 50 + .../car-decision-assistant/tsconfig.json | 35 + .../car-decision-assistant/vite.config.ts | 68 + .../car-decision-assistant/worker/index.ts | 46 + 92 files changed, 32709 insertions(+) create mode 100644 demohouse/car-decision-assistant/.env.example create mode 100644 demohouse/car-decision-assistant/.github/workflows/ci.yml create mode 100644 demohouse/car-decision-assistant/.gitignore create mode 100644 demohouse/car-decision-assistant/CHANGELOG.md create mode 100644 demohouse/car-decision-assistant/CONTRIBUTING.md create mode 100644 demohouse/car-decision-assistant/LICENSE create mode 100644 demohouse/car-decision-assistant/PRIVACY.md create mode 100644 demohouse/car-decision-assistant/README.md create mode 100644 demohouse/car-decision-assistant/SECURITY.md create mode 100644 demohouse/car-decision-assistant/SUPPORT.md create mode 100644 demohouse/car-decision-assistant/UPSTREAM.json create mode 100644 demohouse/car-decision-assistant/app/api/health/route.ts create mode 100644 demohouse/car-decision-assistant/app/api/project/evaluate/route.ts create mode 100644 demohouse/car-decision-assistant/app/api/project/recover/route.ts create mode 100644 demohouse/car-decision-assistant/app/api/project/route.ts create mode 100644 demohouse/car-decision-assistant/app/decision-app.tsx create mode 100644 demohouse/car-decision-assistant/app/globals.css create mode 100644 demohouse/car-decision-assistant/app/layout.tsx create mode 100644 demohouse/car-decision-assistant/app/page.tsx create mode 100644 demohouse/car-decision-assistant/build/sites-vite-plugin.ts create mode 100644 demohouse/car-decision-assistant/docs/data-and-evidence-policy.md create mode 100644 demohouse/car-decision-assistant/docs/requirements-engineering.md create mode 100644 demohouse/car-decision-assistant/eslint.config.mjs create mode 100644 demohouse/car-decision-assistant/lib/decision/demo.ts create mode 100644 demohouse/car-decision-assistant/lib/decision/engine.ts create mode 100644 demohouse/car-decision-assistant/lib/decision/index.ts create mode 100644 demohouse/car-decision-assistant/lib/decision/location.ts create mode 100644 demohouse/car-decision-assistant/lib/decision/types.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/agent-plan.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/datapro.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/health.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/index.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/retry.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/runtime.ts create mode 100644 demohouse/car-decision-assistant/lib/harness/types.ts create mode 100644 demohouse/car-decision-assistant/lib/project-errors.ts create mode 100644 demohouse/car-decision-assistant/lib/project-form-state.ts create mode 100644 demohouse/car-decision-assistant/lib/project-service.ts create mode 100644 demohouse/car-decision-assistant/lib/requirements.ts create mode 100644 demohouse/car-decision-assistant/lib/storage/index.ts create mode 100644 demohouse/car-decision-assistant/lib/storage/project-store.ts create mode 100644 demohouse/car-decision-assistant/lib/storage/supabase-decision-project-store.ts create mode 100644 demohouse/car-decision-assistant/lib/storage/tokens.ts create mode 100644 demohouse/car-decision-assistant/lib/storage/types.ts create mode 100644 demohouse/car-decision-assistant/lib/supabase/server.ts create mode 100644 demohouse/car-decision-assistant/lib/vehicle-sales.ts create mode 100644 demohouse/car-decision-assistant/next.config.ts create mode 100644 demohouse/car-decision-assistant/package-lock.json create mode 100644 demohouse/car-decision-assistant/package.json create mode 100644 demohouse/car-decision-assistant/postcss.config.mjs create mode 100644 demohouse/car-decision-assistant/public/assets/decision-data-mark.png create mode 100644 demohouse/car-decision-assistant/public/assets/decision-hero-field.png create mode 100644 demohouse/car-decision-assistant/public/og.png create mode 100644 demohouse/car-decision-assistant/scripts/agent-plan-key.mjs create mode 100644 demohouse/car-decision-assistant/scripts/dev-supabase.mjs create mode 100644 demohouse/car-decision-assistant/scripts/install-agent-skill.mjs create mode 100644 demohouse/car-decision-assistant/scripts/supabase-runtime.mjs create mode 100644 demohouse/car-decision-assistant/scripts/test-skill-installer.mjs create mode 100644 demohouse/car-decision-assistant/scripts/validate-public-release.mjs create mode 100644 demohouse/car-decision-assistant/scripts/validate-skill-package.mjs create mode 100644 demohouse/car-decision-assistant/scripts/verify-live-scenarios.mjs create mode 100644 demohouse/car-decision-assistant/scripts/verify-supabase-runtime.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/SKILL.md create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/agents/openai.yaml create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/references/acceptance.md create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/references/evidence-policy.md create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/references/setup.md create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/references/troubleshooting.md create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/acceptance.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/configure.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/doctor.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/install.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/lib.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/setup-supabase.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/start.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/status.mjs create mode 100644 demohouse/car-decision-assistant/skills/car-decision-assistant/scripts/stop.mjs create mode 100644 demohouse/car-decision-assistant/supabase/001_initial_schema.sql create mode 100644 demohouse/car-decision-assistant/supabase/002_smoke_test.sql create mode 100644 demohouse/car-decision-assistant/supabase/README.md create mode 100644 demohouse/car-decision-assistant/tests/decision-engine.test.mjs create mode 100644 demohouse/car-decision-assistant/tests/dev-runtime.test.mjs create mode 100644 demohouse/car-decision-assistant/tests/harness-clients.test.ts create mode 100644 demohouse/car-decision-assistant/tests/project-service.test.ts create mode 100644 demohouse/car-decision-assistant/tests/rendered-html.test.mjs create mode 100644 demohouse/car-decision-assistant/tests/supabase-storage.test.ts create mode 100644 demohouse/car-decision-assistant/tests/vehicle-sales.test.ts create mode 100644 demohouse/car-decision-assistant/tests/vite-config.test.ts create mode 100644 demohouse/car-decision-assistant/tsconfig.json create mode 100644 demohouse/car-decision-assistant/vite.config.ts create mode 100644 demohouse/car-decision-assistant/worker/index.ts diff --git a/README.md b/README.md index dd857f5e..7f0fa4f8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ | [实时对话式 AI](./demohouse/rtc_conversational_ai/README.md) | 超低延迟的 AI 实时对话应用,更流畅,更自然,更实时。 | | [AI-Media2Doc](./demohouse/media2doc/README.md) | 一键将视频和音频转化为小红书/公众号/知识笔记/视频总结/思维导图等各种风格的文档, 可基于视频内容进行 AI 二次对话。 | | [Mobile-Use](./demohouse/mobile-use/README_zh.md) | 基于火山引擎云手机与豆包视觉大模型能力,通过自然语言指令完成面向移动端场景自动化任务的 AI Agent 解决方案 | +| [购车决策助手](./demohouse/car-decision-assistant/README.md) | 基于 Agent Plan、专业数据集和 AI Native 应用开发底座,将自然语言购车需求与精确车型配置、城市销量和用户主观确认进行可追溯核验。 | | [个人投资助手](./demohouse/personal-investment-assistant/README.md) | 基于 Agent Plan、DataPro 和豆包搜索生成来源可追溯的个股简评与盘后风险摘要,支持个性化关注偏好、定时监控和 Skill 一键初始化。 | ## 相关指引 diff --git a/demohouse/car-decision-assistant/.env.example b/demohouse/car-decision-assistant/.env.example new file mode 100644 index 00000000..28f13099 --- /dev/null +++ b/demohouse/car-decision-assistant/.env.example @@ -0,0 +1,22 @@ +# Server-only Agent Plan credential. Never expose this value to client code. +AGENT_PLAN_API_KEY= + +# Optional overrides. The defaults target the Agent Plan endpoints documented +# in this cookbook's source material. +AGENT_PLAN_MODEL=ark-code-latest +AGENT_PLAN_BASE_URL=https://ark.cn-beijing.volces.com/api/plan +DATAPRO_MCP_URL=https://datapro.hqd.cn-beijing.volces.com/mcp + +# Project persistence. Keep service-role credentials on the server only. +PROJECT_STORAGE_BACKEND=supabase +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_ANON_KEY= + +# Used only by the local byted-supabase-cli startup helper. +SUPABASE_WORKSPACE_ID= +SUPABASE_CLI_PROFILE= +SUPABASE_REGION=cn-beijing + +# Optional override for the real Provider acceptance script. +CAR_DECISION_BASE_URL=http://localhost:3003 diff --git a/demohouse/car-decision-assistant/.github/workflows/ci.yml b/demohouse/car-decision-assistant/.github/workflows/ci.yml new file mode 100644 index 00000000..bce52e94 --- /dev/null +++ b/demohouse/car-decision-assistant/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.15.0 + cache: npm + + - name: Install locked dependencies + run: npm ci --ignore-scripts + + - name: Run public release checks + run: npm run release:verify diff --git a/demohouse/car-decision-assistant/.gitignore b/demohouse/car-decision-assistant/.gitignore new file mode 100644 index 00000000..b132a907 --- /dev/null +++ b/demohouse/car-decision-assistant/.gitignore @@ -0,0 +1,54 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +next-env.d.ts +*.tsbuildinfo +/dist/ +/.wrangler/ +/outputs/ +/work/ + +# local browser and release-validation artifacts +/.playwright-cli/ +/qa/ + +# local app runtime and private configuration +/.car-decision-assistant/ +/backups/ +/supabase/.temp/ +/.openai/hosting.json diff --git a/demohouse/car-decision-assistant/CHANGELOG.md b/demohouse/car-decision-assistant/CHANGELOG.md new file mode 100644 index 00000000..327576f1 --- /dev/null +++ b/demohouse/car-decision-assistant/CHANGELOG.md @@ -0,0 +1,24 @@ +# 更新记录 + +## 0.1.0 + +- 移除内部 QA、过时验收报告和模板文件,补齐公开仓库元数据与 CI。 +- 将锁定依赖全部切换到公共 npm registry,外部环境可以直接安装。 +- 将最低 Node.js 版本调整为 `22.15.0`,与测试和真实 Supabase 验收脚本保持一致。 +- 固定 GitHub Actions 提交版本,并在 Linux 上执行完整发布门禁。 + +首个公开版本: + +- 支持自然语言购车需求和最多 3 款候选车。 +- 支持一次精确车型确认并冻结专业数据集返回的 code 或精确名称标识。 +- 支持车型配置与城市车系月度数据并行查询、部分成功和来源追踪。 +- 支持 AI Native 应用开发底座的匿名项目、恢复码、版本冲突、本人确认和报价持久化。 +- 提供稳定错误码、真实 AI Native 应用开发底座验收和三组真实 Harness 场景。 +- 提供 `car-decision-assistant` 初始化 Skill。 + +已知限制: + +- 当前不接入豆包搜索。 +- 部分专业数据集响应不提供原生数值车型 code。 +- 口碑、保值率、保险成本、真实落地价和主观体验不能自动核验。 +- 当前是自托管开源应用,不提供公网 SaaS、多人协作或 SLA。 diff --git a/demohouse/car-decision-assistant/CONTRIBUTING.md b/demohouse/car-decision-assistant/CONTRIBUTING.md new file mode 100644 index 00000000..53c8ac81 --- /dev/null +++ b/demohouse/car-decision-assistant/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# 贡献指南 + +## 开发流程 + +1. 从最新主分支创建短分支。 +2. 不提交密钥、运行日志、浏览器状态、私有截图或 Provider 原始响应。 +3. 修改事实解析时同时增加主体、版本、单位和保守降级测试。 +4. 修改 Skill 时同步更新 `SKILL.md`、脚本和隔离安装测试。 +5. 提交前执行 `npm run release:verify`。 + +## 数据与模型规则 + +- 不使用模型记忆生成汽车配置、销量或价格事实。 +- 不把车系级数据借给精确配置。 +- 不把指导价写成落地价。 +- 不伪造专业数据集返回的 code、trace ID 或来源时间。 +- 主观体验和当前数据源不支持的需求必须保持待确认。 + +## Pull Request + +说明用户问题、行为变化、验证命令、真实 Harness 能力是否调用、数据迁移影响和已知风险。数据库变更必须包含可重复 Schema、RLS 检查和回滚说明。 diff --git a/demohouse/car-decision-assistant/LICENSE b/demohouse/car-decision-assistant/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/demohouse/car-decision-assistant/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/demohouse/car-decision-assistant/PRIVACY.md b/demohouse/car-decision-assistant/PRIVACY.md new file mode 100644 index 00000000..f710248b --- /dev/null +++ b/demohouse/car-decision-assistant/PRIVACY.md @@ -0,0 +1,25 @@ +# 隐私说明 + +购车决策助手是自托管应用。数据保存位置和保留期限由部署者控制。 + +## 保存的数据 + +- 用户输入的城市、预算、购车时间和原始用车需求。 +- 用户确认的候选车型、本人体验、报价和书面信息。 +- 专业数据的必要摘要、查询时间、状态和 trace/request ID。 +- 匿名项目的编辑令牌摘要和恢复码摘要。 + +## 不保存的数据 + +- 不要求手机号、邮箱、身份证或合同上传。 +- 不向经销商发送销售线索。 +- V1 不保存专业数据集的完整原始响应。 +- 数据库不保存明文编辑令牌或恢复码。 + +## 保留与删除 + +项目默认在最后一次更新后保留 90 天。用户可以在页面中删除当前项目;关联记录通过数据库外键级联删除。部署者应自行配置过期清理任务,并在公开服务前说明实际执行频率。 + +## 第三方服务 + +Agent Plan、专业数据集和 AI Native 应用开发底座会按各自服务条款处理请求。部署者负责确认账号权限、区域、套餐和合规要求。 diff --git a/demohouse/car-decision-assistant/README.md b/demohouse/car-decision-assistant/README.md new file mode 100644 index 00000000..10dec580 --- /dev/null +++ b/demohouse/car-decision-assistant/README.md @@ -0,0 +1,126 @@ +# 购车决策助手 + +一个面向个人购车者的开源全栈应用。用户用自然语言说明真实用车需求,选择 1–3 款候选车;系统核验精确车型、配置和城市车系月度数据,并把每项要求标为“已确认 / 不符合 / 待确认”。 + +它不是车型推荐榜单,也不会用模型常识补齐汽车事实。主观体验、真实落地价和销售承诺由用户本人记录,专业事实保留来源时间与 trace/request ID。 + +> 本项目用于整理购车条件和核验依据,不构成购买、价格、保险、金融或安全建议。 + +## 最快使用方式 + +可把下面一句话交给支持联网和终端操作的 Codex 或 Claude Code: + +```text +帮我初始化购车决策助手:https://github.com/3494036618-eng/car-decision-assistant/blob/v0.1.0/skills/car-decision-assistant/SKILL.md +``` + +Skill 会下载同一版本的完整仓库,检查本机环境,引导用户选择自己的 AI Native 应用开发底座 Workspace,私密配置 Agent Plan Key,安装依赖、启动网站并执行真实验收。Skill 不会把密钥写入仓库或聊天,也不会未经确认创建、暂停或删除云资源。 + +本地已有仓库时,也可以安装 Skill: + +```bash +npm ci +npm run skill:install:codex +# 或 npm run skill:install:claude +``` + +## 前置条件 + +- Node.js `>=22.15.0` +- 已购买并可使用 Agent Plan +- 已在“配置 Harness”中开启并授权“专业数据集” +- 一个属于当前用户的“AI Native 应用开发底座”Workspace +- `byted-supabase-cli` 已安装并完成火山账号登录 + +项目不会内置公共演示账号、共享 Key 或作者的 Workspace ID。 + +## 产品边界 + +- 支持 1–3 款候选车;每款车只进行一次精确车型确认。 +- Agent Plan 模型只负责结构化用户需求,不生成车型事实。 +- 专业数据集负责精确车型配置和城市车系月度数据。 +- 专业数据集返回原生车型 code 时冻结该 code;未返回 code 时冻结“来源 + 精确版本名”的名称标识,并明确标注,不伪造上游 ID。 +- 指导价只能作为参考,不能替代包含保险、税费和服务费的真实落地报价。 +- 晕车、舒适度、空间感受、异味、补能便利性等由用户本人确认。 +- 当前版本不接入豆包搜索,不自动核验口碑、保值率、保险成本或公开网页信息。 +- 单个专业数据步骤失败时保留其他已返回结果,并显示缺失原因;不借用其他版本数据。 + +完整事实边界见 [数据与证据规则](./docs/data-and-evidence-policy.md),自然语言需求的拆分、追踪和扩展方式见 [需求工程说明](./docs/requirements-engineering.md)。 + +## 技术结构 + +- Vinext + React 19 + TypeScript +- 火山引擎 AI Native 应用开发底座(基于 Supabase):PostgreSQL、RLS、匿名项目和恢复码 +- Agent Plan:自然语言需求结构化 +- 专业数据集:车型配置和城市车系月度数据 + +主要目录: + +- `app/`:单页前端与 API Routes +- `lib/decision/`:确定性决策规则和三态聚合 +- `lib/harness/`:Agent Plan、专业数据集客户端、超时和重试 +- `lib/storage/`、`lib/supabase/`:项目存储与 AI Native 应用开发底座客户端 +- `supabase/`:Schema、RLS 和原子保存函数 +- `skills/car-decision-assistant/`:安装、配置、启动和验收 Skill +- `tests/`:页面、规则、车型、销量、存储和 Harness 测试 + +## 手动配置与运行 + +先在用户明确选择的 Workspace 中执行 [supabase/001_initial_schema.sql](./supabase/001_initial_schema.sql)。具体命令和安全边界见 [supabase/README.md](./supabase/README.md)。 + +随后显式设置当前用户自己的 Workspace 信息: + +```bash +export SUPABASE_WORKSPACE_ID="" +export SUPABASE_CLI_PROFILE="agent-plan" +export SUPABASE_REGION="cn-beijing" +npm run dev:supabase -- --host 127.0.0.1 --port 3003 +``` + +`dev:supabase` 会从 `byted-supabase-cli` 登录态读取服务端地址和 Key,并在交互式终端隐藏读取 Agent Plan Key。凭证只注入服务端子进程,不写入项目文件。 + +不接入真实云服务、只调试页面和构建时运行: + +```bash +npm run dev -- --host 127.0.0.1 --port 3003 +``` + +## 验证 + +```bash +npm run verify +npm run release:verify +``` + +`verify` 执行 ESLint、TypeScript、生产构建和单元测试。`release:verify` 额外检查公开文件、Skill 包与隔离安装流程,不调用真实 Harness 能力。 + +以下命令会访问真实云资源,执行前必须确认当前 Workspace 和额度: + +```bash +npm run test:supabase:live +npm run test:scenarios:live +``` + +- `test:supabase:live`:创建、读取、并发冲突、恢复轮换、匿名隔离和删除。 +- `test:scenarios:live`:杭州、上海、成都三组真实场景,调用 Agent Plan 与专业数据集,并清理验收项目。 + +HTTP 200、进程存活或 `npm run verify` 通过都不等于真实业务可用。正式交付必须同时通过依赖安装、真实 Harness 能力、AI Native 应用开发底座、三场景和浏览器验收。 + +## 隐私与安全 + +- 浏览器通过 HttpOnly Cookie 关联匿名项目。 +- 数据库只保存编辑令牌和恢复码的 SHA-256 摘要。 +- 恢复项目后轮换编辑令牌,旧 Cookie 失效。 +- 项目默认在最后一次更新后保存 90 天,用户可以删除项目及关联记录。 +- `SUPABASE_SERVICE_ROLE_KEY` 和 `AGENT_PLAN_API_KEY` 只能存在于服务端私密环境。 +- V1 不保存完整专业数据原始响应,只保存必要摘要和来源追踪字段。 + +开源使用前请阅读 [SECURITY.md](./SECURITY.md)、[PRIVACY.md](./PRIVACY.md) 和 [SUPPORT.md](./SUPPORT.md)。 + +## 项目状态 + +当前版本是自托管开源应用,适合本机或受控环境使用,不承诺公网 SaaS、持续可用性、完整车型覆盖或数据源 SLA。已知限制记录在 [CHANGELOG.md](./CHANGELOG.md)。 + +## 许可证 + +本项目采用 [Apache License 2.0](./LICENSE)。第三方服务、数据和商标分别受其提供方条款约束;本仓库不包含第三方汽车数据库。 diff --git a/demohouse/car-decision-assistant/SECURITY.md b/demohouse/car-decision-assistant/SECURITY.md new file mode 100644 index 00000000..67452522 --- /dev/null +++ b/demohouse/car-decision-assistant/SECURITY.md @@ -0,0 +1,29 @@ +# 安全策略 + +## 支持范围 + +当前仅维护最新发布版本。安全修复会记录在 `CHANGELOG.md`。 + +## 报告问题 + +请不要在公开 Issue 中提交 API Key、Supabase Key、恢复码、Cookie、用户购车需求或 Provider 原始响应。先创建不含敏感信息的安全问题说明,维护者会提供私密沟通方式。 + +报告应包含受影响版本、复现步骤、影响范围和已经完成的安全处置。不要在没有授权的情况下访问他人的 Workspace、项目或数据。 + +## 密钥边界 + +- `AGENT_PLAN_API_KEY`、`SUPABASE_SERVICE_ROLE_KEY` 只允许进入服务端环境。 +- 禁止写入 `NEXT_PUBLIC_*`、`VITE_*`、源码、日志、截图、Issue 或提交历史。 +- Skill 只能通过隐藏终端输入、进程环境或权限为 `0600` 的本机文件处理密钥。 +- 仓库只提交空值 `.env.example`。 + +## 数据库边界 + +- 所有业务表启用 RLS。 +- 浏览器不能直接持有 `service_role`。 +- 匿名项目依赖 HttpOnly Cookie、哈希令牌和恢复码轮换。 +- Schema 只能应用到用户明确确认的 Workspace;不能自动创建、暂停或删除云资源。 + +## 已知限制 + +本项目是自托管开源应用,不提供公开账号体系、多租户隔离审计、SLA 或托管安全响应。部署到公网前,维护者必须自行补充反向代理、HTTPS、访问控制、速率限制、日志保留和数据合规方案。 diff --git a/demohouse/car-decision-assistant/SUPPORT.md b/demohouse/car-decision-assistant/SUPPORT.md new file mode 100644 index 00000000..806d586e --- /dev/null +++ b/demohouse/car-decision-assistant/SUPPORT.md @@ -0,0 +1,24 @@ +# 支持说明 + +## 提交问题前 + +请先执行: + +```bash +npm run verify +node skills/car-decision-assistant/scripts/status.mjs +``` + +涉及真实 Harness 能力时,在确认会产生真实调用后执行: + +```bash +node skills/car-decision-assistant/scripts/doctor.mjs --live +``` + +## Issue 内容 + +请提供版本、操作系统、Node.js 版本、触发步骤、稳定错误码和已脱敏日志。不要提交 Key、Cookie、恢复码、`SUPABASE_SERVICE_ROLE_KEY`、用户原始需求或完整 Harness 响应。 + +## 支持边界 + +维护者可以处理可复现的安装、构建、车型绑定、条件解析、存储和界面问题;不保证第三方数据覆盖、实时性、价格准确性、车型推荐结论或云服务 SLA。 diff --git a/demohouse/car-decision-assistant/UPSTREAM.json b/demohouse/car-decision-assistant/UPSTREAM.json new file mode 100644 index 00000000..43791809 --- /dev/null +++ b/demohouse/car-decision-assistant/UPSTREAM.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/3494036618-eng/car-decision-assistant", + "commit": "8d0f557d3758da3c3e8a5830e86e4e868301f3ea", + "version": "0.1.0", + "synced_at": "2026-08-21" +} diff --git a/demohouse/car-decision-assistant/app/api/health/route.ts b/demohouse/car-decision-assistant/app/api/health/route.ts new file mode 100644 index 00000000..ee63103d --- /dev/null +++ b/demohouse/car-decision-assistant/app/api/health/route.ts @@ -0,0 +1,31 @@ +import { createHarnessClients } from "@/lib/harness"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const live = request.nextUrl.searchParams.get("live") === "1"; + const clients = createHarnessClients(); + const [agentPlan, dataPro] = await Promise.all([ + clients.agentPlan.health(live), + clients.dataPro.health(live), + ]); + const status = + agentPlan.status === "ok" && dataPro.status === "ok" + ? "ok" + : agentPlan.status === "unavailable" && + dataPro.status === "unavailable" + ? "unavailable" + : "degraded"; + const health = { + status, + live, + checked_at: new Date().toISOString(), + services: { + agent_plan: agentPlan, + datapro: dataPro, + }, + }; + return NextResponse.json(health, { + status: health.status === "unavailable" ? 503 : 200, + headers: { "cache-control": "no-store" }, + }); +} diff --git a/demohouse/car-decision-assistant/app/api/project/evaluate/route.ts b/demohouse/car-decision-assistant/app/api/project/evaluate/route.ts new file mode 100644 index 00000000..a16a2771 --- /dev/null +++ b/demohouse/car-decision-assistant/app/api/project/evaluate/route.ts @@ -0,0 +1,40 @@ +import { recordProjectAnswer } from "@/lib/project-service"; +import { toProjectApiError } from "@/lib/project-errors"; +import { DECISION_PROJECT_COOKIE_NAME } from "@/lib/storage"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +export async function PATCH(request: NextRequest) { + const token = (await cookies()).get(DECISION_PROJECT_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "当前浏览器没有编辑权限" }, { status: 401 }); + } + try { + const body = (await request.json()) as Record; + const required = ["projectId", "candidateId", "conditionId", "answer"] as const; + if (required.some((key) => typeof body[key] !== "string" || !body[key])) { + throw new Error("记录内容不完整"); + } + const quoteTotalWan = + typeof body.quoteTotalWan === "number" && + Number.isFinite(body.quoteTotalWan) && + body.quoteTotalWan > 0 + ? body.quoteTotalWan + : undefined; + const project = await recordProjectAnswer(body.projectId as string, token, { + candidateId: body.candidateId as string, + conditionId: body.conditionId as string, + answer: body.answer as string, + note: typeof body.note === "string" ? body.note : undefined, + quoteTotalWan: + body.answer === "我已有完整报价" ? quoteTotalWan : undefined, + }); + return NextResponse.json({ project }); + } catch (error) { + const detail = toProjectApiError(error); + return NextResponse.json( + { error: detail.message, ...detail }, + { status: detail.retryable ? 409 : 400 }, + ); + } +} diff --git a/demohouse/car-decision-assistant/app/api/project/recover/route.ts b/demohouse/car-decision-assistant/app/api/project/recover/route.ts new file mode 100644 index 00000000..7e2d06e4 --- /dev/null +++ b/demohouse/car-decision-assistant/app/api/project/recover/route.ts @@ -0,0 +1,49 @@ +import { readProjectView } from "@/lib/project-service"; +import { + ProjectErrorCode, + toProjectApiError, +} from "@/lib/project-errors"; +import { + DECISION_PROJECT_COOKIE_NAME, + DECISION_PROJECT_TTL_DAYS, + recoverDecisionProject, +} from "@/lib/storage"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Record; + if ( + typeof body.projectId !== "string" || + typeof body.recoveryCode !== "string" + ) { + throw new Error("项目编号或恢复码无效"); + } + const recovered = await recoverDecisionProject( + body.projectId.trim(), + body.recoveryCode.trim(), + ); + const project = await readProjectView( + recovered.projectId, + recovered.editToken, + ); + const response = NextResponse.json({ project }); + response.cookies.set(DECISION_PROJECT_COOKIE_NAME, recovered.editToken, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: DECISION_PROJECT_TTL_DAYS * 24 * 60 * 60, + path: "/", + }); + return response; + } catch (error) { + const detail = toProjectApiError( + error, + ProjectErrorCode.RECOVERY_CODE_INVALID, + ); + return NextResponse.json( + { error: detail.message, ...detail }, + { status: 400 }, + ); + } +} diff --git a/demohouse/car-decision-assistant/app/api/project/route.ts b/demohouse/car-decision-assistant/app/api/project/route.ts new file mode 100644 index 00000000..aec857a9 --- /dev/null +++ b/demohouse/car-decision-assistant/app/api/project/route.ts @@ -0,0 +1,132 @@ +import { + createProjectWithHarness, + readProjectView, + validateNewProjectRequest, +} from "@/lib/project-service"; +import { + ProjectErrorCode, + ProjectServiceError, + projectApiError, + toProjectApiError, +} from "@/lib/project-errors"; +import { + DECISION_PROJECT_COOKIE_NAME, + DECISION_PROJECT_TTL_DAYS, + deleteDecisionProject, +} from "@/lib/storage"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +function setEditCookie(response: NextResponse, editToken: string) { + response.cookies.set(DECISION_PROJECT_COOKIE_NAME, editToken, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: DECISION_PROJECT_TTL_DAYS * 24 * 60 * 60, + path: "/", + }); +} + +export async function GET(request: NextRequest) { + const projectId = request.nextUrl.searchParams.get("projectId")?.trim(); + const token = (await cookies()).get(DECISION_PROJECT_COOKIE_NAME)?.value; + if (!projectId || !token) { + return NextResponse.json({ error: "当前浏览器没有可恢复的项目" }, { status: 404 }); + } + try { + const project = await readProjectView(projectId, token); + return NextResponse.json({ project }); + } catch (error) { + const detail = toProjectApiError( + error, + ProjectErrorCode.PROJECT_NOT_FOUND, + ); + return NextResponse.json( + { error: detail.message, ...detail }, + { status: 404 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const input = validateNewProjectRequest(await request.json()); + const previousEditToken = (await cookies()).get( + DECISION_PROJECT_COOKIE_NAME, + )?.value; + const result = await createProjectWithHarness(input); + if ( + input.replaceProjectId && + !result.requiresIdentityConfirmation && + result.editToken + ) { + if (!previousEditToken) { + await deleteDecisionProject( + result.project.id, + result.editToken, + ).catch(() => {}); + throw new ProjectServiceError( + projectApiError(ProjectErrorCode.PROJECT_SAVE_FAILED, { + message: "当前浏览器没有原项目的编辑权限", + action: "请先恢复原项目,再修改需求", + }), + ); + } + try { + await deleteDecisionProject( + input.replaceProjectId, + previousEditToken, + ); + } catch (error) { + await deleteDecisionProject( + result.project.id, + result.editToken, + ).catch(() => {}); + throw new ProjectServiceError( + projectApiError(ProjectErrorCode.PROJECT_SAVE_FAILED, { + message: "新结果已回滚,原项目保持不变", + action: "请刷新页面后重试", + }), + { cause: error }, + ); + } + } + const response = NextResponse.json({ + project: result.project, + recoveryCode: result.recoveryCode, + requiresIdentityConfirmation: result.requiresIdentityConfirmation, + code: result.code, + harness: result.harness, + }); + if (result.editToken) { + setEditCookie(response, result.editToken); + } + return response; + } catch (error) { + const detail = toProjectApiError(error); + return NextResponse.json( + { error: detail.message, ...detail }, + { status: detail.retryable ? 503 : 400 }, + ); + } +} + +export async function DELETE(request: NextRequest) { + const projectId = request.nextUrl.searchParams.get("projectId")?.trim(); + const token = (await cookies()).get(DECISION_PROJECT_COOKIE_NAME)?.value; + if (!projectId || !token) { + return NextResponse.json({ error: "没有可删除的项目" }, { status: 404 }); + } + try { + await deleteDecisionProject(projectId, token); + const response = NextResponse.json({ deleted: true }); + response.cookies.delete(DECISION_PROJECT_COOKIE_NAME); + return response; + } catch (error) { + const detail = toProjectApiError(error); + return NextResponse.json( + { error: detail.message, ...detail }, + { status: detail.code === ProjectErrorCode.PROJECT_NOT_FOUND ? 404 : 400 }, + ); + } +} diff --git a/demohouse/car-decision-assistant/app/decision-app.tsx b/demohouse/car-decision-assistant/app/decision-app.tsx new file mode 100644 index 00000000..fe699732 --- /dev/null +++ b/demohouse/car-decision-assistant/app/decision-app.tsx @@ -0,0 +1,2405 @@ +"use client"; + +import { + ConditionCategory, + DecisionStatus, + PendingReason, + isSameCityScope, + type CitySalesSeries, + type ConditionEvaluation, + type DecisionCondition, + type DecisionEvidence, + type DecisionProject, + type PendingIssue, + type VehicleCandidate, + type VehicleFact, +} from "@/lib/decision"; +import { + ArrowClockwise, + ArrowRight, + CaretRight, + Car, + ChartLineUp, + CheckCircle, + Database, + Lightning, + MapPin, + MinusCircle, + PencilSimple, + Plus, + RoadHorizon, + ShieldCheck, + SteeringWheel, + SuitcaseRolling, + UserCircle, + Wallet, + XCircle, +} from "@phosphor-icons/react"; +import { + FormEvent, + startTransition, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import Image from "next/image"; +import { isProjectFormUnchanged } from "../lib/project-form-state"; + +type Overlay = + | "create" + | "recover" + | "evidence" + | "conditions" + | "task" + | null; +type Notice = { tone: "success" | "warning" | "neutral"; message: string }; +type CreateMode = "new" | "add"; + +interface ProjectResponse { + project: DecisionProject; + recoveryCode?: string; + requiresIdentityConfirmation?: boolean; + code?: string; + harness?: { + status: "ok" | "partial" | "unavailable"; + message: string; + }; +} + +interface CreateProjectDraft { + city: string; + purchaseTime: string; + maxBudget: string; + need: string; + candidates: string[]; + candidateIdentityIds: string[]; + identityProject?: DecisionProject; +} + +function readCreateProjectDraft(key: string): CreateProjectDraft | null { + if (typeof window === "undefined") return null; + try { + const draft = JSON.parse( + window.localStorage.getItem(key) ?? "null", + ) as Partial | null; + if ( + !draft || + typeof draft.city !== "string" || + typeof draft.purchaseTime !== "string" || + typeof draft.maxBudget !== "string" || + typeof draft.need !== "string" || + !Array.isArray(draft.candidates) || + draft.candidates.length < 1 || + draft.candidates.length > 3 || + !draft.candidates.every((candidate) => typeof candidate === "string") + ) { + return null; + } + return { + city: draft.city, + purchaseTime: draft.purchaseTime, + maxBudget: draft.maxBudget, + need: draft.need, + candidates: draft.candidates, + candidateIdentityIds: Array.isArray(draft.candidateIdentityIds) + ? draft.candidateIdentityIds + .filter((identityId) => typeof identityId === "string") + .slice(0, draft.candidates.length) + : [], + identityProject: + draft.identityProject && typeof draft.identityProject === "object" + ? draft.identityProject + : undefined, + }; + } catch { + window.localStorage.removeItem(key); + return null; + } +} + +const issueMeta: Record< + PendingIssue["pendingReason"], + { owner: string; helper: string; options: string[] } +> = { + [PendingReason.MISSING_VEHICLE_DATA]: { + owner: "本次查询结果", + helper: "首次生成已经完成全部查询;没有可靠返回的字段不会由模型猜测补齐。", + options: [], + }, + [PendingReason.CONFIGURATION_UNVERIFIED]: { + owner: "本次查询结果", + helper: + "车型身份已经锁定,但该字段未按已选车型的数据标识返回,因此不会采用其他版本的数据。", + options: [], + }, + [PendingReason.PERSONAL_EXPERIENCE_REQUIRED]: { + owner: "需要本人确认", + helper: "请按自己的真实场景记录,不由模型替你判断体验。", + options: ["符合我的需要", "不符合我的需要", "仍不确定"], + }, + [PendingReason.SALES_WRITTEN_CONFIRMATION_REQUIRED]: { + owner: "需要销售书面确认", + helper: "只记录是否写进正式报价或订单,不要求上传合同。", + options: ["已写入正式材料", "仍是口头说法", "还没有确认"], + }, + [PendingReason.QUOTE_REQUIRED]: { + owner: "需要录入报价", + helper: "手动填写关键费用即可,不需要上传报价单。", + options: ["我已有完整报价", "报价还缺费用项", "还没有报价"], + }, + [PendingReason.CONFIRMATION_INVALIDATED]: { + owner: "需要重新确认", + helper: "车型、城市、支付方式或报价版本发生变化,旧结论已失效。", + options: ["重新确认", "仍不确定", "更换条件"], + }, +}; + +function formatCandidateName(project: DecisionProject, candidateId: string) { + const candidate = project.candidates.find((item) => item.id === candidateId); + if (!candidate) return "未找到车型"; + const { manufacturer, series, modelYear, trim } = candidate.vehicle; + return [manufacturer, series, modelYear, trim].filter(Boolean).join(" "); +} + +function formatCandidateTitle(candidate: VehicleCandidate) { + const { manufacturer, series, modelYear, trim } = candidate.vehicle; + return [manufacturer, series, modelYear, trim].filter(Boolean).join(" "); +} + +function candidateNameIssue(value: string) { + return value.trim().replace(/\s+/g, "").length < 2 + ? "请至少填写品牌或车系名称" + : null; +} + +function vehicleIdentityStatus(candidate: VehicleCandidate) { + return /^(?:datapro|datapro-name):/.test( + candidate.vehicle.exactModelId, + ) + ? { tone: "verified", label: "车型身份已锁定" } + : { tone: "pending", label: "车型待唯一核验" }; +} + +function formatUpdatedAt(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "刚刚"; + return new Intl.DateTimeFormat("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Asia/Shanghai", + }).format(date); +} + +function formatSourceTime(value?: string) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Asia/Shanghai", + }).format(date); +} + +function cleanSourceDisplayText(value: string) { + return value.replace(/([\p{L}\p{N}])\s*[●✓√★]+/gu, "$1"); +} + +function formatFactValue(fact: VehicleFact) { + const displayValue = cleanSourceDisplayText(fact.value); + if (fact.field === "cltc_pure_range_km") { + const rangeValues = Array.from( + displayValue.matchAll(/(\d{2,4}(?:\.\d+)?)\s*(?:km|公里)/gi), + (match) => Number(match[1]), + ).filter((value) => Number.isFinite(value)); + if (rangeValues.length > 1) { + const minimum = Math.min(...rangeValues); + const maximum = Math.max(...rangeValues); + if (minimum !== maximum) return `${minimum}–${maximum} km`; + } + } + if ( + fact.unit && + /^(?:cny|rmb|元)$/i.test(fact.unit) && + /(?:元|万)/.test(displayValue) + ) { + return displayValue; + } + if (!fact.unit || displayValue.includes(fact.unit)) return displayValue; + return `${displayValue}${fact.unit}`; +} + +function formatFactList(facts: VehicleFact[]) { + return facts + .slice(0, 3) + .map((fact) => `${fact.label} ${formatFactValue(fact)}`) + .join(" · "); +} + +function normalizedFactText(fact: VehicleFact) { + return `${fact.field} ${fact.label}`.toLowerCase(); +} + +function factsForEvaluation( + candidate: VehicleCandidate, + condition: DecisionCondition, + evaluation?: ConditionEvaluation, +) { + const facts = candidate.facts ?? []; + const explicitFields = new Set(evaluation?.factFields ?? []); + const explicitFacts = facts.filter((fact) => explicitFields.has(fact.field)); + if (explicitFacts.length) return explicitFacts; + + const ruleField = condition.rule?.field.toLowerCase(); + if (ruleField) { + const exact = facts.find((fact) => fact.field.toLowerCase() === ruleField); + if (exact) return [exact]; + } + + const title = condition.title.toLowerCase(); + const aliases: string[][] = []; + if (/座|seat/.test(title)) aliases.push(["座位", "seat"]); + if (/续航|cltc|wltc|range/.test(title)) { + aliases.push(["续航", "range", "cltc", "wltc"]); + } + if (/四驱|两驱|后驱|前驱|驱动/.test(title)) { + aliases.push(["驱动", "drive"]); + } + if (/预算|价格|落地|报价/.test(title)) { + aliases.push(["指导价", "价格", "price", "guide_price", "报价"]); + } + if (/充电/.test(title)) aliases.push(["充电", "charge"]); + + for (const group of aliases) { + const matched = facts.find((fact) => + group.some((alias) => normalizedFactText(fact).includes(alias)), + ); + if (matched) return [matched]; + } + return []; +} + +function findEvidence( + project: DecisionProject, + candidate: VehicleCandidate, + evaluation: ConditionEvaluation, + fact?: VehicleFact, +) { + const refs = new Set(evaluation.evidenceRefs ?? []); + if (fact?.evidenceId) refs.add(fact.evidenceId); + const evidence = project.evidence ?? []; + const exact = evidence.find((item) => refs.has(item.id)); + if (exact || !fact) return exact; + const expectedSource = fact.source === "user_quote" ? "user" : "datapro"; + return evidence + .filter( + (item) => + item.candidateId === candidate.id && + item.sourceType === expectedSource, + ) + .sort( + (left, right) => + new Date(right.capturedAt).getTime() - + new Date(left.capturedAt).getTime(), + )[0]; +} + +function outcomeSource( + project: DecisionProject, + candidate: VehicleCandidate, + evaluation: ConditionEvaluation, + fact?: VehicleFact, +) { + const evidence = findEvidence(project, candidate, evaluation, fact); + if (evidence) { + const time = formatSourceTime(evidence.capturedAt); + return `${evidence.sourceName}${time ? ` · ${time}` : ""}`; + } + if (fact) { + const source = fact.source === "datapro" ? "专业数据集" : "用户报价"; + const time = formatSourceTime(fact.capturedAt); + return `${source}${time ? ` · ${time}` : ""}`; + } + if (evaluation.userConfirmation) return "用户本人确认"; + if (evaluation.status === DecisionStatus.PENDING) return "仍需补充信息"; + return "已核验信息"; +} + +function evidenceStatusCopy(evidence: DecisionEvidence) { + if (evidence.status === "current") return "已采用"; + if (evidence.status === "needs_review") return "待核验,未采用"; + return "服务暂不可用"; +} + +function MarketSalesChart({ + series, + candidateOrder, +}: { + series: CitySalesSeries[]; + candidateOrder: string[]; +}) { + const [activeMonth, setActiveMonth] = useState<{ + key: string; + label: string; + x: number; + } | null>(null); + const monthKeys = [ + ...new Set( + series.flatMap((item) => + item.points.map((point) => point.monthKey ?? point.month), + ), + ), + ] + .sort() + .slice(-6); + const monthLabel = (key: string) => + series + .flatMap((item) => item.points) + .find((point) => (point.monthKey ?? point.month) === key)?.month ?? key; + const maximum = Math.max( + ...series.flatMap((item) => item.points.map((point) => point.value)), + 1, + ); + const chartWidth = 420; + const chartHeight = 230; + const chartLeft = 46; + const chartRight = 18; + const chartTop = 20; + const chartBottom = 34; + const plotWidth = chartWidth - chartLeft - chartRight; + const plotHeight = chartHeight - chartTop - chartBottom; + const xPosition = (index: number) => + chartLeft + (index * plotWidth) / Math.max(monthKeys.length - 1, 1); + const yPosition = (value: number) => + chartTop + plotHeight - (value / maximum) * plotHeight; + const chartDescription = series + .map( + (item) => + `${item.series}(${item.statisticLabel}):${item.points + .map((point) => `${point.month}${point.value}`) + .join(",")}`, + ) + .join(";"); + + return ( +
+ + {[maximum, maximum / 2, 0].map((value, index) => { + const y = chartTop + (index * plotHeight) / 2; + return ( + + + + ); + })} + {monthKeys.map((monthKey, index) => ( + + {monthLabel(monthKey)} + + ))} + {activeMonth ? ( + + ) : null} + {series.map((item, seriesIndex) => { + const toneIndex = Math.max( + candidateOrder.indexOf(item.candidateId), + seriesIndex, + ); + const points = monthKeys.map((key, monthIndex) => { + const point = item.points.find( + (candidatePoint) => + (candidatePoint.monthKey ?? candidatePoint.month) === key, + ); + return point + ? { + key, + label: point.month, + value: point.value, + x: xPosition(monthIndex), + y: yPosition(point.value), + } + : null; + }); + const lineSegments = points.reduce>>>( + (segments, point) => { + if (!point) { + if (segments.at(-1)?.length) segments.push([]); + return segments; + } + if (!segments.length) segments.push([]); + segments.at(-1)!.push(point); + return segments; + }, + [], + ); + + return ( + + {lineSegments + .filter((segment) => segment.length > 1) + .map((segment, segmentIndex) => ( + `${point.x},${point.y}`) + .join(" ")} + /> + ))} + {points.filter(Boolean).map((point) => ( + + setActiveMonth({ + key: point!.key, + label: point!.label, + x: point!.x, + }) + } + onMouseLeave={() => setActiveMonth(null)} + onFocus={() => + setActiveMonth({ + key: point!.key, + label: point!.label, + x: point!.x, + }) + } + onBlur={() => setActiveMonth(null)} + > + + + + ))} + + ); + })} + + {activeMonth ? ( +
chartWidth * 0.88 ? "end" : "" + }`} + role="status" + style={{ + left: `${(activeMonth.x / chartWidth) * 100}%`, + top: `${((chartTop - 4) / chartHeight) * 100}%`, + }} + > + {activeMonth.label}车系数据 +
+ {series.map((item, seriesIndex) => { + const toneIndex = Math.max( + candidateOrder.indexOf(item.candidateId), + seriesIndex, + ); + const value = item.points.find( + (point) => + (point.monthKey ?? point.month) === activeMonth.key, + )?.value; + return ( +
+