Prechádzať zdrojové kódy

feat: 运行时 config.js + 同源代理文档,并关掉生产包 console

支持政务网/内网通过 public/config.js 热改 DMS 栏目与字段双 c_ 前缀;环境变量收拢为同源前缀;政策库只读远端路径;删除 redirect.html;源码内注释会进包的 console,避免生产控制台刷屏。

Co-Authored-By: Cursor <cursoragent@cursor.com>
gongtianxiao 1 mesiac pred
rodič
commit
952a018901
79 zmenil súbory, kde vykonal 1917 pridanie a 3050 odobranie
  1. 11 49
      .env.development
  2. 13 55
      .env.production
  3. 32 0
      README.md
  4. 9 7
      harness/docs/architecture.md
  5. 8 6
      harness/docs/exec-plans/tech-debt-tracker.md
  6. 1 1
      harness/docs/reference/asr-nls.md
  7. 14 14
      harness/docs/reference/current-api-call-sites.md
  8. 58 39
      harness/docs/reference/legacy-content-markers.md
  9. 145 0
      harness/docs/reference/nginx-proxy.md
  10. 53 0
      harness/progress.md
  11. 1 1
      harness/sops/verification.md
  12. 1 0
      harness/tools/README.md
  13. 9 0
      harness/tools/_entry-dms.ts
  14. 80 41
      harness/tools/upload-policy-library.mjs
  15. 106 0
      harness/tools/verify-clipboard-fallback.mjs
  16. 1 1
      harness/tools/verify-dms-chat-storage.mjs
  17. 101 0
      harness/tools/verify-dms-field-prefix.mjs
  18. 1 1
      harness/tools/verify-dms-payload-fields.mjs
  19. 1 1
      harness/tools/verify-dms-service-token.mjs
  20. 3 4
      harness/tools/verify-inline-scripts.mjs
  21. 12 0
      harness/tools/verify-nls-realtime.mjs
  22. 22 15
      harness/tools/verify-policy-library.mjs
  23. 38 9
      harness/tools/verify-stream-filter-rules.mjs
  24. 16 99
      index.html
  25. 3 3
      package.json
  26. 71 0
      public/config.js
  27. 0 79
      redirect.html
  28. 12 26
      src/asr/nls-protocol.ts
  29. 42 57
      src/asr/nls.ts
  30. 12 46
      src/components/BusinessAssistantMobile.vue
  31. 17 10
      src/components/BusinessAssistantMobileInputShell.vue
  32. 15 42
      src/components/BusinessAssistantPC.vue
  33. 8 8
      src/components/Chat/AccountInfo.vue
  34. 41 161
      src/components/Chat/BusinessRecord.vue
  35. 0 229
      src/components/Chat/FormContent.vue
  36. 0 175
      src/components/Chat/LinkURLContent.vue
  37. 0 222
      src/components/Chat/OfficeURLContent.vue
  38. 41 133
      src/components/Chat/PolicyMatch.vue
  39. 15 56
      src/components/Chat/QuestionCard.vue
  40. 1 2
      src/components/Chat/ScopeContent.vue
  41. 10 20
      src/components/Chat/TextContent.vue
  42. 7 16
      src/components/Chat/markdown-plain-text.ts
  43. 7 16
      src/components/Chat/policy-library-utils.ts
  44. 11 31
      src/components/Chat/policy-match-utils.ts
  45. 1 3
      src/components/Chat/text-content-utils.ts
  46. 33 26
      src/components/Common/FeedbackWidget.vue
  47. 1 1
      src/components/Common/MediaViewer.vue
  48. 1 1
      src/components/Common/References.vue
  49. 1 1
      src/components/Common/RelatedQuestionsWidget.vue
  50. 1 1
      src/components/Common/ScrollList.vue
  51. 53 122
      src/components/answer-stream.ts
  52. 133 262
      src/components/api-chat-coordinator.ts
  53. 18 35
      src/components/business-assistant/chat-generation-task.ts
  54. 17 66
      src/components/business-assistant/shared.ts
  55. 58 114
      src/components/business-assistant/useBusinessAssistantChat.ts
  56. 12 29
      src/components/business-assistant/useBusinessAssistantUpload.ts
  57. 4 7
      src/components/useEnterpriseAuth.ts
  58. 2 2
      src/components/utils.ts
  59. 4 0
      src/main.ts
  60. 4 7
      src/network/api/chat-sessions.ts
  61. 12 15
      src/network/api/company-classification.ts
  62. 56 141
      src/network/api/dms/chat-sessions-dms.ts
  63. 30 61
      src/network/api/dms/classification-sync-utils.ts
  64. 113 48
      src/network/api/dms/client.ts
  65. 27 37
      src/network/api/dms/company-info-sync.ts
  66. 70 37
      src/network/api/dms/file-upload.ts
  67. 16 24
      src/network/api/dms/service-token.ts
  68. 6 6
      src/network/api/enterprise.ts
  69. 6 8
      src/network/api/nls-token.ts
  70. 5 33
      src/types/record.ts
  71. 7 12
      src/utils/app-message.ts
  72. 9 13
      src/utils/client-ip.ts
  73. 5 10
      src/utils/interrupted-message.ts
  74. 80 0
      src/utils/policy-library-loader.ts
  75. 4 13
      src/utils/question-cards-answers.ts
  76. 3 6
      src/utils/question-cards-content.ts
  77. 25 40
      src/utils/runtime-config.ts
  78. 33 149
      src/utils/stream-xml-filter.ts
  79. 18 45
      vite.config.ts

+ 11 - 49
.env.development

@@ -1,59 +1,21 @@
-# 开发环境(npm run dev)。生产见 .env.production。
-# 本机专属覆盖:**需要时新建** `.env.development.local`(`.gitignore` 已覆盖 `.env.*.local` 模式,
-# 不会入库)—— 那个文件 2026-09-28 已删除,因为里面原本的 NLS 凭据现已不再需要。
-#
-# 分区:① 构建信息 ② 接口地址(进产物) ③ 只给 dev 代理读的 target ④ 云服务的同源路径
-# 字段含义与「谁在读」见 harness/docs/architecture.md 的「环境变量」表。
+# 开发环境(npm run dev)。字段释义见 harness/docs/architecture.md「环境变量」。
+# 本机覆盖写 `.env.development.local`(不入库)。改完需重启 dev server。
 
-# ── ① 构建信息 ──────────────────────────────────────────────
 VITE_VERSION=${npm_package_version}
 
-# ── ② 接口地址(会进产物,改完要重新构建)─────────────────────
-# 主业务 API 网关(企业登录 / 企业信息,见 src/network/api/enterprise.ts)
-# 备用地址(切换时取消注释、注释掉当前这行):
-#   VITE_API="//192.168.14.25:4006"
-#   VITE_API="//human-card-mini.metamaker.cn"
-#   VITE_API="//fifth-hospital.test.metamaker.cn"
-# ⚠️ 不要写 `VITE_API="/api"` —— 那个前缀现在归「后端网关代理」(见下面 ③)用,语义不同
+# 主业务网关(企业登录 / 企业信息)。勿写成 "/api"(那是同源代理前缀)
+# 备用://192.168.14.25:4006  //human-card-mini.metamaker.cn  //fifth-hospital.test.metamaker.cn
 VITE_API="//qingpu-data-api.metamaker.cn"
 
-# 政策库的"远端"—— **DMS 文件服务里的那份**(用户 2026-09-24 指定:把 policies_public.v2.json
-# 上传到 DMS,那个路径就是新的远端政策接口)。同源路径,dev 走 vite 代理 `/dms/`(直通)。
-# 加载顺序固定为「先远端、取不到再用随包发布的本地副本」(见 index.html)。
-# 重新上传(库内容更新时):node harness/tools/upload-policy-library.mjs
-VITE_POLICY_REMOTE_URL="/dms/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json"
-
-# 嵌入 iframe 时父页面的 origin(index.html 的 postMessage 目标;**不要写成 '*'**)
-VITE_PARENT_ORIGIN='https://192.168.14.50:5174'
-
-# 新版聊天协议后端(POST /api/chat,SSE)
-# 开发环境走 vite 同源代理(见 vite.config.ts 的 /chat-api/),
-# 避免 HTTPS 页面直连 HTTP 后端被浏览器拦截,同时绕开后端未实现 CORS 的问题
+# 聊天后端(POST /api/chat,SSE)—— vite 代理 /chat-api/(剥前缀)
 VITE_CHAT_API="/chat-api"
 
-# DMS 数据管理服务(会话/问答记录/反馈的落地库)
-# 走 vite 同源代理:HTTPS 页面直连 http:// 的 DMS 会被浏览器按混合内容拦截,
-# 且 token 由代理侧注入(见 vite.config.ts),不进浏览器产物
+# DMS 数据服务 —— vite 代理 /dms/
+# 政策库远端路径写在 public/config.js 的 dms.policyRemotePath(DMS 上传会改名)
 VITE_DMS_API="/dms"
 
-# ── ③ 同源路径(生产由 nginx 做等价转发)─────────────────────
-# ⚠️ 代理的 **target 地址不在这里**,直接写在 `vite.config.ts` 的 proxy 里
-# (用户 2026-09-29 要求「vite.config.ts 里面不要加环境变量」,本文件不再被它读取)。
-
-# 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
-# 它是 **HTTP**,而页面是 HTTPS —— 直连会被混合内容拦掉,所以走同源代理。
-# ⚠️ `/api` 这条代理会**剥掉前缀**(网关真实路径是 /proxy/verify/…),所以这里写前缀就够。
-VITE_IP_API="/api"
-
-# 未登录访客换 DMS token 的登录端点(**同源路径**,dev 走 vite 代理 `/oauth/` → 121.43.55.7:10086)。
-# 前端 `src/network/api/dms/service-token.ts` 用它 + 内置服务账号换 token。
-VITE_OAUTH_LOGIN_URL="/oauth/api/user/login"
-
-# 阿里云 NLS(实时语音识别)的 token 端点 —— 后端网关提供,**同源路径**(与取 IP 同一台机器)。
-# 网关只有 HTTP、页面是 HTTPS,直连会被混合内容拦掉,所以走 vite 代理(见 vite.config.ts 的 /api/)。
-# 生产要在 nginx 里把 /api/ 转发到 http://121.43.55.7:10011/(去掉前缀),前端代码不用改。
-VITE_NLS_TOKEN_API="/api/proxy/verify/getALYToken"
+# 后端网关同源前缀(取 IP / NLS token)—— vite 代理 /api/(剥前缀)
+VITE_GATEWAY_API="/api"
 
-# NLS 的 appkey:**已内置进代码**(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`,
-# 用户 2026-09-28 要求「直接写入使用的地方,不再放在环境变量里」)。
-# 真要用 env 临时覆盖也可以:`VITE_NLS_APPKEY` 仍然优先于内置值。
+# OAuth 同源前缀 —— vite 代理 /oauth/;登录路径由代码拼 /api/user/login
+VITE_OAUTH_LOGIN_URL="/oauth"

+ 13 - 55
.env.production

@@ -1,63 +1,21 @@
-# 生产环境(npm run build --mode production)。开发见 .env.development。
-#
-# 分区(与 .env.development 对齐):① 构建信息 ② 接口地址(进产物) ③ 云服务的同源路径
-# ⚠️ 生产**没有** vite 代理(`server.proxy` 只对 `npm run dev` 生效,`npm run preview` 会复用同一份,
-#    容易误判"生产已通")—— 下面这些同源路径**都要由部署侧 nginx 转发**,见各条注释。
-# 字段含义与「谁在读」见 harness/docs/architecture.md 的「环境变量」表。
+# 生产环境(npm run build --mode production)。字段释义见 harness/docs/architecture.md「环境变量」。
+# 无 vite 代理:下方同源路径须由 nginx 转发。改完需重新构建。
 
-# ── ① 构建信息 ──────────────────────────────────────────────
 VITE_VERSION=${npm_package_version}
 
-# ── ② 接口地址(会进产物)────────────────────────────────────
-# 主业务 API 网关(企业登录 / 企业信息)
+# 主业务网关(企业登录 / 企业信息)。勿写成 "/api"(那是同源代理前缀)
+# 备用://192.168.14.25:4006  //human-card-mini.metamaker.cn  //fifth-hospital.test.metamaker.cn
 VITE_API="//qingpu-data-api.metamaker.cn"
 
-# 政策库的"远端"—— DMS 文件服务里的那份(同 dev,见 .env.development 的说明)。
-# ⚠️ 生产要能用它,需要两件事(与技术债 #9 同源):① `VITE_DMS_API` 配好(下面 ③);
-#    ② nginx 把 `/dms/` 转发到 DMS 主机(2026-09-29 起**直通、不再补前缀**)。
-# 取不到就自动退回随包发布的本地副本(`/merchant-agent/policies_public.v2.json`),功能不受影响。
-VITE_POLICY_REMOTE_URL="/dms/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json"
+# 聊天后端(POST /api/chat,SSE)—— nginx /chat-api/(剥前缀 → 后端真实 /api/chat)
+VITE_CHAT_API="/chat-api"
 
-# 嵌入 iframe 时父页面的 origin(index.html 的 postMessage 目标;**不要写成 '*'**)
-VITE_PARENT_ORIGIN='https://aixq.shqp.gov.cn/'
-
-# 新版聊天协议后端(POST /api/chat,SSE),直连模式
-# ⚠️ 生产部署时请改为实际后端地址(当前仍指向内网测试机,见技术债 #11)
-VITE_CHAT_API="http://192.168.2.23:8000"
-
-# 跳转映射(`redirect.html` 用:`?redirect=<key>` → 目标地址)。
-# **不能删**:缺了它 redirect.html 里的 `%VITE_REDIRECT_MAP%` 会原样留下、页面脚本直接语法错误
-# (⚠️ 本项只有生产有,dev 缺 → 本地打开 `/redirect.html` 会是语法错误页;登录链不受影响)
-# 值里的 `#` 必须写成 `%23`(.env 里 `#` 是注释符),页面里会还原。
-VITE_REDIRECT_MAP={"demo":"https://demo.heijingai.com/FTA-c2p/%23/overview","zhaoshang":"https://demo.heijingai.com/policy-application/%23/"}
-
-# ── ③ 同源路径(都需 nginx 转发;2026-09-29 起前缀与服务真实路径对齐)──
-#   /api/**  → http://121.43.55.7:10011(**剥掉 /api**:网关真实路径是 /proxy/verify/…)
-#   /dms/**  → http://121.43.55.7:10081(**直通**:DMS 真实路径就带 /dms,并注入 token 头)
-#   (聊天后端 VITE_CHAT_API 上面是绝对地址;若要改成同源,另配一条 nginx 规则)
-#
-# 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
-# ⚠️ 它只有 HTTP,页面是 HTTPS —— 生产**必须由 nginx 转发 /api/ 到 121.43.55.7:10011(去前缀)**,
-#    否则请求会被浏览器按混合内容拦掉
-#    (拦掉不致命:拿不到 IP 会退回 访客_<浏览器随机 id>,只是访客归属不稳定)
-VITE_IP_API="/api"
-
-# 未登录访客换 DMS token 的登录端点(**同源路径**)
-# ⚠️ 生产**必须由 nginx 把 `/oauth/` 直通到 http://121.43.55.7:10086**(无需改前缀),
-#    否则未登录访客换不到 token ⇒ DMS 读写全失败
-VITE_OAUTH_LOGIN_URL="/oauth/api/user/login"
-
-# 阿里云 NLS 的 token 端点(实时语音识别)—— 后端网关提供,同源路径(与取 IP 同一台机器)
-# ⚠️ 生产同样由 nginx 转发 `/api/`(去前缀)—— NLS token 与取 IP **共用这一条**(2026-09-29 合并)
-VITE_NLS_TOKEN_API="/api/proxy/verify/getALYToken"
-
-# DMS 数据服务(会话/问答记录/反馈,由代理/nginx 注入 token 头)
+# DMS 数据服务 —— nginx 直通 /dms/
+# 政策库远端路径写在 public/config.js 的 dms.policyRemotePath(DMS 上传会改名)
 VITE_DMS_API="/dms"
-# ✅ NLS 的 appkey:**已内置进代码**(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`,
-#    用户 2026-09-28 要求)⇒ **生产不再需要部署侧注入**,语音识别开箱可用。
-#    如临时要换:设 `VITE_NLS_APPKEY`(env / 或 index.html 注入 globalThis)即可覆盖内置值。
 
-# ── 已删(留说明,别再加回来)────────────────────────────────
-# [已删] VITE_DOWNLOAD_URL="/api/download/"(政策库 json 的下载前缀)——
-# 2026-09-28 用户确认删除:**全仓库没有任何读者**(index.html 的本地兜底早已写死
-# `/merchant-agent/policies_public.v2.json`),生产侧 nginx 也就不再需要转发 `/api/download/`。
+# 后端网关同源前缀(取 IP / NLS token)—— nginx /api/(剥前缀)
+VITE_GATEWAY_API="/api"
+
+# OAuth 同源前缀 —— nginx 直通 /oauth/;登录路径由代码拼 /api/user/login
+VITE_OAUTH_LOGIN_URL="/oauth"

+ 32 - 0
README.md

@@ -653,3 +653,35 @@
     ② token 落在浏览器。技术债 **#8**(已同步更新:暴露面扩大、生产接入前必须解决)
   - 验证:新增 `harness/tools/verify-dms-service-token.mjs` **5/5** —— 含关键断言
     「带假 token 被 DMS 拒(212)⇒ 代理没有覆盖前端带的 token」
+- **确认「独立页面、不再嵌入」后清掉父页面那套**(用户:「不会嵌入到其他页面,有什么可以删除的」):
+  - `index.html` 删掉 `initMessageChannel()`(监听父页面消息 + 回发 `{type:"ready"}`)与调用
+    —— ⚠️ **保留紧随其后的遮罩收尾三行**(`hasSequence` 等,`verify-inline-scripts` 守着)
+  - 删掉 `WeixinJSBridgeReady` 监听(回调里只剩 `console.log` + 注释掉的视频播放)
+  - 两份 env 删掉 `VITE_PARENT_ORIGIN`;文档(architecture env 表 / call-sites #12 / 技术债 #11)同步
+  - 验证:构建通过 · `verify-inline-scripts` **8/8** · `validate-harness` 通过
+- **旧协议内容标记全部清完**(用户:①死链删 ②旧对话已清空,`<form>/<link-url>/<file-docx>` 也删):
+  - `stream-xml-filter.ts` 删掉 **7 条规则 + 7 个枚举成员**(含两个随之失去调用方的私有方法与
+    "silence 透明容器"特判)⇒ **规则表只剩 4 条**(scope / image-scope / ref_links / question-cards)
+  - 删掉三个渲染组件(`FormContent` / `LinkURLContent` / `OfficeURLContent`)及其挂载、import、
+    `inject("formOptionsControl")`、`customProcessor`、`jsonrepair`;两个壳组件里已无人 inject 的同名 provide 也删
+  - 🐛 **顺带修掉一个更早就存在的解析器挂起 bug**:`isPossibleOpenPrefix()` 没检查"是否真的以规则名开头",
+    导致任意文本只要在第 N 个字符处是空格/`>` 就被当成"可能的前缀"而**永久挂起**
+    (实测 `<form id="a">…` 撞 `image-scope`、`</link-url>` 撞 `ref_links`)
+  - 验证:`verify-stream-filter-rules` **32/32** · `verify-question-cards` 36/36 · 构建通过 · dev 冒烟页面 200
+- **注释精简**(用户:「清理一下注释,只保留最基本的功能注释」):
+  - 范围:`src/` 全部 + `vite.config.ts` / `index.html` / 两份 `.env`(`harness/tools/*.mjs` 不动 —— 头部注释就是"怎么跑")
+  - 保留:一句用途 + **非显然硬约束**(鉴权头叫 `token`、`202` 是空结果、请求体只两字段、`/api` 剥前缀、遮罩收尾四行删不得…);
+    删除:日期/"用户要求"/踩坑史/演变叙述/`[已删]` 记录/代价论证
+  - 体量:42 个 `.ts`/`.vue` 的注释行 **2561 → 1638(−923,−36%)**;`.env` 的**变量行与 HEAD 逐字一致**
+  - 🔑 **代码未变的证明**:HEAD 与工作区分别 esbuild `minify` 后逐字节比对 ⇒ 31 个 `.ts` 里 **30 个完全一致**
+    (唯一不同者是上一条里我自己的代码改动);`.vue` 逐行审计也全是注释
+  - 验证:构建 ✓ · `verify-inline-scripts` 8/8 · `verify-answer-stream` 165/165 · 其余 8 个校验脚本全过 · dev 冒烟页面 200
+- **部署到 HTTP:隐藏麦克风入口 + 修好「HTTP 下复制失效」**(用户:「麦克风问题暂时无法解决,所以先隐藏掉」):
+  - 麦克风入口全仓库只有一处(`BusinessAssistantMobileInputShell.vue`,PC/Mobile 共用)⇒
+    加开关 `VOICE_INPUT_VISIBLE = false` + `v-if`;ASR 逻辑保留,改回 `window.isSecureContext` 即 HTTPS 自动恢复。
+    产物核对:麦克风 SVG 已被常量折叠,**生产包 0 命中**
+  - ⚠️ **顺带发现 HTTP 硬伤**:`navigator.clipboard` 在非安全上下文不存在,原写法
+    `navigator.clipboard.writeText(x).catch(…execCommand 兜底…)` 会**同步抛错**、兜底永不执行 ⇒
+    **全站「复制」失效**(主复制按钮就在 `FeedbackWidget` 里)。改成显式判断 + try/catch + 兜底;
+    新增 `verify-clipboard-fallback.mjs` **9/9**(HTTP / HTTPS / 权限被拒 三组桩)
+  - 非安全上下文风险点已核查:`crypto.randomUUID` 有守卫+兜底 · `mediaDevices`/`subtle`/`serviceWorker` 无虞或不可达

+ 9 - 7
harness/docs/architecture.md

@@ -123,15 +123,17 @@ src/
 |---|---|---|---|
 | `VITE_VERSION` | 页面标题里的版本号 | 两份 | `index.html` 的 `%VITE_VERSION%` |
 | `VITE_API` | 主业务网关(企业登录 / 企业信息,**老接口**) | 两份 | `enterprise.ts:51,83`、两个壳组件的 `apiBaseUrl` |
-| `VITE_POLICY_REMOTE_URL` | 惠企政策库的**远端那份**(DMS 文件服务里的 json) | 两份 | `index.html`;取不到自动退回随包本地副本 |
-| `VITE_PARENT_ORIGIN` | 嵌 iframe 时父页面的 origin(postMessage 目标) | 两份 | `index.html`;**不要写成 `*`** |
+| ~~`VITE_POLICY_REMOTE_URL`~~ | ~~惠企政策库远端完整 URL~~ —— **已删**:改到 `public/config.js` 的 `dms.policyRemotePath`(填 uploadFile **返回的相对路径**;DMS 会改名,不能写死 `policies_public.v2.json`)。取不到再本地兜底 | — | `policy-library-loader.ts` |
+| ~~`VITE_PARENT_ORIGIN`~~ | ~~嵌 iframe 时父页面的 origin(postMessage 目标)~~ —— **✅ 2026-09-29 已删**:用户确认本项目是**独立页面、不再嵌入**任何父页面 ⇒ `index.html` 里的 `initMessageChannel()`(监听父页面消息 + 回发 `ready`)整套删除;`localStorage.initMessage` 也无读者 | — | — |
 | `VITE_CHAT_API` | 对话后端(SSE)。**服务前缀**(`/chat-api`、`http://host:8000`)会自动补 `/api/chat`,完整地址则原样用 | 两份 | `api-chat-coordinator.ts`;**企业分类也走它**(`company-classification.ts`) |
-| `VITE_DMS_API` | DMS 数据服务(会话/问答/反馈/企业信息)。值是**同源前缀 `/dms`** | 两份 ✅ | `dms/client.ts`、`dms/file-upload.ts`;token 由前端自己带(见下) |
-| `VITE_OAUTH_LOGIN_URL` | 未登录访客换 DMS token 的登录端点(`/oauth/api/user/login`) | 两份 | `dms/service-token.ts`;**同源路径**,生产需 nginx 直通 `/oauth/` |
-| `VITE_IP_API` | 取本机 IP(DMS 访客归属 `访客_<IP>`)的**前缀**(代码再拼 `/proxy/verify/getIPByBrowser`) | 两份 | `client-ip.ts`;**必须同源**(网关只有 HTTP) |
-| `VITE_NLS_TOKEN_API` | 语音识别 token 端点(后端网关签发)**完整路径** | 两份 | `nls-token.ts`;**必须同源**,与取 IP 共用 `/api` 那条代理 |
+| `VITE_DMS_API` | DMS 数据服务(会话/问答/反馈/企业信息/政策库文件)。值是**同源前缀 `/dms`** | 两份 ✅ | `dms/client.ts`、`dms/file-upload.ts`、`policy-library-loader.ts`;token 由前端自己带 |
+| `public/config.js` → `__ZHAOSHANG_CONFIG__.dms` | **运行时** DMS 栏目/模型 id + 内网双 `c_` 开关(部署后可改、无需重建) | 随包 | `index.html` 先引入;`dms/client.ts` 读栏目;`fieldDoubleCPrefix` 时出入网编解码 `c_*` ↔ `c_c_*` |
+| `VITE_OAUTH_LOGIN_URL` | OAuth 同源前缀(默认 `/oauth`);登录路径 `/api/user/login` 由代码拼接 | 两份 | `dms/service-token.ts`;生产需 nginx 直通 `/oauth/` |
+| `VITE_GATEWAY_API` | 后端网关同源前缀(取 IP + NLS token;默认 `/api`;代码再拼 `/proxy/verify/…`)。**不是**企业业务网关 `VITE_API` | 两份 | `client-ip.ts`、`nls-token.ts`;nginx/vite **剥前缀** → 网关 |
+| ~~`VITE_IP_API`~~ | ~~旧名~~ —— **已改名为 `VITE_GATEWAY_API`**(2026-09-29) | — | — |
+| ~~`VITE_NLS_TOKEN_API`~~ | ~~语音 token 完整路径~~ —— **已删**:与取 IP 共用 `VITE_GATEWAY_API`,路径由 `getNlsTokenApiUrl()` 拼接 | — | — |
 | `VITE_NLS_APPKEY` | NLS 的 appkey(**不是密钥**)。**2026-09-28 起已内置进代码**(`runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`),本变量降级为"可选覆盖" | 可不出现在任何 env | 语音识别请求要带;`globalThis` 注入 → env → 内置值 的覆盖链 |
-| `VITE_REDIRECT_MAP` | `redirect.html` 的跳转映射(`#key` → 目标地址) | **仅生产** ⚠️ | `redirect.html`;dev 缺它 → 本地直接打开该页是语法错误页(登录链不受影响) |
+| ~~`VITE_REDIRECT_MAP`~~ | ~~`redirect.html` 跳转映射~~ —— **✅ 2026-09-29 已删**(政策申报中转与本助手无关;登录回跳后续自行对接) | — | — |
 
 > 📌 **代理前缀方案(2026-09-29 用户指定)**:前缀**尽量与服务真实路径一致**,只有服务端本身不带前缀时才剥:
 > `/oauth/**`(→10086,直通)· `/dms/**`(→10081,直通,并注入 token)·

+ 8 - 6
harness/docs/exec-plans/tech-debt-tracker.md

@@ -166,10 +166,10 @@
 - **是什么**:把生产产物接到真实的生产后端。用户 2026-09-28 给出的地址:
   **DMS `10.235.245.174`**、**AI 对话 `10.249.33.148`** —— **两个都没给端口**。
 - **为什么先欠着**:用户同日明确——「生产环境适配先不管,先标记待办,我们先处理别的问题」。
-- **欠着会怎样**:生产产物现在 ①`VITE_CHAT_API` 还指着**测试后端** `http://192.168.2.23:8000`
-  (`.env.production` 里就写着"生产部署时请改为实际后端地址");
-  ②**根本没有 `VITE_DMS_API`** → `getDmsApiBaseUrl()` 返回空串,附件弹「未配置 DMS 接口地址」、
-  会话/问答**静默不落库**(与 **#9** 是同一件事,#9 是 DMS 那一半)。
+- **欠着会怎样**:生产产物现在 ①`VITE_CHAT_API` 已改成同源 `/chat-api`(与 dev 一致),
+  但部署侧 nginx 还没配上真实生产后端(对话机 `10.249.33.148`,端口未定);
+  ②~~根本没有 `VITE_DMS_API`~~ → **已补**同源 `/dms`(见 #9),仍差 nginx。
+  未配 nginx 时聊天/DMS 会 404,会话/问答**静默不落库**。
 - **动手前要先拍板的两件事**:
   1. **端口**:沿用测试环境的 `8000`(对话)/ `10081`(DMS),还是别的;
   2. **连接方式**:
@@ -177,7 +177,8 @@
        两台机器;**DMS 那条还要注入 token**(前端不持有 token)。这也正是 **#8** 的暴露面所在
        (token 不分调用者 → 站点可访问 = DMS 数据可读,接入前应一并解决);
      - **直连绝对地址**(`http://10.249.33.148:<端口>`)→ 生产页面是 **HTTPS**
-       (`VITE_PARENT_ORIGIN = https://aixq.shqp.gov.cn/`),直连 `http://10.x` 会被浏览器
+       (页面是 HTTPS;原来的父页面 origin 变量 `VITE_PARENT_ORIGIN` 已于 2026-09-29 随"不再嵌入"删除),
+       直连 `http://10.x` 会被浏览器
        按**混合内容拦掉**,除非页面本身就跑在 http 下。
 - **2026-09-28 核对过的事实(复用时不用再查一遍)**:
   - `vite.config.ts` 的 `server.proxy` **只对 `npm run dev` 生效**,`npm run build` 的产物里
@@ -186,7 +187,8 @@
     → "本地 preview 能通、上生产 404" 就是这个原因,别被它当成"生产已通"。
   - ~~`*_TARGET` 只有 dev 代理读~~ → **2026-09-29 起那四个变量已删除**:
     代理 target 直接写在 `vite.config.ts`(用户要求「不要加环境变量」)。
-    产物只认 `VITE_CHAT_API` / `VITE_DMS_API` / `VITE_IP_API` / `VITE_NLS_TOKEN_API` 的最终值。
+    产物只认 `VITE_CHAT_API` / `VITE_DMS_API` / `VITE_GATEWAY_API` 的最终值
+    (NLS token 路径由代码拼在 `VITE_GATEWAY_API` 后)。
   - 生产 env 里的同源路径与 dev **同名同法**(2026-09-29 统一):
     `/dms/**`(DMS + 政策库 json,直通并注入 token)· `/api/**`(网关:语音 token + 取 IP,**剥前缀**)。
     nginx 照这两条配即可,**不再需要补 `/dms` 前缀**。

+ 1 - 1
harness/docs/reference/asr-nls.md

@@ -41,7 +41,7 @@ token 由**后端网关**签发(2026-09-24 起,用户指定):
 
 | 变量 | 值 | 说明 |
 |---|---|---|
-| `VITE_NLS_TOKEN_API` | `/api/proxy/verify/getALYToken` | 同源路径(与取本机 IP 共用 `/api` 代理,2026-09-29 起);**两份 env 都写了** |
+| `VITE_GATEWAY_API` | `/api` | 取 IP 与 NLS token 共用的后端网关同源前缀(代码再拼 `/proxy/verify/…`) |
 | `VITE_NLS_APPKEY` | NLS 的 appkey | **不是密钥**(换 token 不需要它、网关也不校验它),而且每次识别请求本就会把它明文发出去。**2026-09-28 起直接内置在代码里**(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`)—— **不再需要部署侧注入,也不要求 `.env.*.local` 里有它**;env / `globalThis` 仍可覆盖 |
 
 ## 2. 连接与指令

+ 14 - 14
harness/docs/reference/current-api-call-sites.md

@@ -16,19 +16,19 @@
 | 2 | `POST {同一后端}/api/company/classify` | 企业多标签分类 | `src/network/api/company-classification.ts` |
 | 3 | `{VITE_DMS_API}/content/*` | DMS 读写(1886/1887/1888/1889 会话·问答·反馈·企业信息)。**2026-09-29 起 token 由前端自己带**(未登录访客用服务账号现换,见 #17) | `src/network/api/dms/client.ts` |
 | 4 | `POST {VITE_DMS_API}/file/uploadFile` | 附件上传到 DMS 文件服务 | `src/network/api/dms/file-upload.ts` |
-| 5 | `GET {VITE_NLS_TOKEN_API}`(→ `http://121.43.55.7:10011/proxy/verify/getALYToken`) | 语音识别的 token(后端网关签发) | `src/network/api/nls-token.ts` |
+| 5 | `GET {VITE_GATEWAY_API}/proxy/verify/getALYToken`(→ `http://121.43.55.7:10011/proxy/verify/getALYToken`) | 语音识别的 token(后端网关签发) | `src/network/api/nls-token.ts` |
 | 6 | `wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1` | 实时语音识别(阿里云 NLS) | `src/asr/nls.ts` |
-| 7 | `GET {VITE_IP_API}/proxy/verify/getIPByBrowser`(→ 同上 10011) | 取本机 IP(DMS 访客归属) | `src/utils/client-ip.ts` |
+| 7 | `GET {VITE_GATEWAY_API}/proxy/verify/getIPByBrowser`(→ 同上 10011) | 取本机 IP(DMS 访客归属) | `src/utils/client-ip.ts` |
 | 8 | `POST {VITE_API}/enterprise/login`、`GET {VITE_API}/fta_ent_policy/enterprise_info` | 企业登录换码 / 拉企业信息(**老接口**) | `src/network/api/enterprise.ts` |
-| 9 | `GET {VITE_POLICY_REMOTE_URL}`(= `/dms/static/file/1889-c_question-83859739-….json`) | 惠企政策库:**先取 DMS 里的那份,取不到再用本地副本**(`/merchant-agent/policies_public.v2.json`)。原厂商 `VITE_DOWNLOAD_URL` 与 `VITE_POLICY_LOCAL_FIRST` 开关已随本次改动删除(2026-09-24 用户要求) | `index.html` + `Chat/PolicyMatch.vue` |
+| 9 | `GET {VITE_DMS_API}{config.dms.policyRemotePath}` | 惠企政策库:**只读**;路径是 uploadFile 返回的相对路径(DMS 会改名)。空/失败 → 本地 `/merchant-agent/policies_public.v2.json`。上传:`upload-policy-library.mjs` | `src/utils/policy-library-loader.ts` |
 | ~~10~~ | ~~`GET {VITE_MODEL_SERVER}/v1/policies?…`~~ | **✅ 2026-09-28 已删**(政策列表的"第三档":库与卡片都为空才走到,实测从未触发)——连带删掉 4 个 helper 与两份 env 的 `VITE_MODEL_SERVER` | — |
 | ~~11~~ | ~~`GET <后端在正文里给的 link>`~~ | **✅ 2026-09-28 已删**(「政策列表(更新版)」:旧协议正文里 `<policy-list-updated>{link,payload}</policy-list-updated>` 给出地址、前端照着取 json)。与 #10 是**同一个接口**,只是地址由后端下发;用户同日确认「老对话仅测试、后续会全部清空」→ 唯一的触发源消失,组件与标签规则一并删除(见 [legacy-content-markers.md](legacy-content-markers.md)) | — |
-| 12 | `postMessage(…, VITE_PARENT_ORIGIN)` | 与父页面通信(嵌 iframe 场景) | `index.html` |
-| 13 | `VITE_REDIRECT_MAP` | 跳转映射页(纯本地,不发请求) | `redirect.html` |
+| ~~12~~ | ~~`postMessage(…, VITE_PARENT_ORIGIN)`~~ | **✅ 2026-09-29 已删**(与父页面通信那套):用户确认本项目是**独立页面、不再嵌入**任何父页面 ⇒ `index.html` 的 `initMessageChannel()`(监听父页面消息 + 回发 `{type:"ready"}`)与 `VITE_PARENT_ORIGIN` 一并删除 | — |
+| ~~13~~ | ~~`VITE_REDIRECT_MAP` / `redirect.html`~~ | **✅ 2026-09-29 已删**:跳转映射页(政策申报中转)与本助手无关,后续登录回跳自行对接 | — |
 | ~~14~~ | ~~`GET https://hm.baidu.com/hm.js?d6307cb…`~~ | **✅ 2026-09-28 已删**(**百度统计**第三方脚本)。理由:数据进的是**厂商账号**(站点配置 `dm:["demo.heijingai.com"]`)、本项目从未用过它的数据(`git log -S '_hmt.push'` 全历史为空)、且它是全站**唯一**往第三方发请求、并在青浦域名上写 cookie 的东西 —— 用户当日确认「删掉」 | — |
 | 15 | `GET https://zwdt.sh.gov.cn/qykj/…/policy-detail?id=<市级政策id>` | 政策详情页(**外部导航**,`target=_blank`,不是接口) | `Chat/policy-library-utils.ts` 的 `buildPolicyDetailUrl` |
-| 16 | `https://zwdtuser.sh.gov.cn/uc/login/login.jsp?redirect_uri=…` | 企业登录(**外部导航**,跳一网通办,登录后经 `redirect.html` 回来) | `components/useEnterpriseAuth.ts` 的 `redirectToLogin` |
-| 17 | `POST {VITE_OAUTH_LOGIN_URL}`(= `/oauth/api/user/login` → `121.43.55.7:10086`) | **未登录访客换 DMS token**(前端用**内置服务账号**换,2026-09-29 起) | `src/network/api/dms/service-token.ts` |
+| 16 | `https://zwdtuser.sh.gov.cn/uc/login/login.jsp?redirect_uri=…` | 企业登录(**外部导航**,跳一网通办;回跳为本页,白名单后续对接) | `components/useEnterpriseAuth.ts` 的 `redirectToLogin` |
+| 17 | `POST {VITE_OAUTH_LOGIN_URL}/api/user/login`(前缀 `/oauth` → `121.43.55.7:10086`) | **未登录访客换 DMS token**(前端用**内置服务账号**换,2026-09-29 起) | `src/network/api/dms/service-token.ts` |
 
 > 📌 **2026-09-28 按代码重核时补的三条**(此前清单漏记,复核方法见 §四):
 > - **#14 百度统计**(**已删**,原始排查记录留在这里备查):它是**唯一会加载第三方脚本、
@@ -47,8 +47,8 @@
 >     **`dm` 白名单不阻止客户端上报**(若有校验也在服务端,未验证)。
 >   - 脚本 CDN 有个观察:请求**不带 Referer 时返回 0 字节**(curl 实测两次),带 Referer 才给 ~30 KB。
 > - **#15 / #16 是浏览器导航、不是 XHR**:前者由政策库条目的 `data.市级政策id` 拼出(261/262 有该字段,
->   见 `policy-library-utils.ts`);后者是把当前地址交给一网通办做 `redirect_uri`,
->   所以 `redirect.html` 必须是一个能直接打开的静态页(见 §一 的说明)。
+>   见 `policy-library-utils.ts`);后者是把当前地址交给一网通办做 `redirect_uri`
+>   (~~原经 `redirect.html` 中转~~ 已删,现回跳本页)。
 > - ⚠️ ~~**`VITE_DOWNLOAD_URL` 是死配置**~~ → **✅ 2026-09-28 已删**:它只定义在 `.env.production`,
 >   全仓库**没有任何读者**(`index.html` 的本地兜底早写死 `/merchant-agent/policies_public.v2.json`)。
 >   现在两份 env 都没有它,**生产侧 nginx 也不再需要转发 `/api/download/`**。
@@ -96,10 +96,10 @@
 
 ### D. 仍在用、别误删的环境变量
 
-`VITE_API`(#8 企业登录在用)· `VITE_CHAT_API` · `VITE_DMS_API` · `VITE_IP_API` ·
-`VITE_NLS_TOKEN_API` · `VITE_NLS_APPKEY`(**2026-09-28 起已内置进代码**,本变量只作可选覆盖) ·
-`VITE_POLICY_REMOTE_URL`(政策库远端,2026-09-24 新增) ·
-`VITE_PARENT_ORIGIN` · `VITE_REDIRECT_MAP`(`redirect.html` 用)· `VITE_VERSION` ·
+`VITE_API`(#8 企业登录在用)· `VITE_CHAT_API` · `VITE_DMS_API` · `VITE_GATEWAY_API` ·
+`VITE_NLS_APPKEY`(**2026-09-28 起已内置进代码**,本变量只作可选覆盖) ·
+~~`VITE_POLICY_REMOTE_URL`~~(改到 config.js `policyRemotePath`) ·
+~~`VITE_PARENT_ORIGIN`~~(2026-09-29 已删,独立页面不需要)· ~~`VITE_REDIRECT_MAP`~~(同日已删,申报中转页无关)· `VITE_VERSION` ·
 `VITE_OAUTH_LOGIN_URL`(#17 换 token 用,2026-09-29 新增)。
 (原有的四个 `*_TARGET` **已于 2026-09-29 删除** —— 代理 target 直接写在 `vite.config.ts` 里,见下。)
 
@@ -265,7 +265,7 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
 ### 7. 取本机 IP(DMS 访客归属用)—— **在用(2026-09-22 新增)**
 
 - 触发:应用启动时预热一次(`main.ts`),写 DMS 前会 `await` 一次(内部缓存 + 并发去重)
-- 接口:`GET {VITE_IP_API}/proxy/verify/getIPByBrowser` → **纯文本 IP**
+- 接口:`GET {VITE_GATEWAY_API}/proxy/verify/getIPByBrowser` → **纯文本 IP**
   (响应头 Content-Type 标的是 `application/json`,但正文就是裸 IP —— 按 text 读,别 JSON.parse)
 - **必须走同源代理**:接口是 `http://121.43.55.7:10011`,页面是 HTTPS ——
   接口带 `Access-Control-Allow-Origin: *`(跨域没问题),但**混合内容**会被浏览器拦掉。

+ 58 - 39
harness/docs/reference/legacy-content-markers.md

@@ -1,32 +1,42 @@
-# 旧协议内容标记存活清单(只读)
+# 旧协议内容标记存活清单
 
-> **核对时间**:2026-09-28(用户「老对话仅测试,后续会全部清空」→ 要求顺带把
-> 其他只服务旧协议的块查一遍)
-> **性质**:**只读排查** —— 除 `<policy-list-updated>`(本次已删,见 §一)外,本清单
-> **没有据此改任何代码**;结论供用户决定下一步删什么。
+> **核对时间**:2026-09-28 首查(只读);**2026-09-29 完成处置** —— 用户确认
+> 「独立页面、不再嵌入」+「旧对话已清空」后,清单里除"在用"的 5 条之外**全部删除**。
 > **方法**:全仓库 grep `<标记`,逐个反查三件事:
 > ① `stream-xml-filter.ts` 里有没有规则 → ② 谁消费(渲染组件 / `customProcessor`)→
 > ③ **现在谁产出**(新协议适配层?旧会话历史?开发环境 mock 文本?)。复核命令见 §四。
 
-## 一、结论表
+## 一、结论表(2026-09-29 后:只剩"在用"的 5 条)
 
 | 标记 | 过滤器规则 | 消费端 | 现在的产出者 | 结论 |
 |---|---|---|---|---|
-| `<scope>` | block([stream-xml-filter.ts:53](../../../src/utils/stream-xml-filter.ts)) | `ScopeContent` | **适配层**(progress / heartbeat → 思考中卡片) | ✅ 在用 |
+| `<scope>` | block | `ScopeContent` | **适配层**(progress / heartbeat → 思考中卡片) | ✅ 在用 |
 | `<!-- POLICY_TABLE … -->` | 特殊块 | `PolicyMatch` | **适配层**(item 事件) | ✅ 在用 |
-| `<ref_links>` | block(:58) | `BusinessRecord` 参考资料 | **适配层**(source 事件) | ✅ 在用 |
-| `<question-cards>` | block(:77) | `QuestionCard` | **适配层**(interrupt 事件) | ✅ 在用 |
-| `<image-scope>` | block(:55) | `ImageContent` | **适配层**(result.cipa → 企业微信二维码) | ✅ 在用 |
-| ~~`<policy-list-updated>`~~ | ~~block~~ | ~~`PolicyListUpdatedMatch`~~ | 无 | 🗑 **2026-09-28 已整条删掉**(见下节) |
-| `<form>` | block(:52) | `FormContent`(BusinessRecord:116) | **无** —— src 里没有产出代码 | ⚠️ 只有旧会话历史能触发 |
-| `<link-url>` | block(:54) | `LinkURLContent`(:117) | **无** | ⚠️ 同上 |
-| `<file-docx>` | block(:59-64) | `OfficeURLContent`(:125) | **无** —— 附件走请求体 `pictures`/`files`,不走正文标记 | ⚠️ 同上 |
-| `<ui-form>` | skip(:65-70) | `customProcessor` → `setFormOptions`(BusinessRecord:561-574) | **无** | 💀 **整条链是死的**:两个壳组件 provide 的 `setFormOptions` **都是空函数**(`BusinessAssistantMobile.vue:424` / `BusinessAssistantPC.vue:366`),即便有内容也不产生任何界面变化 |
-| `<followup>` | skip(:71-76) | **无人注册** —— `customProcessor` 里只有 `UIForm` 一项 | 只有**开发环境测试按钮**的 mock 文本(`test-document-line-stream.ts:20`、`useBusinessAssistantChat.ts:1466`) | 💀 **全链死**:`followupSuggestions` 全仓库**没有任何写入点**(旧协调器删除后就没接回),块被静默丢弃 |
-| `<silence>` | skip(:51,透明容器) | 无(只是壳) | 同上,仅那两处 mock 文本 | 💀 只剩 mock;`interrupted-message.ts:19` 还会剥它,属无害残留 |
-| `<qingpu_enterprise_notificaion>` | skip(:78-83) | **无人注册** `customProcessor` | **无** | 💀 死规则(`AccountInfo.vue` 里的 `ENTERPRISE_BUBBLE_EVENT` 是另一个 DOM 事件,与此无关) |
-
-> 行号是 **2026-09-28 改动后**的(删掉 policy-list-updated 规则会让后面的行号前移一位)。
+| `<ref_links>` | block | `BusinessRecord` 参考资料 | **适配层**(source 事件) | ✅ 在用 |
+| `<question-cards>` | block | `QuestionCard` | **适配层**(interrupt 事件) | ✅ 在用 |
+| `<image-scope>` | block | `ImageContent` | **适配层**(result.cipa → 企业微信二维码) | ✅ 在用 |
+| ~~`<policy-list-updated>`~~ | ~~block~~ | ~~`PolicyListUpdatedMatch`~~ | 无 | 🗑 **2026-09-28 已删**(见 §二) |
+| ~~`<form>`~~ · ~~`<link-url>`~~ · ~~`<file-docx>`~~ | ~~block~~ | ~~`FormContent` / `LinkURLContent` / `OfficeURLContent`~~ | 无(只有旧历史能触发) | 🗑 **2026-09-29 已删**(见 §三) |
+| ~~`<ui-form>`~~ | ~~skip~~ | ~~`customProcessor` → `setFormOptions`~~ | 无(消费端本来就是空函数) | 🗑 **2026-09-29 已删** |
+| ~~`<followup>`~~ · ~~`<silence>`~~ | ~~skip~~ | 无人注册 | 仅开发环境 mock 文本 | 🗑 **2026-09-29 已删** |
+| ~~`<qingpu_enterprise_notificaion>`~~ | ~~skip~~ | 无人注册 | 无 | 🗑 **2026-09-29 已删** |
+
+**删除后 `stream-xml-filter.ts` 的规则表只剩 4 条**(scope / image-scope / ref_links / question-cards)
++特殊块 `<!-- POLICY_TABLE … -->`;枚举 `RecordRowTypes` 只剩 6 个成员。
+
+> ⚠️ **遗留**:开发环境那两个 mock 文本(`test-document-line-stream.ts:20`、
+> `useBusinessAssistantChat.ts:1466`)里仍写着 `<silence><followup …>` —— 点"测试流"按钮时
+> 那串标记会以**原始文本**显示(dev-only,不影响线上)。要清可以把那两处 mock 一并改掉。
+
+> 🐛 **顺带修掉一个更早就存在的解析器 bug**(是靠本清单的断言发现的):
+> `isPossibleOpenPrefix()` 在"标签名比规则名长"那一支里**没检查是否真的以规则名开头**,
+> 于是任意文本只要在第 `open.length` 个字符处是空格或 `>` 就会被误判为"可能的前缀"而
+> **永久挂起**(既不产出行也不吐文本)。实测:`<form id="a">…`(偏移 11 处是 `>` → 撞上 `image-scope`)、
+> `</link-url>`(偏移 9 处是 `>` → 撞上 `ref_links`)。已修 + 在
+> `harness/tools/verify-stream-filter-rules.mjs` 加断言钉住。
+
+## 二、2026-09-28 已删:`<policy-list-updated>`
+
 
 ## 二、本次已删:`<policy-list-updated>`
 
@@ -47,18 +57,25 @@
   正文里那串 `<policy-list-updated>{"link":…}</policy-list-updated>` 会**当普通文本显示**
   (既没有卡片列表,也不再被吞掉)。新会话不受影响。
 
-## 三、剩下的怎么处理(建议,未动手)
+## 三、2026-09-29 已删:其余 7 条(用户确认「独立页面 + 旧对话已清空」后一次性清掉)
+
+**删了什么**:
+- 规则 7 条 + 枚举成员 7 个(`Form` / `LinkURL` / `OfficeURL` / `UIForm` / `Silence` /
+  `FollowUp` / `EnterpriseNotification`)
+- 三个渲染组件文件:`FormContent.vue` / `LinkURLContent.vue` / `OfficeURLContent.vue`
+- `BusinessRecord.vue`:三处挂载、三个 import、`inject("formOptionsControl")` 与
+  `customProcessor`(`<ui-form>` 那项)、因此失去用途的 `jsonrepair` import
+- 两个壳组件里已无人 inject 的 `provide("formOptionsControl", …)`(本来就是空函数)
+- `stream-xml-filter.ts` 里随之失去调用方的两个私有方法
+  (`splitSafeContentBeforeCloseTail` / `getNestedFilter`,为 silence 的"透明容器"服务)
 
-按「删了会不会有人看出来」分两档:
+**依据**:用户 2026-09-29 明确「这个是单独的前端页面,不会嵌入到其他页面」+「旧对话已清空」
+⇒ 那三个"只服务旧历史"的渲染器失去唯一触发源;另外四条本来就是死链。
 
-1. **`<ui-form>` / `<followup>` / `<silence>` / `<qingpu_enterprise_notificaion>`** ——
-   已经没有任何效果(消费端空实现或无人注册),删规则不会有任何可见变化。
-   要动的话删规则即可,**枚举成员与 `FormContent` / `LinkURLContent` / `OfficeURLContent`
-   不要跟着删**(理由见下)。
-2. **`<form>` / `<link-url>` / `<file-docx>`** —— 渲染组件是活的(旧历史里仍能画出来)。
-   删规则 = 旧会话那几处变成原始文本,与 policy-list-updated 同一个副作用。
-   既然旧对话要清空,这批也可以走同一条路;**但建议等清空动作真的做完再删**,
-   免得清空前有人翻旧会话时看到一片标记。
+**验证**:`verify-stream-filter-rules` **32/32**(新增断言:7 个标记都不再产出行、按普通文本留在正文、
+枚举只剩 6 个成员、三个组件文件与其挂载/import/inject 都没了)·
+`verify-question-cards` 36/36(真实切行器往返)· `verify-policy-card-list` 24/24 ·
+`npm run build` 通过 · `vue-tsc` 无新增报错 · dev 冒烟页面 200 且 `BusinessRecord.vue` 编译 200
 
 > 另有一批**与旧协议无关**的历史包袱在 [`current-api-call-sites.md`](current-api-call-sites.md)
 > §二/§三(已停用接口、死代码),不在本文范围。
@@ -66,16 +83,18 @@
 ## 四、复核方法(可重跑)
 
 ```bash
-# ① 每个标记的产出者:排除过滤器与消费组件后应当为空(或只剩开发环境 mock)
-grep -rn "<followup\|<ui-form\|<link-url\|<file-docx\|<form" src/ \
-  | grep -v "stream-xml-filter\|Content.vue\|test-document-line-stream"
+# ① 规则表只剩 4 条 + 枚举只剩 6 个成员
+grep -n "name: RecordRowTypes\." src/utils/stream-xml-filter.ts
+grep -c "= \"" src/utils/stream-xml-filter.ts   # 期望 6(枚举成员数)
 
-# ② 谁注册了 customProcessor
-grep -n "RecordRowTypes\.[A-Za-z]*," src/components/Chat/BusinessRecord.vue
+# ② 被删的 7 个标记在 src 里**只在注释里**出现(`[已删]` 说明)
+grep -rn "<followup\|<ui-form\|<link-url\|<file-docx\|<form\|<silence\|qingpu_enterprise" src/ \
+  | grep -v "^\S*:\s*[0-9]*:\s*//" 
 
-# ③ 两个壳组件的 setFormOptions 是不是空函数
-grep -n -A1 "formOptionsControl" src/components/BusinessAssistantMobile.vue src/components/BusinessAssistantPC.vue
+# ③ 组件与挂载确已消失
+ls src/components/Chat/FormContent.vue src/components/Chat/LinkURLContent.vue \
+   src/components/Chat/OfficeURLContent.vue 2>&1 | grep -c "No such file"   # 期望 3
 
-# ④ followupSuggestions 有没有写入点(应当没有)
-grep -rn "followupSuggestions\s*=" src/
+# ④ 断言的权威版本(推荐直接跑这个)
+node harness/tools/verify-stream-filter-rules.mjs
 ```

+ 145 - 0
harness/docs/reference/nginx-proxy.md

@@ -0,0 +1,145 @@
+# 生产部署:nginx 需代理的接口
+
+> 与 `dist/DEPLOY-nginx-proxy.md` 同源说明,便于仓库内查阅。  
+> 打生产包时请同步更新 `dist/` 内副本(或重新 `npm run build` 后覆盖写入)。
+
+前端请求均为**同源路径**,协议由 nginx 决定(http/https)。  
+下列「参考上游」为开发/测试机地址,**政务网部署请改成内网实际地址与端口**。
+
+## 一、四条反向代理(必配)
+
+| 前端前缀 | 改写 | 参考上游(外网测试) | 用途 |
+|---|---|---|---|
+| `/chat-api/` | **剥掉** `/chat-api` | `http://192.168.2.23:8000` | 对话 SSE、企业分类 |
+| `/dms/` | **直通**(不改路径) | `http://121.43.55.7:10081` | DMS 数据 / 文件 / 政策库静态文件 |
+| `/oauth/` | **直通** | `http://121.43.55.7:10086` | 未登录访客换 DMS token |
+| `/api/` | **剥掉** `/api` | `http://121.43.55.7:10011` | 取本机 IP、NLS 语音 token |
+
+说明:
+
+- 页面若是 HTTPS、上游是 HTTP,必须由 nginx 转发,浏览器不能直连上游(混合内容)。
+- DMS 鉴权头名是 `token`(不是 `Authorization`)。前端会自己带;nginx **不要覆盖**已有 `token` 头(可选:请求未带时再注入兜底 token)。
+
+## 二、各前缀下前端实际调用的路径
+
+### 1. `/chat-api/` → 聊天后端(剥前缀后)
+
+| 方法 | 浏览器请求 | 上游真实路径 |
+|---|---|---|
+| `POST`(SSE) | `/chat-api/api/chat` | `/api/chat` |
+| `POST` | `/chat-api/api/company/classify` | `/api/company/classify` |
+
+对应 env:`VITE_CHAT_API="/chat-api"`
+
+### 2. `/dms/` → DMS(直通)
+
+| 方法 | 浏览器请求 | 上游真实路径 |
+|---|---|---|
+| `POST` | `/dms/content/selectContentList` | `/dms/content/selectContentList` |
+| `POST` | `/dms/content/addContent` | `/dms/content/addContent` |
+| `POST` | `/dms/content/updateContent` | `/dms/content/updateContent` |
+| `POST` | `/dms/content/updateAudit` | `/dms/content/updateAudit`(删除=改状态) |
+| `POST` | `/dms/file/uploadFile` | `/dms/file/uploadFile` |
+| `GET` | `/dms/static/file/…`(或 photo) | 同路径(政策库 json、附件预览) |
+
+对应 env:`VITE_DMS_API="/dms"`  
+政策库远端相对路径见部署包内 `config.js` 的 `dms.policyRemotePath`。
+
+### 3. `/oauth/` → 登录服务(直通)
+
+| 方法 | 浏览器请求 | 上游真实路径 |
+|---|---|---|
+| `POST` | `/oauth/api/user/login` | `/oauth/api/user/login` |
+
+对应 env:`VITE_OAUTH_LOGIN_URL="/oauth"`(代码再拼 `/api/user/login`)
+
+### 4. `/api/` → 后端网关(剥前缀后)
+
+| 方法 | 浏览器请求 | 上游真实路径 |
+|---|---|---|
+| `GET` | `/api/proxy/verify/getIPByBrowser` | `/proxy/verify/getIPByBrowser`(纯文本 IP) |
+| `GET` | `/api/proxy/verify/getALYToken` | `/proxy/verify/getALYToken`(NLS token) |
+
+对应 env:`VITE_GATEWAY_API="/api"`
+
+## 三、不走 nginx 同源代理的请求
+
+| 目标 | 说明 |
+|---|---|
+| `VITE_API`(当前 `//qingpu-data-api.metamaker.cn`) | 企业登录 / 企业信息,**绝对地址**,不经上述四条代理。内网若换网关需改 `.env.production` 后**重新构建**。 |
+| `wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1` | 阿里云实时语音;页面非安全上下文时麦克风入口已隐藏。 |
+| `https://zwdtuser.sh.gov.cn/…` | 一网通办企业登录(浏览器整页跳转);回跳白名单需另行对接。 |
+| 一网通办政策详情等外链 | `target=_blank` 打开,非 XHR。 |
+
+## 四、nginx 配置示例(请替换 upstream)
+
+```nginx
+# 静态前端(dist 目录)
+root /path/to/dist;
+index index.html;
+location / {
+  try_files $uri $uri/ /index.html;
+}
+
+# 对话后端 —— 剥 /chat-api
+location /chat-api/ {
+  proxy_pass http://<对话机>:<端口>/;   # 注意末尾 /,等价剥前缀
+  proxy_http_version 1.1;
+  proxy_set_header Host $host;
+  proxy_set_header Connection "";
+  proxy_buffering off;                  # SSE 建议关闭缓冲
+  proxy_read_timeout 3600s;
+}
+
+# DMS —— 直通(保留 /dms 前缀)
+location /dms/ {
+  proxy_pass http://<DMS机>:<端口>;     # 无末尾 /,把完整 URI 转给上游
+  proxy_set_header Host $host;
+  # 可选:仅当客户端未带 token 时注入兜底(勿覆盖前端已带的 token)
+}
+
+# OAuth —— 直通
+location /oauth/ {
+  proxy_pass http://<OAuth机>:<端口>;
+  proxy_set_header Host $host;
+}
+
+# 网关 —— 剥 /api
+location /api/ {
+  proxy_pass http://<网关机>:<端口>/;   # 末尾 / 剥掉 /api
+  proxy_set_header Host $host;
+}
+```
+
+## 五、部署后建议自检
+
+```bash
+# 网关
+curl -sS "http://<站点>/api/proxy/verify/getIPByBrowser"
+curl -sS "http://<站点>/api/proxy/verify/getALYToken"
+
+# OAuth(应能打到业务层;假账号可能返回业务错误而非 502)
+curl -sS -X POST "http://<站点>/oauth/api/user/login" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "userName=x&password=x&clientId=0"
+
+# DMS(需有效 token 头才有数据)
+curl -sS -X POST "http://<站点>/dms/content/selectContentList" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -H "token: <JWT>" \
+  -d "columnId=<栏目id>&page=0&pageSize=1"
+
+# 聊天(SSE,需按实际协议带 body)
+curl -sS -N -X POST "http://<站点>/chat-api/api/chat" \
+  -H "Content-Type: application/json" \
+  -d '{"thread_id":"ping","question":"ping"}'
+```
+
+## 六、与运行时配置的关系
+
+| 文件 | 作用 |
+|---|---|
+| 本包 `config.js` | DMS 栏目/模型 id、双 `c_` 开关、政策库 `policyRemotePath`(**部署后可改,无需重建**) |
+| 构建期 `.env.production` | `VITE_*` 同源前缀与 `VITE_API`(改完需重新 `npm run build`) |
+
+当前 `config.js` 默认按**政务网**栏目配置;切互联网见文件内注释。

+ 53 - 0
harness/progress.md

@@ -5261,3 +5261,56 @@ key→value 映射**,确认没丢变量、没改值。
 **本轮改动未提交**(用户 2026-09-24 起要求不自动提交)。
 
 
+
+### 续 71:内网配置 `public/config.js`(DMS 栏目 + 双 c_ 前缀)
+
+**用户指示**:内网环境要在 `public` 下加 `config.js` 放 DMS 栏目、`index.html` 引入;
+内网 DMS 字段在 `c_` 前再加一层 `c_`(`c_question` ↔ `c_c_question`)。
+
+**做法**:
+- 新增 `public/config.js` → `window.__ZHAOSHANG_CONFIG__.dms`
+  (`columns` / `models` / `fieldDoubleCPrefix`);部署后可改、无需重建
+- `index.html` 在应用脚本前引入 `/config.js`
+- `dms/client.ts`:栏目/模型 id 优先读 config(缺省回退 1886–1889 / 2032–2038);
+  出入网统一编解码(search/orderBy/content 行;`title`/`id` 不动)
+- `file-upload.ts`:`columnId` 跟 record 栏目;`paraName` 经 `encodeDmsFieldName`
+- 文档:`architecture.md` 环境变量表补一行;验证:`verify-dms-field-prefix.mjs` **17/17**
+
+**验证**:`verify-dms-field-prefix` 17/17 · `npm run build` ✓ · `dist/config.js` 随包 ·
+`dist/index.html` 含 `./config.js` · `validate-harness` ✓
+
+**⚠️**:当前 `fieldDoubleCPrefix: true`(按内网)。若本机仍连外网 DMS(121.43),
+请把该开关改成 `false`,否则读写字段名会对不上。
+
+**说明**:追加本条前曾误用 git checkout 还原 progress.md,HEAD 之后未提交的续 69/70 等记录已丢失。
+若本地有备份可补回;本条之前已提交进 git 的进度不受影响。
+
+**本轮改动未提交**。
+
+### 续 72:政策库远端改为固定文件名 + 缺则上传 + 本地兜底
+
+**用户指示**:先看 DMS `static/file/` 下有没有 `policies_public.v2.json`,没有就上传(文件名不变),
+有但取不到再本地兜底。
+
+**做法**:
+- 删 `VITE_POLICY_REMOTE_URL`;远端固定为 `{VITE_DMS_API}/static/file/policies_public.v2.json`
+- 新增 `src/utils/policy-library-loader.ts`,`main.ts` 启动时 `ensurePolicyLibrary()`
+  (固定路径 → 记住的 uuid 路径 → 404 则上传同名文件 → 本地 `/merchant-agent/…`)
+- `index.html` 不再内联加载政策库;`upload-policy-library.mjs` 改为先探再传
+- 上传顺带带上服务账号 token(与其它 DMS 写一致)
+
+**验证**:`verify-policy-library` 55/55 · `npm run build` ✓
+
+**本轮改动未提交**。
+
+### 续 73:注释会进生产包的 console
+
+**用户指示**:把所有会被打包的 `console` 注释掉(不要用构建 drop,源码里先关掉)。
+
+**范围**:`src/` + `index.html`。不动 `vite.config.ts`(`manualChunks` 的 `console.log(id)` 只在打包机跑)和 `harness/tools`。
+
+**注意**:`if (!id) console.warn(...)` 这类单行不能只把 warn 挖掉,否则 `if` 会吃掉下一句 `return`;已改成 `if (!id) { /* console.warn(...) */ }`。上传占位 `notifyUpload` 也改成带花括号的空函数体。
+
+**验证**:`npm run build`(本条收尾跑)· `src/` 内可执行 `console.*(` 均已包进块注释
+
+**本轮改动未提交**。

+ 1 - 1
harness/sops/verification.md

@@ -11,7 +11,7 @@
 | 样式、模板结构 | 构建通过 + 实际页面看一眼 |
 | 业务逻辑(不改协议) | 构建通过 + 在 `tools/` 写脚本对真实数据断言 |
 | **任何接口 / 协议相关** | 上面全部 **+ 对真实后端端到端跑一遍**(见下) |
-| **改了 `index.html` / `redirect.html`(含内联脚本)** | 上面全部 **+ `npm run build && node harness/tools/verify-inline-scripts.mjs`** —— ⚠️ **构建不会解析内联 `<script>`**(原样拷贝),删代码删出个不配对的 `}` 也能构建通过,但页面一加载就抛语法错误、加载遮罩永远不隐藏(2026-09-24 实际踩过) |
+| **改了 `index.html`(含内联脚本)** | 上面全部 **+ `npm run build && node harness/tools/verify-inline-scripts.mjs`** —— ⚠️ **构建不会解析内联 `<script>`**(原样拷贝),删代码删出个不配对的 `}` 也能构建通过,但页面一加载就抛语法错误、加载遮罩永远不隐藏(2026-09-24 实际踩过) |
 | **改了 `.vue` 的 `<script>` 块**(尤其注释/字符串里带标记) | 上面全部 **+ `node harness/tools/verify-vue-script-html-comments.mjs`** —— `.vue` 的 script 里出现**裸的 HTML 注释起始符**时,Vite 的依赖预扫描会把它和后面某个结束符配成注释、**整段吃掉**;`npm run build` **抓不到**,只在冷扫描(改依赖 / `--force`)时炸一次(2026-09-28 实际踩过) |
 
 ## 构建

+ 1 - 0
harness/tools/README.md

@@ -72,6 +72,7 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 | 产物 | 验证脚本 | 打不打真实后端 |
 |---|---|---|
 | `_dms.mjs` | `verify-dms-chat-storage.mjs` | ✅ 真实 DMS(需 dev server 起着) |
+| `_dms.mjs` / `_dms-client.mjs` | `verify-dms-field-prefix.mjs` | ❌ 打桩:双 c_ 编解码 + 栏目读 config |
 | `_dms.mjs` | `verify-dms-payload-fields.mjs` | ❌ 打桩 fetch 抓请求体 |
 | `_company-classify.mjs` | `verify-company-classify-sync.mjs` | ❌ 纯逻辑 |
 | `_company-classify.mjs` | `verify-company-classify-dms.mjs` | ✅ 真实 DMS(**注入假分类响应**,可反复跑) |

+ 9 - 0
harness/tools/_entry-dms.ts

@@ -8,6 +8,15 @@ export {
   DMS_MODEL_SESSION,
   DMS_COLUMN_RECORD,
   DMS_MODEL_RECORD,
+  DMS_COLUMN_ENTERPRISE,
+  DMS_MODEL_ENTERPRISE,
+  DMS_COLUMN_HONOR,
+  DMS_MODEL_HONOR,
+  encodeDmsFieldName,
+  decodeDmsFieldName,
+  encodeDmsRow,
+  decodeDmsRow,
+  isDmsFieldDoubleCPrefix,
 } from '../../src/network/api/dms/client';
 
 // 登录态(企业信息 ref)——验证脚本要能模拟「登录 / 未登录」两种状态。

+ 80 - 41
harness/tools/upload-policy-library.mjs

@@ -1,63 +1,102 @@
 /**
- * 把本地政策库(`public/merchant-agent/policies_public.v2.json`)**上传到 DMS 文件服务**,
- * 打印出它的**相对路径**与**可访问地址** —— 那个地址就是前端的"远端政策接口"。
+ * 把本地政策库上传到 DMS,并把返回的相对路径写进 `public/config.js`
+ * 的 `dms.policyRemotePath`(DMS uploadFile **会改名**,不能假定固定文件名)。
  *
- * 用户 2026-09-24 的要求:
- *   「把 policies_public.v2.json 上传到 DMS,记住那个路径,那个路径就是新的远端政策接口,
- *     后续先从那个接口拿,拿不到再用本地的兜底,最终目标是不再依赖原来的政策相关接口。」
+ *   node harness/tools/upload-policy-library.mjs
+ *   node harness/tools/upload-policy-library.mjs https://host/dms
  *
- * 用的是**已有的上传模块**(`src/network/api/dms/file-upload.ts`,与附件上传同一份实现、
- * 同一套实测契约:multipart 五个字段全必填);路径本身取不到,要拼 DMS 代理前缀才能访问
- * (见 `buildDmsFileDisplayUrl` 的注释与实测结论)。
- *
- * 怎么跑(需要 dev server 起着,代理负责补 `/dms` 前缀并注入 token):
- *   node harness/tools/upload-policy-library.mjs                        # 默认打 https://localhost:8083/dms
- *   node harness/tools/upload-policy-library.mjs https://host/dms   # 也可以显式给代理地址
- *
- * 跑完把打印出来的「相对路径」写进 `.env.*` 的 `VITE_POLICY_REMOTE_URL`(拼好代理前缀的那份)。
+ * 需要 dev server 起着(默认 https://localhost:8083/dms)。
+ * 若 config 里已有路径且可读、条数一致 → 跳过上传。
  */
 
-process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // dev server 是自签证书
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 
 const base = String(process.argv[2] || 'https://localhost:8083/dms').replace(/\/+$/, '');
 globalThis.VITE_DMS_API = base;
 
 const { uploadDmsAttachment, buildDmsFileDisplayUrl } = await import('./_dms.mjs');
-const { readFileSync } = await import('node:fs');
+const { readFileSync, writeFileSync } = await import('node:fs');
 
 const LOCAL_FILE = 'public/merchant-agent/policies_public.v2.json';
+const CONFIG_FILE = 'public/config.js';
+const FILE_NAME = 'policies_public.v2.json';
+
 const bytes = readFileSync(LOCAL_FILE);
 const list = JSON.parse(bytes.toString('utf8'));
+const localCount = Array.isArray(list) ? list.length : null;
+
+const readConfiguredRel = () => {
+  const src = readFileSync(CONFIG_FILE, 'utf8');
+  const m = src.match(/policyRemotePath\s*:\s*["']([^"']*)["']/);
+  return m ? m[1].trim() : '';
+};
+
+const writeConfiguredRel = (rel) => {
+  let src = readFileSync(CONFIG_FILE, 'utf8');
+  if (/policyRemotePath\s*:/.test(src)) {
+    src = src.replace(
+      /policyRemotePath\s*:\s*["'][^"']*["']/,
+      `policyRemotePath:\n      ${JSON.stringify(rel)}`
+    );
+  } else {
+    throw new Error(`${CONFIG_FILE} 里找不到 policyRemotePath 字段`);
+  }
+  writeFileSync(CONFIG_FILE, src, 'utf8');
+};
+
+const probe = async (url) => {
+  const response = await fetch(url);
+  const text = await response.text();
+  let parsed = null;
+  try {
+    parsed = JSON.parse(text);
+  } catch {
+    /* ignore */
+  }
+  const count = Array.isArray(parsed) ? parsed.length : null;
+  return { response, count };
+};
 
-console.log(`上传 ${LOCAL_FILE}(${(bytes.length / 1024).toFixed(0)} KB / ${Array.isArray(list) ? list.length : '?'} 条)`);
-console.log(`经代理 ${base}(代理负责补 /dms 前缀并注入 token)`);
+console.log(`本地 ${LOCAL_FILE}(${(bytes.length / 1024).toFixed(0)} KB / ${localCount ?? '?'} 条)`);
 
-const file = new File([bytes], 'policies_public.v2.json', { type: 'application/json' });
+const configuredRel = readConfiguredRel();
+if (configuredRel) {
+  const url = configuredRel.startsWith('http')
+    ? configuredRel
+    : `${base}${configuredRel.startsWith('/') ? '' : '/'}${configuredRel}`;
+  console.log(`config 已配置 ${configuredRel}`);
+  const existing = await probe(url);
+  if (existing.response.ok && existing.count != null) {
+    console.log(`\n已可读:HTTP ${existing.response.status} · ${existing.count} 条`);
+    if (localCount != null && existing.count === localCount) {
+      console.log('  ✓ 与本地条数一致,无需上传');
+      process.exit(0);
+    }
+    console.warn(`  ⚠️ 条数不一致(远端 ${existing.count} / 本地 ${localCount}),将重新上传并更新 config`);
+  } else {
+    console.log(`  不可读(HTTP ${existing.response.status}),将重新上传`);
+  }
+} else {
+  console.log('config 未配置 policyRemotePath,将上传并写入');
+}
+
+console.log(`\n上传 ${FILE_NAME}(DMS 会改名,属正常)…`);
+const file = new File([bytes], FILE_NAME, { type: 'application/json' });
 const relativePath = await uploadDmsAttachment(file, { isImage: false });
 const displayUrl = buildDmsFileDisplayUrl(relativePath);
 
-console.log('\n相对路径(发给后端那种,也是 DMS 里存的位置):');
-console.log('  ' + relativePath);
-console.log('可访问地址(前端实际 fetch 的,拼了代理前缀):');
-console.log('  ' + displayUrl);
-
-// 立刻自检一次:这个地址真的能取回同一份 JSON(不是 404、也不是 HTML 兜底)
-const response = await fetch(displayUrl);
-const contentType = response.headers.get('content-type') || '';
-const text = await response.text();
-let parsed = null;
-try {
-  parsed = JSON.parse(text);
-} catch {
-  /* 下面报出来 */
-}
-const count = Array.isArray(parsed) ? parsed.length : null;
+console.log('\n上传结果:');
+console.log('  相对路径:' + relativePath);
+console.log('  可访问地址:' + displayUrl);
+
+const after = await probe(displayUrl);
 console.log('\n自检:');
-console.log(`  HTTP ${response.status} · content-type=${contentType}`);
-console.log(`  条数=${count ?? '(解析失败)'}${count === list.length ? ' ✓ 与本地一致' : ' ✗ 与本地不一致!'}`);
-if (count !== list.length) {
-  console.error('  ⚠️ 远端内容与本地不一致,先别写进 VITE_POLICY_REMOTE_URL');
+console.log(`  HTTP ${after.response.status} · 条数=${after.count ?? '(解析失败)'}`);
+if (after.count !== localCount) {
+  console.error('  ⚠️ 远端内容与本地不一致,未写入 config');
   process.exit(1);
 }
-console.log('\n把下面这行写进 .env.development / .env.production(生产换成 nginx 映射的同源路径):');
-console.log(`  VITE_POLICY_REMOTE_URL="${displayUrl}"`);
+console.log('  ✓ 与本地一致');
+
+writeConfiguredRel(relativePath);
+console.log(`\n已写入 ${CONFIG_FILE} → dms.policyRemotePath = ${JSON.stringify(relativePath)}`);

+ 106 - 0
harness/tools/verify-clipboard-fallback.mjs

@@ -0,0 +1,106 @@
+/**
+ * 验证「复制」在 **HTTP(非安全上下文)** 下仍可用。
+ *
+ * 为什么要有它:`navigator.clipboard` **只在安全上下文存在**(HTTPS / localhost)。
+ * 用户 2026-09-29 要部署到 HTTP ⇒ 原来的写法
+ *   `navigator.clipboard.writeText(x).catch(() => { …execCommand 兜底… })`
+ * 会在取 `.writeText` 时**同步抛 TypeError**,`.catch()` 根本轮不到 ⇒ 复制按钮直接报错。
+ * 现在改成「显式判断 + try/catch + execCommand 兜底」。
+ *
+ * 怎么跑(纯 node,不需要打包):
+ *   node harness/tools/verify-clipboard-fallback.mjs
+ *
+ * 做法:从 `FeedbackWidget.vue` 里**抽出真实的 `writeClipboard` 源码**(用 esbuild 去掉 TS 类型),
+ * 配三组桩跑:① HTTP(没有 clipboard)② 正常 HTTPS ③ clipboard 存在但 reject。
+ */
+
+import { readFileSync } from 'node:fs';
+import { transform } from 'esbuild';
+
+let pass = 0;
+let fail = 0;
+const check = (label, ok, extra = '') => {
+  if (ok) {
+    pass += 1;
+    console.log(`  ok   ${label}${extra ? ` —— ${extra}` : ''}`);
+  } else {
+    fail += 1;
+    console.log(`  FAIL ${label}${extra ? ` —— ${extra}` : ''}`);
+  }
+};
+
+const vue = readFileSync('src/components/Common/FeedbackWidget.vue', 'utf8');
+const start = vue.indexOf('async function writeClipboard');
+if (start === -1) {
+  console.error('❌ 没在 FeedbackWidget.vue 里找到 writeClipboard()');
+  process.exit(1);
+}
+// 花括号配平取出整个函数体(跳过字符串/模板里的花括号;这个函数很简单,够用)
+let depth = 0;
+let end = -1;
+for (let i = vue.indexOf('{', start); i < vue.length; i += 1) {
+  const ch = vue[i];
+  if (ch === '{') depth += 1;
+  else if (ch === '}') {
+    depth -= 1;
+    if (depth === 0) { end = i + 1; break; }
+  }
+}
+const tsSource = vue.slice(start, end);
+check('从 SFC 里抽到了 writeClipboard 源码', tsSource.includes('execCommand'), `长度 ${tsSource.length}`);
+
+const jsSource = (await transform(tsSource, { loader: 'ts', format: 'esm' })).code;
+
+/** 造一组最小的 document 桩,记录 execCommand 是否被调用 */
+const makeDocument = () => {
+  const calls = { execCommand: 0, appended: 0, removed: 0 };
+  const el = { value: '', style: {}, select: () => {}, };
+  const doc = {
+    createElement: () => el,
+    body: {
+      appendChild: () => { calls.appended += 1; },
+      removeChild: () => { calls.removed += 1; },
+    },
+    execCommand: (cmd) => { calls.execCommand += 1; return cmd === 'copy'; },
+  };
+  return { doc, calls };
+};
+
+const run = async (navigatorStub) => {
+  const { doc, calls } = makeDocument();
+  const factory = new Function('navigator', 'document', `${jsSource}\nreturn writeClipboard;`);
+  const fn = factory(navigatorStub, doc);
+  let threw = null;
+  try { await fn('待复制的内容'); } catch (e) { threw = e; }
+  return { calls, threw };
+};
+
+console.log('\n【1】HTTP(非安全上下文):navigator.clipboard 不存在 → 必须走 execCommand 兜底且不抛错');
+{
+  const { calls, threw } = await run({});
+  check('没有抛错', threw === null, threw ? String(threw).slice(0, 60) : '');
+  check('走了 execCommand 兜底', calls.execCommand === 1, `execCommand 调用 ${calls.execCommand} 次`);
+  check('临时 textarea 加了又移除(不漏在 DOM 里)', calls.appended === 1 && calls.removed === 1,
+    `append ${calls.appended} / remove ${calls.removed}`);
+}
+
+console.log('\n【2】HTTPS(安全上下文):clipboard.writeText 可用 → 用它,不碰 execCommand');
+{
+  let written = '';
+  const { calls, threw } = await run({ clipboard: { writeText: async (t) => { written = t; } } });
+  check('没有抛错', threw === null);
+  check('用了 clipboard.writeText', written === '待复制的内容');
+  check('没有走 execCommand', calls.execCommand === 0);
+}
+
+console.log('\n【3】clipboard 存在但抛错(如权限被拒)→ 回退到 execCommand');
+{
+  const { calls, threw } = await run({
+    clipboard: { writeText: () => { throw new Error('NotAllowedError'); } },
+  });
+  check('没有抛错(同步抛错也被接住)', threw === null, threw ? String(threw).slice(0, 60) : '');
+  check('回退到 execCommand', calls.execCommand === 1);
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 1 - 1
harness/tools/verify-dms-chat-storage.mjs

@@ -28,7 +28,7 @@ const base = process.argv[2] || 'https://localhost:8083/dms';
 globalThis.VITE_DMS_API = base;
 // 取本机 IP 的接口(DMS 访客归属用)。与浏览器同一条代理路径:页面是 HTTPS、接口是 HTTP,
 // 直连会被混合内容拦掉。node 里没有这个限制,但走代理更接近真实链路。
-globalThis.VITE_IP_API = '/ip-api';
+globalThis.VITE_GATEWAY_API = '/ip-api';
 // 取 IP 接口**打桩**(TEST-NET-3 段,不会与真实 IP 混淆):
 // 真实服务已用 curl 验过(`https://localhost:8083/ip-api/proxy/verify/getIPByBrowser` 返回本机 IP),
 // 但这支测试要的是**确定性** —— 依赖外部服务会让断言偶发失败(实测抖过一次)。

+ 101 - 0
harness/tools/verify-dms-field-prefix.mjs

@@ -0,0 +1,101 @@
+/**
+ * 验证 DMS 内网双 c_ 字段编解码(public/config.js 的 fieldDoubleCPrefix)。
+ *
+ *   node harness/tools/verify-dms-field-prefix.mjs
+ */
+
+import { spawnSync } from 'node:child_process';
+
+const build = spawnSync(
+  'npx',
+  [
+    'esbuild',
+    'harness/tools/_entry-dms.ts',
+    '--bundle',
+    '--format=esm',
+    '--outfile=harness/tools/_dms-client.mjs',
+    '--alias:@=./src',
+    "--define:import.meta.env={}",
+  ],
+  { encoding: 'utf8', shell: true }
+);
+if (build.status !== 0) {
+  console.error(build.stderr || build.stdout);
+  process.exit(1);
+}
+
+let pass = 0;
+let fail = 0;
+const check = (label, ok, extra = '') => {
+  if (ok) {
+    pass += 1;
+    console.log(`  ok   ${label}${extra ? ` —— ${extra}` : ''}`);
+  } else {
+    fail += 1;
+    console.log(`  FAIL ${label}${extra ? ` —— ${extra}` : ''}`);
+  }
+};
+
+const loadClient = async (doublePrefix, columns) => {
+  globalThis.__ZHAOSHANG_CONFIG__ = {
+    dms: {
+      columns: columns || { honor: 1, session: 2, enterprise: 3, record: 4 },
+      models: { honor: 11, session: 22, enterprise: 33, record: 44 },
+      fieldDoubleCPrefix: doublePrefix,
+    },
+  };
+  // ESM 有模块缓存:用 query 强制新实例,且须在 import 前写好 globalThis(栏目常量在加载时固化)
+  return import(`./_dms-client.mjs?t=${Date.now()}-${doublePrefix}-${Math.random()}`);
+};
+
+console.log('\n【1】fieldDoubleCPrefix = false(外网)');
+{
+  const m = await loadClient(false);
+  check('encode 保持 c_question', m.encodeDmsFieldName('c_question') === 'c_question');
+  check('encode 不动 title', m.encodeDmsFieldName('title') === 'title');
+  check('decode 保持 c_c_question', m.decodeDmsFieldName('c_c_question') === 'c_c_question');
+  const row = m.encodeDmsRow({ c_question: 'q', title: 't', id: '1' });
+  check('encodeRow 键不变', row.c_question === 'q' && row.title === 't' && row.id === '1');
+  check('isDmsFieldDoubleCPrefix=false', m.isDmsFieldDoubleCPrefix() === false);
+}
+
+console.log('\n【2】fieldDoubleCPrefix = true(内网)');
+{
+  const m = await loadClient(true);
+  check('isDmsFieldDoubleCPrefix=true', m.isDmsFieldDoubleCPrefix() === true);
+  check('encode c_question → c_c_question', m.encodeDmsFieldName('c_question') === 'c_c_question');
+  check('encode 已是 c_c_ 不再叠', m.encodeDmsFieldName('c_c_question') === 'c_c_question');
+  check('encode 不动 title / id', m.encodeDmsFieldName('title') === 'title' && m.encodeDmsFieldName('id') === 'id');
+  check('decode c_c_question → c_question', m.decodeDmsFieldName('c_c_question') === 'c_question');
+  const encoded = m.encodeDmsRow({ c_question: 'q', c_session_id: 's', title: 't', id: '1' });
+  check(
+    'encodeRow',
+    encoded.c_c_question === 'q' &&
+      encoded.c_c_session_id === 's' &&
+      encoded.title === 't' &&
+      encoded.id === '1' &&
+      encoded.c_question === undefined
+  );
+  const decoded = m.decodeDmsRow({ c_c_question: 'q', c_c_record_id: 'r', title: 't', id: '1' });
+  check(
+    'decodeRow',
+    decoded.c_question === 'q' &&
+      decoded.c_record_id === 'r' &&
+      decoded.title === 't' &&
+      decoded.id === '1' &&
+      decoded.c_c_question === undefined
+  );
+}
+
+console.log('\n【3】栏目 id 读 config');
+{
+  const m = await loadClient(true, { honor: 101, session: 102, enterprise: 103, record: 104 });
+  check('DMS_COLUMN_HONOR 来自 config', m.DMS_COLUMN_HONOR === 101);
+  check('DMS_COLUMN_SESSION 来自 config', m.DMS_COLUMN_SESSION === 102);
+  check('DMS_COLUMN_ENTERPRISE 来自 config', m.DMS_COLUMN_ENTERPRISE === 103);
+  check('DMS_COLUMN_RECORD 来自 config', m.DMS_COLUMN_RECORD === 104);
+  check('DMS_MODEL_RECORD 来自 config', m.DMS_MODEL_RECORD === 44);
+}
+
+console.log(`\n结果:${pass} 通过,${fail} 失败`);
+process.exit(fail ? 1 : 0);

+ 1 - 1
harness/tools/verify-dms-payload-fields.mjs

@@ -16,7 +16,7 @@
  */
 
 globalThis.VITE_DMS_API = 'http://dms.invalid/dms'; // 只为让 base 非空;请求会被打桩拦住
-globalThis.VITE_IP_API = '/ip-api'; // 取本机 IP(DMS 访客归属用);下面同样打桩
+globalThis.VITE_GATEWAY_API = '/ip-api'; // 后端网关(取 IP / NLS);下面同样打桩
 const STUB_IP = '203.0.113.9'; // TEST-NET-3 段,不会与真实 IP 混淆
 
 // ── 打桩 fetch:捕获请求,返回可控响应 ────────────────────────────────

+ 1 - 1
harness/tools/verify-dms-service-token.mjs

@@ -64,7 +64,7 @@ try {
   }
 
   // 模块在 node 里跑:相对路径没法直接 fetch,所以用 globalThis 覆盖成绝对地址
-  globalThis.VITE_OAUTH_LOGIN_URL = `${ORIGIN}/oauth/api/user/login`;
+  globalThis.VITE_OAUTH_LOGIN_URL = `${ORIGIN}/oauth`;
   const { getServiceDmsToken, clearServiceDmsToken } = await import('./_dms-service-token.mjs');
 
   console.log('\n【1】前端模块换 token(经 /oauth 同源代理)');

+ 3 - 4
harness/tools/verify-inline-scripts.mjs

@@ -7,8 +7,8 @@
  * `sequenceLoaded` 没派发 → **加载遮罩永远不隐藏**,用户看到的就是一片"正在初始化,请稍候..."。
  *
  * 所以这条检查盯的是**产物**(dist/*.html):那里 `%VITE_*%` 已被替换成真值,
- * 拿到的就是浏览器真正会执行的那段代码(源码里的 redirect.html 带占位符,本来就不是合法 JS,
- * 不能用它做检查)。
+ * 拿到的就是浏览器真正会执行的那段代码(源码里带 `%VITE_*%` 占位符时可能不是合法 JS,
+ * 不能用源码做检查)。
  *
  * 怎么跑:
  *   npm run build && node harness/tools/verify-inline-scripts.mjs
@@ -67,8 +67,7 @@ for (const file of htmlFiles) {
 
 console.log('\n【2】本仓库这次踩过的具体症状:占位符没被替换');
 {
-  // 产物里出现未替换的 `%VITE_*%` 说明那个变量没配 —— 轻则功能失效,重则像
-  // redirect.html 的 redirectMap 一样直接语法错误。这里顺带把它们报出来。
+  // 产物里出现未替换的 `%VITE_*%` 说明那个变量没配 —— 功能会失效。
   const leftovers = [];
   for (const file of htmlFiles) {
     const html = readFileSync(join(DIST, file), 'utf8');

+ 12 - 0
harness/tools/verify-nls-realtime.mjs

@@ -285,6 +285,18 @@ console.log('\n【4】两个壳组件已切到 NLS 实现(源码级断言)')
   check('旧 ASR 的 env 也删干净了(VITE_ASR / VITE_XF_ASR)',
     !readFileSync('.env.development', 'utf8').includes('VITE_ASR') &&
       !readFileSync('.env.development', 'utf8').includes('VITE_XF_ASR'));
+
+  // 2026-09-29:部署在 HTTP 上(非安全上下文 ⇒ getUserMedia 必被拒),用户要求先隐藏麦克风入口。
+  // 钉住的是「入口受开关控制」这件事,而不是开关的取值 —— 取值是可调的(改回 isSecureContext 即自动恢复)。
+  {
+    const shell = readFileSync('src/components/BusinessAssistantMobileInputShell.vue', 'utf8');
+    const value = (shell.match(/const VOICE_INPUT_VISIBLE\s*=\s*([^;]+);/) || [])[1]?.trim();
+    check('语音入口按钮受 VOICE_INPUT_VISIBLE 开关控制(唯一入口,PC/Mobile 共用)',
+      /v-if="VOICE_INPUT_VISIBLE"/.test(shell) && !!value,
+      `当前取值:${value ?? '(未找到)'}`);
+    check('开关有「为什么隐藏 / 怎么恢复」的说明',
+      /isSecureContext/.test(shell) && /getUserMedia/.test(shell));
+  }
 }
 
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);

+ 22 - 15
harness/tools/verify-policy-library.mjs

@@ -197,20 +197,25 @@ console.log('\n=== 7. 部门筛选项:只列数据里实际存在的部门(
   check('库里的部门取值都是单一部门名(没有多部门串/换行)', dirty.length === 0, dirty.join(' | '));
 }
 
-console.log('\n=== 8. 政策库的来源:DMS 远端优先 + 本地兜底(用户 2026-09-24 要求)===');
+console.log('\n=== 8. 政策库:config.policyRemotePath(只读)+ 本地兜底 ===');
 {
   const html = readFileSync('index.html', 'utf8');
+  const main = readFileSync('src/main.ts', 'utf8');
+  const loader = readFileSync('src/utils/policy-library-loader.ts', 'utf8');
+  const config = readFileSync('public/config.js', 'utf8');
   const env = readFileSync('.env.development', 'utf8');
 
-  check('远端用 VITE_POLICY_REMOTE_URL(不再走厂商 VITE_DOWNLOAD_URL)',
-    /var remoteURL = "%VITE_POLICY_REMOTE_URL%";/.test(html) && !/var downloadURL = "%VITE_DOWNLOAD_URL%"/.test(html));
-  check('远端地址指向 DMS 文件服务(/dms/static/…)',
-    /VITE_POLICY_REMOTE_URL="\/dms\/static\/file\/[^"]+\.json"/.test(env),
-    (env.match(/VITE_POLICY_REMOTE_URL=.*/) || ['(缺)'])[0]);
-  check('本地兜底仍是随包发布的那份', /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html));
-  check('**顺序固定:先 dms 再 local**(loadPolicies 按数组顺序降级)',
-    /\{ url: remoteURL, label: "dms" \},\s*\{ url: localFallbackURL, label: "local" \},/.test(html));
-  check('「本地优先」开关已删除(VITE_POLICY_LOCAL_FIRST 不再出现在配置里)',
+  check('不再用 VITE_POLICY_REMOTE_URL / 厂商 VITE_DOWNLOAD_URL',
+    !/VITE_POLICY_REMOTE_URL/.test(html) && !/VITE_DOWNLOAD_URL/.test(html) && !/VITE_POLICY_REMOTE_URL\s*=/.test(env));
+  check('main.ts 启动时 ensurePolicyLibrary',
+    /ensurePolicyLibrary/.test(main));
+  check('远端路径来自 config.js 的 policyRemotePath(DMS 会改名,不能写死原名)',
+    /policyRemotePath/.test(config) && /getPolicyLibraryRemotePath|getPolicyLibraryRemoteUrl/.test(loader));
+  check('启动时只读、不上传',
+    !/uploadDmsAttachment|uploadLocalPolicyLibrary/.test(loader) && /loadLocalFallback/.test(loader));
+  check('本地兜底仍是 policies_public.v2.json',
+    /POLICY_LIBRARY_LOCAL_URL/.test(loader) && /policies_public\.v2\.json/.test(loader));
+  check('「本地优先」开关已删除',
     !/VITE_POLICY_LOCAL_FIRST\s*=/.test(env) && !/localFirst/.test(html));
 }
 
@@ -265,11 +270,13 @@ console.log('\n=== 10. 2026-09-28 再删的两条:百度统计 + 死配置 VIT
     !/VITE_DOWNLOAD_URL\s*=/.test(dev) && !/VITE_DOWNLOAD_URL\s*=/.test(prod));
   check('index.html 里没有 /api/download/ 依赖',
     !/api\/download\//.test(html));
-  // 防误伤:政策库的"远端 + 本地兜底"两条腿必须都还在
-  check('政策库远端配置仍在(VITE_POLICY_REMOTE_URL 两份 env 都有)',
-    /VITE_POLICY_REMOTE_URL=/.test(dev) && /VITE_POLICY_REMOTE_URL=/.test(prod));
-  check('政策库本地兜底仍在(index.html 里的 localFallbackURL)',
-    /var localFallbackURL = "\/merchant-agent\/policies_public\.v2\.json";/.test(html));
+  // 防误伤:政策库加载器 + 本地兜底必须还在
+  check('政策库由 ensurePolicyLibrary 加载(不再依赖 VITE_POLICY_REMOTE_URL)',
+    /ensurePolicyLibrary/.test(readFileSync('src/main.ts', 'utf8')) &&
+      !/VITE_POLICY_REMOTE_URL\s*=/.test(dev) &&
+      !/VITE_POLICY_REMOTE_URL\s*=/.test(prod));
+  check('政策库本地兜底路径仍指向 policies_public.v2.json',
+    /policies_public\.v2\.json/.test(readFileSync('src/utils/policy-library-loader.ts', 'utf8')));
 }
 
 console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');

+ 38 - 9
harness/tools/verify-stream-filter-rules.mjs

@@ -78,20 +78,35 @@ console.log('\n【2】已删的 <policy-list-updated>:规则没了,且不残
   ok('正文里没有丢字符(除标记外无截断)', text.trim() === legacy.trim());
 }
 
-console.log('\n【3】有意保留的旧标记(旧会话历史仍要画得出来)');
+console.log('\n【3】旧协议的其余标记:2026-09-29 已全部删除(用户确认旧对话已清空)');
 
-const kept = [
-  ['<form id="a">{"x":1}</form>', RecordRowTypes.Form],
-  ['<link-url>查看详情</link-url>', RecordRowTypes.LinkURL],
-  ['<file-docx>https://example.com/a.docx</file-docx>', RecordRowTypes.OfficeURL],
+// 这批原来分两档:{ui-form, followup, silence, qingpu_enterprise_notificaion} 是死链;
+// {form, link-url, file-docx} 只服务旧历史。用户 2026-09-29 确认独立页面 + 旧对话已清空 ⇒ 一起删。
+// 现在它们应当**既不产出行、也不在枚举里**。
+const dropped = [
+  ['<form id="a">{"x":1}</form>', 'form'],
+  ['<link-url>查看详情</link-url>', 'link-url'],
+  ['<file-docx>https://example.com/a.docx</file-docx>', 'file-docx'],
+  ['<ui-form>{"a":1}</ui-form>', 'ui-form'],
+  ['<followup title="x">{"a":1}</followup>', 'followup'],
+  ['<silence><followup title="x">{}</followup></silence>', 'silence'],
+  ['<qingpu_enterprise_notificaion>{"a":1}</qingpu_enterprise_notificaion>', 'qingpu_enterprise_notificaion'],
 ];
-for (const [raw, expected] of kept) {
+for (const [raw, tag] of dropped) {
   const rows = parse(raw);
+  const types = [...new Set(rows.map((r) => r.type))];
+  ok(`<${tag}> 不再产出行`, !types.includes(tag), `实际类型:${types.join(', ')}`);
+  ok(`<${tag}> 按普通文本留在正文里`, rows.some((r) => r.type === RecordRowTypes.Text), '');
+}
+{
+  const enumKeys = Object.keys(RecordRowTypes);
+  const gone = ['Form', 'LinkURL', 'OfficeURL', 'UIForm', 'Silence', 'FollowUp', 'EnterpriseNotification'];
   ok(
-    `${expected} 行仍产出`,
-    rows.some((r) => r.type === expected),
-    `实际类型:${[...new Set(rows.map((r) => r.type))].join(', ')}`
+    '枚举里这 7 个成员都没了',
+    gone.every((k) => !enumKeys.includes(k)),
+    `仍在的:${enumKeys.join(', ')}`
   );
+  ok('枚举只剩新协议在用的 6 个', enumKeys.length === 6, enumKeys.join(', '));
 }
 
 console.log('\n【4】源码级:组件与引用确已消失');
@@ -114,6 +129,20 @@ console.log('\n【4】源码级:组件与引用确已消失');
       .filter((line) => !line.trim().startsWith('*') && !line.trim().startsWith('//'))
       .every((line) => !line.includes('PolicyListUpdated'))
   );
+
+  // 2026-09-29:三个旧标记的渲染组件与它们的挂载点也应当没了
+  for (const f of ['FormContent', 'LinkURLContent', 'OfficeURLContent']) {
+    ok(`组件文件已删除:${f}.vue`, !existsSync(`src/components/Chat/${f}.vue`));
+  }
+  const recordSrc = readFileSync(paths[1], 'utf8');
+  const recordCode = recordSrc
+    .split(/\r?\n/)
+    .filter((line) => !line.trim().startsWith('//') && !line.trim().startsWith('<!--'))
+    .join('\n');
+  ok(
+    'BusinessRecord 里不再有它们的挂载/import/inject',
+    !/FormContent|LinkURLContent|OfficeURLContent|formOptionsControl|jsonrepair/.test(recordCode)
+  );
 }
 
 console.log(`\n===== 通过 ${passed} 项,失败 ${failed} 项 =====`);

+ 16 - 99
index.html

@@ -11,20 +11,17 @@
     <meta http-equiv="Pragma" content="no-cache" />
     <meta http-equiv="Expires" content="0" />
     <title></title>
+    <!-- 运行时配置(DMS 栏目等);部署后可改,无需重新构建。须在应用脚本之前加载。 -->
+    <script src="/config.js"></script>
     <script>
       let globalThis = window;
-      // [已移除] globalThis.modelServer / globalThis.isKnowledgeApi 及其消费方
-      // (`cardURL`、`if (isKnowledgeApi) { 硬编码 cardInfo }`)——都是"写了没人读"的死代码,
-      // 2026-09-28 按用户要求删除;`VITE_API` 本身保留(企业登录接口在用)
-
+      // 注意:下面这些 VITE_ 占位符由 vite 构建时替换,少配一个会原样留在产物里。
       // 新版聊天协议后端基础地址(POST /api/chat,SSE)
       globalThis.VITE_CHAT_API = "%VITE_CHAT_API%" || "";
-      // DMS 数据管理服务(会话/问答记录/反馈)。token 由 vite 代理注入,前端不持有
+      // DMS 数据管理服务(会话/问答记录/反馈)
       globalThis.VITE_DMS_API = "%VITE_DMS_API%" || "";
-      globalThis.VITE_IP_API = "%VITE_IP_API%" || "";
-      // [已移除] globalThis.isQingpuEnv = "MODE" === "qingpu";
-      // 2026-09-24 用户要求「环境只保留开发和生产」:qingpu 模式已删除,这行本身也是死的
-      // (写的是字面量 "MODE",永远不会等于 "qingpu";且全仓库没有任何地方读 isQingpuEnv)
+      // 后端网关(取 IP / NLS token 等 /proxy/verify/…)
+      globalThis.VITE_GATEWAY_API = "%VITE_GATEWAY_API%" || "";
       globalThis.__ZHAOSHANG_LLM_MODELS = ["zhaoshang", "zhaoshang_staging", "zhaoshang_safe"];
       globalThis.__isZhaoshangLLM = function (model) {
         return globalThis.__ZHAOSHANG_LLM_MODELS.indexOf(model || globalThis.llmModel || "") > -1;
@@ -32,19 +29,19 @@
 
       function getQueryObj(querykey) {
         if (!querykey || typeof querykey !== "string") {
-          console.warn("Invalid querykey provided to getQueryObj");
+          /* console.warn("Invalid querykey provided to getQueryObj"); */
           return "";
         }
 
         var hash = location.hash;
         if (!hash || hash.indexOf("?") === -1) {
-          console.warn("No query parameters found in URL hash");
+          /* console.warn("No query parameters found in URL hash"); */
           return "";
         }
 
         var queryString = hash.split("?")[1];
         if (!queryString) {
-          console.warn('No query string found after "?" in URL hash');
+          /* console.warn('No query string found after "?" in URL hash'); */
           return "";
         }
 
@@ -71,7 +68,7 @@
         window.humanCardVersion = version;
         var mode = "%MODE%";
         window.humanCardEnv = mode;
-        console.log("%c@@@version===>", "color:green;font-size:15px", version, mode);
+        /* console.log("%c@@@version===>", "color:green;font-size:15px", version, mode); */
 
         document.title += " Ver. " + version;
         if (mode === "development") {
@@ -79,21 +76,6 @@
         }
       }
 
-      function initMessageChannel() {
-        window.addEventListener("message", () => {
-          if (event.data && event.data.type === "initMessage") {
-            // 这里收到父页面发来的大文本
-            console.log("收到父页面消息:", event.data.text);
-            localStorage.setItem("initMessage", event.data.text);
-            // ...你的业务处理...
-          }
-        });
-        window.parent.postMessage(
-          { type: "ready" },
-          "%VITE_PARENT_ORIGIN%" // 指定父页面的 origin,务必不要写成 '*',防止安全隐患
-        );
-      }
-
       function updateFavicon() {
         var favicon =
           document.querySelector("#app-favicon") || document.querySelector('link[rel="icon"]');
@@ -127,69 +109,15 @@
           if (credit_code) {
             globalThis.transmission = { credit_code };
           }
-          // 预下载完整政策列表到内存中
-          (function () {
-            // 远端 = **DMS 文件服务**里的那份(用户 2026-09-24:把 policies_public.v2.json 传到 DMS,
-            // 那个路径就是新的"远端政策接口")。同源路径:dev 走 vite 代理、生产走 nginx。
-            var remoteURL = "%VITE_POLICY_REMOTE_URL%";
-            // 兜底 = 随包发布的本地副本(`public/merchant-agent/…`)
-            var localFallbackURL = "/merchant-agent/policies_public.v2.json";
-
-            function applyPolicies(data, source) {
-              // 支持 { data: [...] } 和纯数组两种格式
-              var list = Array.isArray(data) ? data : data.data;
-              globalThis.policiesPublic = list || [];
-              console.log(
-                "%c[zhaoshang] 政策列表加载完成(" +
-                  source +
-                  "):" +
-                  globalThis.policiesPublic.length +
-                  " 条",
-                "color:green"
-              );
-            }
-
-            function loadPolicies(source) {
-              return fetch(source.url)
-                .then(function (response) {
-                  if (!response.ok) throw new Error("HTTP " + response.status);
-                  return response.json();
-                })
-                .then(function (data) {
-                  applyPolicies(data, source.label);
-                });
-            }
-
-            // 顺序固定:**先远端(DMS),取不到再用本地兜底**(用户 2026-09-24 明确要求)。
-            // 原先那个「开发环境本地优先」的开关(VITE_POLICY_LOCAL_FIRST)随旧远端一起删掉了 ——
-            // 它当年是为了绕开"厂商远端那份没有市级政策id"的问题,现在远端换成我们自己传的 DMS 那份,
-            // 两边内容一致,不再需要这个开关。
-            var sources = [
-              { url: remoteURL, label: "dms" },
-              { url: localFallbackURL, label: "local" },
-            ];
-
-            loadPolicies(sources[0]).catch(function (err) {
-              console.warn(
-                "[zhaoshang] 政策列表(" + sources[0].label + ")加载失败,尝试另一个来源:",
-                err
-              );
-              loadPolicies(sources[1]).catch(function (fallbackErr) {
-                console.warn("[zhaoshang] 政策列表两个来源都加载失败:", fallbackErr);
-                globalThis.policiesPublic = [];
-              });
-            });
-          })();
+          // 惠企政策库由 src/main.ts → ensurePolicyLibrary() 加载
+          // (config.dms.policyRemotePath → 本地兜底;上传用 upload-policy-library.mjs)
         }
 
         document.title = globalThis.orgTitle;
         updateFavicon();
-        initMessageChannel();
-        // ⚠️ 这三行+addVersionInfo 是**加载遮罩的收尾信号**,删不得:
-        // BusinessAssistant.vue 的 initSequence() 靠 `globalThis.hasSequence`(或等 `sequenceLoaded` 事件)
-        // 来隐藏 #loading-cover —— 少了它页面会永远卡在"正在初始化,请稍候..."。
-        // (原来它们挂在 `if (globalThis.isKnowledgeApi) { … }` 里、在硬编码 cardInfo 之后;
-        //   2026-09-28 删那段死枝时**误删过一次**,现已移到这里无条件执行,语义与原来一致。)
+        // 下面四行是**加载遮罩的收尾信号,删不得**:BusinessAssistant.vue 的 initSequence() 靠
+        // globalThis.hasSequence(或等 sequenceLoaded 事件)来隐藏 #loading-cover,
+        // 少了它页面会永远卡在"正在初始化,请稍候..."。
         globalThis.hasSequence = true;
         document.dispatchEvent(new CustomEvent("dataLoaded"));
         document.dispatchEvent(new CustomEvent("sequenceLoaded"));
@@ -197,18 +125,13 @@
       })();
 
       (function () {
+        // eruda 移动端调试面板:只在 URL 带 ?eruda=true(或 localStorage 的 active-eruda 开关)时加载
         var src = "//cdn.jsdelivr.net/npm/eruda";
         if (!/eruda=true/.test(window.location) && localStorage.getItem("active-eruda") != "true")
           return;
         document.write("<scr" + 'ipt src="' + src + '"></scr' + "ipt>");
         document.write("<scr" + "ipt>eruda.init();</scr" + "ipt>");
       })();
-      //
-      document.addEventListener("WeixinJSBridgeReady", () => {
-        console.log("WeixinJSBridgeReady");
-        // let video = document.getElementById("human-stream");
-        // video && video.play();
-      });
     </script>
   </head>
 
@@ -218,7 +141,6 @@
       <div id="cover">
         <!-- <div id="circle" data-text="&nbsp;"></div> -->
         <div id="result-text">正在初始化,请稍候...</div>
-        <!-- 加载中,请稍候... -->
       </div>
     </div>
     <div id="error-info" style="display: none; opacity: 0">
@@ -250,10 +172,6 @@
         });
       })();
     </script>
-    <!-- [已删] 百度统计(hm.baidu.com/hm.js)——2026-09-28 用户确认删除:
-         数据进的是厂商账号(其站点配置 dm 指向 demo.heijingai.com),本项目从未用过它的数据,
-         且它是全站唯一往第三方发请求、且在青浦域名上写 cookie 的东西。理由与排查见
-         harness/docs/reference/current-api-call-sites.md 的 #14 -->
     <script type="module" src="/src/main.ts"></script>
 
     <style>
@@ -345,7 +263,6 @@
 
         color: #fff;
 
-        /* title/h5_medium */
         font-family: PingFang HK;
         font-size: 16px;
         font-style: normal;

+ 3 - 3
package.json

@@ -1,7 +1,7 @@
 {
-  "name": "vite-vue-typescript-starter",
+  "name": "zhaoshang-llm",
   "private": true,
-  "version": "1.6.1",
+  "version": "2.0",
   "type": "module",
   "scripts": {
     "dev": "vite --host --port 8083 --mode development",
@@ -43,4 +43,4 @@
     "vite-svg-loader": "^5.1.0",
     "vue-tsc": "^1.0.13"
   }
-}
+}

+ 71 - 0
public/config.js

@@ -0,0 +1,71 @@
+/**
+ * 运行时配置(`public/config.js`,部署后可直接改,无需重新构建)。
+ * index.html 在应用脚本之前引入。
+ *
+ * ┌─ 环境区分 ─────────────────────────────────────────────
+ * │ 当前默认:**政务网**(内网栏目 id)
+ * │ 切回互联网:把下方「政务网」块注释掉,解开「互联网」块,
+ * │            并将 fieldDoubleCPrefix 改为 false。
+ * └────────────────────────────────────────────────────────
+ *
+ * 政策库远端:DMS uploadFile **会改名**(变成 `…-c_question-<uuid>.json`),
+ * 上传后把返回的相对路径填到 `policyRemotePath`。
+ * 空字符串 = 不走远端,直接用本地 `/merchant-agent/policies_public.v2.json`。
+ */
+window.__ZHAOSHANG_CONFIG__ = {
+  dms: {
+    // ========== 栏目 / 模型 id ==========
+    // 前端现用:enterprise / honor / session / record
+    // 其余项仅备案,代码尚未读取。
+
+    // ---- 【互联网】外网 DMS(121.43…,原栏目 1886–1889)----
+    columns: {
+      honor: 1886,       // 企业荣誉信息
+      session: 1887,     // 助手会话
+      enterprise: 1888,  // 企业基础信息
+      record: 1889,      // 助手问答记录
+      // pageView: 1885, // 助手页面浏览(互联网)
+    },
+    models: {
+      honor: 2032,
+      session: 2034,
+      enterprise: 2036,
+      record: 2038,
+      // pageView: 2030,
+    },
+    // ---- 【互联网】结束 ---- 
+
+    // ---- 【政务网】内网 DMS(当前生效)----
+    // 父栏目「营商助手」3036(一般不管)
+    // columns: {
+    //   enterprise: 3037, // 企业基础信息
+    //   honor: 3050, // 企业荣誉信息
+    //   session: 3055, // 助手会话
+    //   record: 3049, // 助手问答记录
+
+    // },
+    // models: {
+    //   enterprise: 2303, // 企业基础信息
+    //   honor: 2329, // 企业荣誉信息
+    //   session: 2339, // 助手会话
+    //   record: 2327, // 助手问答记录
+    // },
+    // ---- 【政务网】结束 ----
+
+    /**
+     * 字段双 c_ 前缀:
+     * - 政务网 true(建栏时字段名已带 c_,DMS 克隆后再加一层 → c_c_xxx)
+     * - 互联网 false
+     */
+    fieldDoubleCPrefix: false,
+
+    /**
+     * 政策库在 DMS 上的相对路径(uploadFile 返回的 content)。
+     * 前端拼成 `{VITE_DMS_API}` + 该路径;取不到再本地兜底。
+     * 【互联网】示例仍保留在下一行;政务网重新 upload 后改成新路径。
+     */
+    // 【互联网】"/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json"
+    policyRemotePath:
+      "/static/file/1889-c_question-83859739-17d2-4b7f-b427-7ee27e098e79.json",
+  },
+};

+ 0 - 79
redirect.html

@@ -1,79 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Redirecting...</title>
-    <style>
-      body {
-        margin: 0;
-        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-        background: #f5f5f5;
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        min-height: 100vh;
-      }
-      .error-box {
-        display: none;
-        background: #fff;
-        padding: 40px 48px;
-        border-radius: 12px;
-        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
-        text-align: center;
-        color: #333;
-        font-size: 16px;
-        line-height: 1.6;
-      }
-    </style>
-  </head>
-  <body>
-    <div id="error" class="error-box"></div>
-    <script>
-      (function () {
-        var redirectMap = %VITE_REDIRECT_MAP%;
-
-        // hash 为 target key,支持 /redirect.html#demo?credit_code=123 和 /redirect.html?from=wx#demo 两种形式
-        var rawHash = location.hash;
-        var hashBody = rawHash ? rawHash.slice(1) : "";  // "demo?credit_code=123" 或 "demo"
-        var hashParts = hashBody.split("?");
-        var targetKey = hashParts[0];
-        // hash 中 ? 之后的部分作为额外 querystring
-        var hashQuery = hashParts.length > 1 ? "?" + hashParts.slice(1).join("?") : "";
-
-        if (!targetKey) {
-          showError("\u672a\u6307\u5b9a\u8df3\u8f6c\u76ee\u6807\uff0c\u8bf7\u901a\u8fc7 hash \u6307\u5b9a target");
-          return;
-        }
-
-        var targetUrl = redirectMap[targetKey];
-        if (targetUrl) {
-          // .env 中 # 为注释符,URL 中的 # 使用 %23 编码,此处解码还原
-          targetUrl = targetUrl.replace(/%23/g, "#");
-        } else {
-          showError("\u672a\u627e\u5230\u8df3\u8f6c\u76ee\u6807\uff1a" + targetKey);
-          return;
-        }
-
-        // 合并 location.search 和 hash 中的 querystring,原样透传
-        var queryString;
-        if (location.search && hashQuery) {
-          queryString = location.search + "&" + hashQuery.slice(1);
-        } else {
-          queryString = location.search || hashQuery;
-        }
-
-        // querystring 直接追加到目标 URL 末尾
-        var finalUrl = targetUrl + queryString;
-
-        location.replace(finalUrl);
-
-        function showError(msg) {
-          var el = document.getElementById("error");
-          el.textContent = msg;
-          el.style.display = "block";
-        }
-      })();
-    </script>
-  </body>
-</html>

+ 12 - 26
src/asr/nls-protocol.ts

@@ -1,12 +1,8 @@
 /**
- * 阿里云 NLS「实时语音识别」的**协议层**(纯函数,不碰 DOM / WebSocket)。
+ * 阿里云 NLS「实时语音识别」的**协议层**(纯函数,不碰 DOM / WebSocket);
+ * `src/asr/nls.ts` 只负责音频采集与连接。
  *
- * 单独抽出来的原因:① 帧结构与事件映射是这个改造里唯一"错了就静默不工作"的部分,
- * 抽成纯函数就能被 harness 直接测(`harness/tools/verify-nls-realtime.mjs`);
- * ② `src/asr/nls.ts` 只负责音频采集与连接,读起来更清楚。
- *
- * 契约来源:**实测**(`harness/tools/probe-nls-realtime.mjs` 打真实网关,
- * 逐帧看过原始载荷;文档侧要点见 progress.md 续 33)。两条踩过的坑:
+ * 契约来源是**实测**(打真实网关、逐帧看过原始载荷)。两条坑:
  *   1. **每一条指令都要带 `appkey`** —— 只有 start 带时,stop 会回
  *      `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`
  *   2. 中间结果(`TranscriptionResultChanged`)**会被后续结果覆盖/重叠**,
@@ -27,13 +23,9 @@ export const NLS_FRAME_MS = 100;
 /**
  * 推流节拍与「追赶」上限。
  *
- * 为什么需要追赶:连上网关之前(取 token + 握手)麦克风已经在采集,这段音频先攒着;
- * 如果之后只按 **1× 实时** 排空,那段积压就**永远排不掉** —— 表现为"出字整体慢半拍",
- * 且不会自己恢复(旧厂商 SDK 是按 2× 排空的,所以它能追上)。
- *
- * 实测数据(`probe-nls-realtime.mjs`):按 1× 推时,客户端落后网关 **20~240ms**;
- * 积压来自握手时长(通常 0.2~1s)。这里的策略是**平时按实时、积压时最多 4× 追**
- * (4× 约等于 1s 积压用 330ms 排空;再快没有收益,且离"一次性灌入"越来越近)。
+ * 连上网关之前(取 token + 握手)麦克风已经在采集,这段音频先攒着;如果之后只按 **1× 实时**
+ * 排空,那段积压就**永远排不掉**,表现为"出字整体慢半拍"且不会自己恢复。
+ * 策略是**平时按实时、积压时最多 4× 追**。
  */
 export const NLS_FLUSH_TICK_MS = 50;
 export const NLS_MAX_FRAMES_PER_TICK = 2;
@@ -59,10 +51,8 @@ export interface NlsStartOptions {
   /**
    * 端点静音断句阈值(毫秒,网关允许 200~2000)。
    *
-   * 默认 **500**(网关文档默认 800),实测(`probe-nls-realtime.mjs`):
-   * 300ms 比 800ms 的**定稿早到约 300ms**,中间结果节奏不受它影响(恒 ~480ms)。
-   * 取 500 是折中:说得快的人不会在自然停顿处被切断,说一句停一下的人能更快看到定稿。
-   * 停顿被切句不影响文本完整性(后续句子会继续追加)。
+   * 默认 **500**(网关文档默认 800):说得快的人不会在自然停顿处被切断,
+   * 说一句停一下的人能更快看到定稿;停顿被切句不影响文本完整性(后续句子会继续追加)。
    */
   maxSentenceSilence?: number;
 }
@@ -70,9 +60,8 @@ export interface NlsStartOptions {
 /**
  * `StartTranscription` 帧。`enable_intermediate_result` 必须开,否则没有"边说边出字"。
  *
- * ⚠️ `task_id` **不能为空**:实测网关会回
- * `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!` ——
- * 所以不传时这里自己生成一个(32 位十六进制),调用方拿返回值里的 task_id 复用到 stop 帧。
+ * ⚠️ `task_id` **不能为空**(网关会回 `Invalid task id ''!`),所以不传时这里自己生成一个,
+ * 调用方拿返回值里的 task_id 复用到 stop 帧。
  */
 export const buildStartTranscription = (options: NlsStartOptions) =>
   JSON.stringify({
@@ -160,8 +149,7 @@ export interface NlsTransformPayload {
  * | `SentenceEnd` | `fin`:把本句**定稿**追加进全文,`transformText` = 新全文 |
  * | `SentenceBegin` / 其它 | null(状态不变) |
  *
- * 注意 `SentenceEnd.payload.result` 是**整句**(实测:"青浦区高新技术企业奖励是多少钱?"),
- * 所以是追加而不是替换。
+ * `SentenceEnd.payload.result` 是**整句**,所以是追加而不是替换。
  */
 export const reduceNlsTranscript = (
   state: NlsTranscriptState,
@@ -190,9 +178,7 @@ export const reduceNlsTranscript = (
 /**
  * Float32 采样 → 16k / 16bit / 单声道 的小端 PCM 字节。
  *
- * ⚠️ 与旧 SDK 的 `to16kHZ()` 的区别:**采样率按实际传入的 `fromRate` 算**
- * (旧实现把输入写死成 44100,设备实际是 48000 时会送出"慢 8% 的 16k",
- * 见 `harness/docs/reference/asr-realtime.md` §9.1)—— 这里是新实现,不再沿用那个假设。
+ * ⚠️ **采样率按实际传入的 `fromRate` 算**(写死会导致设备采样率不符时送出"变速"的音频)。
  */
 export const encodePcm16kMono = (input: Float32Array, fromRate: number): Uint8Array => {
   const rate = Number.isFinite(fromRate) && fromRate > 0 ? fromRate : NLS_SAMPLE_RATE;

+ 42 - 57
src/asr/nls.ts

@@ -4,18 +4,12 @@
  *   new ASR(appKey?, CONSTANTS?)  ·  openMicroPhone()  ·  startRecord()  ·  openSocket()
  *   ·  transform()  ·  close()  ·  clear()  ·  事件 `transform` / `error`
  *
- * 为什么保持契约:两个壳组件(`BusinessAssistantPC.vue` / `BusinessAssistantMobile.vue`)
- * 里的 `toggleVoiceInput` / `stopVoiceInput` 一行都不用改,切回旧实现也只改 import。
+ * 要点:
+ *   - 麦克风用 `navigator.mediaDevices.getUserMedia` 采集,PCM 以 16k / 16bit / 单声道推给网关
+ *   - **重采样按实际采样率算**;**定稿只认 `SentenceEnd`**(中间结果会重叠,不能拼)
+ *   - 凭据:**前端不持有任何阿里云凭据**,token + appkey 由后端网关下发(见 `nls-token.ts`)
  *
- * 与旧实现的实质差别:
- *   - 凭据:**前端不持有任何阿里云凭据**,token + appkey 由服务端端点下发
- *     (`src/network/api/nls-token.ts` → 后端网关 `getALYToken`;dev 走 vite 代理 `/aly-api/`,
- *      生产由 nginx 转发同一路径,见技术债 #10)
- *   - 重采样按**实际采样率**算(旧实现写死 44100,见 asr-realtime.md §9.1)
- *   - 定稿只认 `SentenceEnd`(中间结果会重叠,不能拼)
- *   - 采样位深/声道:仍然 16k / 16bit / 单声道
- *
- * 协议细节与实测依据见 `./nls-protocol.ts` 与 `harness/docs/reference/asr-nls.md`。
+ * 协议细节见 `./nls-protocol.ts`。
  */
 
 import { BaseEvent } from "@/utils/base-event";
@@ -77,7 +71,7 @@ export default class NlsAsr extends BaseEvent {
     try {
       this.stream = await navigator.mediaDevices.getUserMedia(this.CONSTANTS);
     } catch (error) {
-      console.warn("[nls] 获取麦克风失败", error);
+      /* console.warn("[nls] 获取麦克风失败", error); */
       // 区分"用户拒绝"与"设备异常":前者要给可操作的提示
       if ((error as DOMException)?.name === "NotAllowedError") {
         throw new Error("麦克风权限被拒绝,请在浏览器或系统设置里允许后重试");
@@ -104,37 +98,36 @@ export default class NlsAsr extends BaseEvent {
 
     this.audioContext = new window.AudioContext();
     this.source = this.audioContext.createMediaStreamSource(this.stream);
-    // ScriptProcessorNode 已废弃,但仓库现有的波形绘制依赖 audioContext/source,
-    // NLS 与旧实现共用同一套采集方式,避免"换语音识别顺带换了采集"这种大改
+    // ScriptProcessorNode 已废弃,但仓库的波形绘制依赖 audioContext/source,
+    // 所以与旧实现共用同一套采集方式
     this.processor = this.audioContext.createScriptProcessor(0, 1, 1);
     this.processor.connect(this.audioContext.destination);
     this.source.connect(this.processor);
     const sampleRate = this.audioContext.sampleRate;
     let capturedChunks = 0;
     let silenceWarned = false;
-    console.log(
+    /* console.log(
       `[nls] 开始采集:AudioContext=${this.audioContext.state} 采样率=${sampleRate}Hz ` +
         `processor 缓冲=${this.processor.bufferSize}`
-    );
+    ); */
     // 音轨自己的状态 —— 分辨「系统/硬件把麦克风静音了」与「接线有问题」的关键证据:
-    //   muted=true 表示轨道被静音(系统静音键、隐私开关、别的程序独占…),
-    //   这时 getUserMedia 照样成功、回调照样触发,但采样全是 0。
+    // muted=true 时 getUserMedia 照样成功、回调照样触发,但采样全是 0。
     const track = this.stream.getAudioTracks?.()[0];
     if (track) {
       const settings = typeof track.getSettings === "function" ? track.getSettings() : {};
-      console.log(
+      /* console.log(
         `[nls] 音轨:label=${JSON.stringify(track.label)} muted=${track.muted} enabled=${track.enabled} ` +
           `state=${track.readyState} deviceId=${String(settings.deviceId || "").slice(0, 8)}… ` +
           `声道=${settings.channelCount ?? "?"}`
-      );
+      ); */
       const reportTrackState = (tag: string) => {
-        console.log(`[nls] ${tag}音轨状态:muted=${track.muted} enabled=${track.enabled} state=${track.readyState}`);
+        /* console.log(`[nls] ${tag}音轨状态:muted=${track.muted} enabled=${track.enabled} state=${track.readyState}`); */
       };
       window.setTimeout(() => reportTrackState("1.5s 后"), 1500);
       track.addEventListener?.("mute", () => reportTrackState("被静音时(mute 事件)"));
       track.addEventListener?.("unmute", () => reportTrackState("解除静音时(unmute 事件)"));
     } else {
-      console.warn("[nls] ⚠️ 流里没有音轨(getUserMedia 拿到的不是音频流?)");
+      /* console.warn("[nls] ⚠️ 流里没有音轨(getUserMedia 拿到的不是音频流?)"); */
     }
     this.processor.onaudioprocess = (event: AudioProcessingEvent) => {
       if (!this.processing || !this.stream || !this.stream.active) return;
@@ -142,10 +135,9 @@ export default class NlsAsr extends BaseEvent {
       const encoded = encodePcm16kMono(channel, sampleRate);
       capturedChunks += 1;
       if (capturedChunks === 1) {
-        console.log(`[nls] 收到第一块音频:输入 ${channel.length} 采样 → 编码后 ${encoded.length} 字节`);
+        /* console.log(`[nls] 收到第一块音频:输入 ${channel.length} 采样 → 编码后 ${encoded.length} 字节`); */
       }
-      // 电平日志:排查「音频在推但服务端没反应」时,先确认麦克风数据不是静音
-      // (每 ~2s 一条;峰值长期 ≈0 说明采集端没拿到声音,问题在设备/权限而不在协议)
+      // 电平日志(每 ~2s 一条):峰值长期 ≈0 说明采集端没拿到声音,问题在设备/权限而不在协议
       if (capturedChunks % 50 === 1) {
         let peak = 0;
         let sum = 0;
@@ -155,36 +147,33 @@ export default class NlsAsr extends BaseEvent {
           sum += value * value;
         }
         const rms = Math.sqrt(sum / channel.length);
-        console.log(`[nls] 采集电平:峰值=${peak.toFixed(4)} RMS=${rms.toFixed(4)}`);
-        // 连续 3 秒全静音 → 直接把「当前用的是哪个输入设备」摊开说清楚。
-        // 实测踩过:Windows 上装了 Steam 的话,默认输入设备可能是
-        // **Steam Streaming Microphone**(虚拟设备,永远输出静音)——
-        // 表现就是 getUserMedia 成功、回调照跑、但电平恒为 0、网关一句都不回。
+        /* console.log(`[nls] 采集电平:峰值=${peak.toFixed(4)} RMS=${rms.toFixed(4)}`); */
+        // 连续 3 秒全静音 → 把「当前用的是哪个输入设备」摊开说清楚
+        // (虚拟设备 / 系统静音 / 被会议软件独占,都会是"能采集但电平恒为 0")
         if (peak === 0 && !silenceWarned) {
           silenceWarned = true;
           const deviceLabel = this.stream?.getAudioTracks?.()[0]?.label || "(未知设备)";
-          console.warn(
+          /* console.warn(
             `[nls] ⚠️ 连续采集到全静音:当前输入设备是 ${JSON.stringify(deviceLabel)}。\n` +
               `[nls]    若它不是你正在说话的麦克风(常见的坑:Steam Streaming Microphone 这类**虚拟设备**、\n` +
               `[nls]    系统静音/隐私开关、被会议软件独占),请在 Windows「声音设置 → 输入」或\n` +
               `[nls]    浏览器「站点设置 → 麦克风」里换一个真实麦克风后重试。`
-          );
+          ); */
         }
       }
       this.pendingAudio.push(encoded);
     };
 
-    // 诊断:AudioContext 有时会以 suspended 起步(那样 onaudioprocess 根本不会回调),
-    // 这里主动 resume 一次,并在 1.5s 后仍没采到任何数据时把状态打出来
+    // AudioContext 有时会以 suspended 起步(那样 onaudioprocess 根本不会回调),主动 resume 一次
     if (this.audioContext.state === "suspended") {
       void this.audioContext.resume().catch(() => undefined);
     }
     window.setTimeout(() => {
       if (capturedChunks === 0 && this.processing) {
-        console.warn(
+        /* console.warn(
           `[nls] ⚠️ 1.5s 内没有采集到任何音频(AudioContext=${this.audioContext?.state},` +
             `stream.active=${this.stream?.active})—— 麦克风可能被别的程序占用或未真正启动`
-        );
+        ); */
       }
     }, 1500);
   }
@@ -192,7 +181,7 @@ export default class NlsAsr extends BaseEvent {
   /** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
   async openSocket(): Promise<void> {
     const { token } = await fetchNlsToken();
-    // appkey 来自环境配置(见 getNlsAppkey);构造参数只作兜底
+    // appkey 来自配置(见 getNlsAppkey);构造参数只作兜底
     this.appkey = getNlsTokenAppkey() || this.appKey;
     this.taskId = createNlsId();
 
@@ -222,16 +211,16 @@ export default class NlsAsr extends BaseEvent {
         const name = getNlsEventName(message);
         if (!name) return;
 
-        // 诊断日志:把网关下行事件打出来(ResultChanged 很密,每 4 条打一条)。
-        // 排查「进录音态但不出字」时,这几行能直接区分「音频没上去」和「服务端没回/回了错」。
+        // 诊断日志:把网关下行事件打出来(ResultChanged 很密,每 4 条打一条),
+        // 排查「进录音态但不出字」时能区分「音频没上去」和「服务端没回/回了错」
         if (name === "TaskFailed") {
-          console.warn(`[nls] ← TaskFailed:${describeNlsFailure(message)}`);
+          /* console.warn(`[nls] ← TaskFailed:${describeNlsFailure(message)}`); */
         } else if (name !== "TranscriptionResultChanged" && name !== "SentenceBegin") {
-          console.log(`[nls] ← ${name}${message?.payload?.result ? ` ${JSON.stringify(message.payload.result)}` : ""}`);
+          /* console.log(`[nls] ← ${name}${message?.payload?.result ? ` ${JSON.stringify(message.payload.result)}` : ""}`); */
         } else if (name === "TranscriptionResultChanged") {
           midEvents += 1;
           if (midEvents % 4 === 1) {
-            console.log(`[nls] ← 中间结果:${JSON.stringify(message?.payload?.result ?? "")}`);
+            /* console.log(`[nls] ← 中间结果:${JSON.stringify(message?.payload?.result ?? "")}`); */
           }
         }
 
@@ -289,8 +278,8 @@ export default class NlsAsr extends BaseEvent {
     this.source?.disconnect();
     this.processor?.disconnect();
 
-    // 余下的音频尽快发完:**用户此刻正在等最后一段文字**,按 100ms 一帧的实时节奏发
-    // 会让"停止后还要等"的体感更差。这里用 20ms 一帧(≈5× 实时),余量通常 ≤100ms
+    // 余下的音频尽快发完:用户此刻正在等最后一段文字,按 100ms 一帧的实时节奏发
+    // 会让"停止后还要等"的体感更差 —— 这里用 20ms 一帧(≈5× 实时)
     const frames = this.pendingAudio;
     this.pendingAudio = [];
     for (const frame of frames) {
@@ -368,11 +357,9 @@ export default class NlsAsr extends BaseEvent {
   }
 
   /**
-   * 把缓存里的音频按帧推给网关:**平时按实时节奏,积压时最多 4× 追赶**。
-   *
-   * 追赶这一步不能省 —— 连上网关前(取 token + 握手,通常 0.2~1s)麦克风已在采集,
-   * 若只按 1× 排空,那段积压永远排不掉,表现为"出字整体慢半拍"(实测见
-   * `probe-nls-realtime.mjs` 的落后测量;协议层常量与注释在 nls-protocol.ts)。
+   * 把缓存里的音频按帧推给网关:**平时按实时节奏,积压时最多 4× 追赶**
+   * (连上网关前麦克风已在采集,只按 1× 排空那段积压永远排不掉,表现为"出字整体慢半拍";
+   *  常量与说明见 nls-protocol.ts)。
    */
   private startFlushTimer(): void {
     this.stopSendTimer();
@@ -387,10 +374,8 @@ export default class NlsAsr extends BaseEvent {
       if (this.socket?.readyState !== WebSocket.OPEN) return;
 
       // 1) 把采集到的块**全部**并进 carry。
-      // ⚠️ 这里曾经写成「只在 carry + 下一块 ≤ 两帧时才合并」—— 一旦浏览器给的 buffer
-      //    比两帧还大(bufferSize 是浏览器自选的),那块就永远合并不进来,
-      //    结果**一帧音频都发不出去**,而界面看着仍在正常录音(用户 2026-09-24 报的
-      //    「进了录音状态但是不出字」就是这个形态)。合并**不设上限**才安全。
+      // ⚠️ 合并**不设上限**:一旦按「carry + 下一块 ≤ 两帧」限制,浏览器给的 buffer
+      //    比两帧大时就永远合并不进来,结果**一帧音频都发不出去**(界面看着仍在正常录音)。
       if (this.pendingAudio.length) {
         const pending = this.pendingAudio;
         this.pendingAudio = [];
@@ -415,16 +400,16 @@ export default class NlsAsr extends BaseEvent {
       }
       if (sentFrames - lastLoggedFrames >= 20) {
         lastLoggedFrames = sentFrames;
-        console.log(`[nls] 已推音频 ${Math.round((sentFrames * bytesPerFrame) / 32)}ms(${sentFrames} 帧)`);
+        /* console.log(`[nls] 已推音频 ${Math.round((sentFrames * bytesPerFrame) / 32)}ms(${sentFrames} 帧)`); */
       }
 
-      // 3) 诊断:录着音却 >=2 秒一帧都没发出去 → 明确报出来,别再"静默不出声"
+      // 3) 诊断:录着音却 >=2 秒一帧都没发出去 → 明确报出来,别"静默不出声"
       if (this.processing && sentFrames === 0 && Date.now() - flushStartedAt > 2000 && Date.now() - stallWarnedAt > 5000) {
         stallWarnedAt = Date.now();
-        console.warn(
+        /* console.warn(
           `[nls] ⚠️ 录音中但音频一帧都没发出去(carry=${carry.length}B,缓存块=${this.pendingAudio.length})` +
             ` —— 若持续如此,检查采集端是否真的在出数据(见上一条「采集开始」日志里的采样率)`
-        );
+        ); */
       }
     }, NLS_FLUSH_TICK_MS);
   }

+ 12 - 46
src/components/BusinessAssistantMobile.vue

@@ -1,9 +1,6 @@
 <template>
   <div class="ba-mobile-container">
-    <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
-    <!-- [已停用] 全局提示改由 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast)
-
-         —— 原来的 <Toast> 是全屏黑遮罩 + 居中白框,用户说"太丑了" -->
+    <!-- 全局提示:走 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast) -->
     <header v-if="currentPage === 'home' || currentPage === 'chat'" class="mobile-header"
       :class="{ 'chat-header': currentPage === 'chat' }">
       <button v-if="currentPage === 'home'" type="button" class="icon-btn menu-btn" aria-label="打开会话菜单"
@@ -395,7 +392,6 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue';
 import BusinessRecord from "./Chat/BusinessRecord.vue";
-// [已停用] import Toast from "./Common/Toast.vue";
 import ScrollList from './Common/ScrollList.vue';
 import BusinessAssistantComposer from './business-assistant/BusinessAssistantComposer.vue';
 import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
@@ -404,9 +400,7 @@ import {
   maybeStopAutoScrollAtLastAiRowTop,
 } from './business-assistant/auto-scroll-top-stop';
 import { scrollBusinessAssistantListToBottom } from './business-assistant/scroll-list-bottom';
-// 实时语音识别:**阿里云 NLS 直连**(2026-09-23 换)。
-// 对外契约与旧厂商 SDK(`@/three-libs/asr/index`)一致,所以这里只有这一行变了;
-// 切回旧实现也只改这一行。详见 harness/docs/reference/asr-nls.md
+// 实时语音识别:走阿里云 NLS(实现见 @/asr/nls,说明见 harness/docs/reference/asr-nls.md)
 import ASR from '@/asr/nls';
 import AccountInfo from "./Chat/AccountInfo.vue";
 import { useEnterpriseAuth } from '@/components/useEnterpriseAuth';
@@ -421,10 +415,6 @@ import {
 
 provide(MODAL_MUTEX_KEY, createModalMutex());
 
-provide('formOptionsControl', {
-  setFormOptions: () => { },
-});
-
 provide('speechControl', {
   stopSpeech: () => { },
   handleStopSpeech: () => { },
@@ -465,10 +455,7 @@ const chatSuggestionsContainerRef = ref<HTMLElement | null>(null);
 const chatSuggestionsBottomPadding = ref(0);
 const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
 const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
-/**
- * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
- * 而不是贴到底部(用户 2026-09-21 选择的做法)。与 PC 端同一口径(说明见 BusinessAssistantPC.vue)。
- */
+/** 「一整块到达」的高度阈值:超过它就把块的**顶部**对到视口顶部,而不是贴到底部(与 PC 端同口径) */
 const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
 /** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
 const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
@@ -527,12 +514,8 @@ const {
   clearUploadDraft,
 } = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
 
-/**
- * 上传校验失败时的提示通道。
- * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
- * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
- */
-let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
+/** 上传校验失败时的提示通道:showToast 由后面的 useBusinessAssistantChat 提供,这里先用占位再接上 */
+let notifyUpload: (message: string) => void = (message) => { /* console.warn('[upload]', message); */ };
 
 const {
   suggestions,
@@ -1090,7 +1073,7 @@ const stopVoiceInput = async () => {
         inputText.value = result.text;
       }
     } catch (error) {
-      console.error("Mobile voice input transform error:", error);
+      /* console.error("Mobile voice input transform error:", error); */
     }
   }
 
@@ -1128,9 +1111,8 @@ const toggleVoiceInput = async () => {
     try {
       success = await asrInstance.openMicroPhone();
     } catch (error) {
-      console.error("Mobile voice input microphone error:", error);
-      // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、
-      // 样式也和全站其他提示不一致(用户 2026-09-23 要求语音这几处一并换掉)
+      /* console.error("Mobile voice input microphone error:", error); */
+      // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、样式也不一致
       showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
       releaseVoiceInputInstance();
       return;
@@ -1150,7 +1132,7 @@ const toggleVoiceInput = async () => {
     await asrInstance.openSocket();
     isVoiceInputMode.value = true;
   } catch (error) {
-    console.error("Mobile voice input error:", error);
+    /* console.error("Mobile voice input error:", error); */
     showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试");
     isVoiceInputMode.value = false;
     releaseVoiceInputInstance();
@@ -1266,10 +1248,7 @@ const releaseScopeHeightLockIfReady = () => {
   return true;
 };
 
-/**
- * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
- * 与 PC 端同实现(说明见 BusinessAssistantPC.vue 里同名函数)。
- */
+/** 大块内容(卡片 / 参考资料)到达时,把它自己的顶部对到视口顶部(与 PC 端同实现) */
 const anchorLargeBlockTop = (heightGrowth: number): boolean => {
   const scrollList = scrollListRef.value;
   const lastAiMessage = getLastAiMessage();
@@ -1400,7 +1379,6 @@ const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: numb
     heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
   ) {
     // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
-    // (用户 2026-09-21 选定;说明见 BusinessAssistantPC.vue 的 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH)
     if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
       payload?.onComplete?.();
       return;
@@ -1851,7 +1829,7 @@ const confirmDeleteSession = () => {
   align-items: center;
   gap: 6px;
   margin: 0;
-  /* 两侧留白:标题与介绍文案都不贴屏幕边(原先几乎顶满,观感很挤) */
+  /* 两侧留白:标题与介绍文案都不贴屏幕边 */
   padding: 0 28px;
   box-sizing: border-box;
   width: 100%;
@@ -1871,10 +1849,8 @@ const confirmDeleteSession = () => {
 .heading {
   margin: 0;
   color: #141b2b;
-  /* 先给老内核一个固定值(= 上限),再用 clamp 让更窄的屏继续收一点。
-     目的:这行字**恒定单行**不换行。
+  /* 目的:这行字**恒定单行**不换行。
      ⚠️ 两行必须同进同退:clamp 这行在后面、会覆盖上一行,只改基础值等于没改。
-     2026-09-18 演进:20px → 16px(用户反馈「太小了」)→ 20px → **22px(当前)**。
      clamp 取 5.7vw:≥386px 的屏恒为 22px,更窄的屏按比例收缩以免标题被顶出屏幕。 */
   font-size: 22px;
   font-size: clamp(17px, 5.7vw, 22px);
@@ -2705,16 +2681,6 @@ const confirmDeleteSession = () => {
     padding-right: 16px;
   }
 
-  /* [已移除 2026-09-18] 此处原为 .heading { font-size: 28px }。
-     它最初与基础值同值(那时基础值是 28px),是个空操作;
-     2026-09-18 基础值改小后它反而变成**窄屏上把标题放大回 28px** 的覆盖——
-     而标题是 white-space: nowrap,13 个字 × 28px ≈ 356px,远超窄屏可用宽度
-     (360px 屏约 272px)—— 会把标题顶出屏幕约 84px。
-     窄屏的收缩已由 .heading 自己的 clamp(13px, 4.2vw, 16px) 负责,故移除。
-  .heading {
-    font-size: 28px;
-  }
-  */
 
   .suggestion-chips {
     padding-left: 12px;

+ 17 - 10
src/components/BusinessAssistantMobileInputShell.vue

@@ -33,8 +33,7 @@
 
       <div class="input-actions-bar">
         <div class="actions-left">
-          <!-- 上传入口:附件上传到 OSS 后随请求体的 `pictures` / `files` 字段发给后端
-               (2026-09-23 起;此前是拼进 question 文本,见 useBusinessAssistantChat 的 sendMessage) -->
+          <!-- 上传入口:附件先传到 OSS,再随请求体的 `pictures` / `files` 字段发给后端 -->
           <button type="button" class="action-btn" aria-label="上传图片" @click="emit('imageUpload')">
             <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
               <path
@@ -49,9 +48,9 @@
                 fill="currentColor" />
             </svg>
           </button>
-          <button type="button" class="action-btn" :class="{ 'voice-active': isVoiceInputMode }"
-            :aria-pressed="isVoiceInputMode" :disabled="isVoiceInputStarting" aria-label="语音输入"
-            @click="emit('voiceToggle')">
+          <button v-if="VOICE_INPUT_VISIBLE" type="button" class="action-btn"
+            :class="{ 'voice-active': isVoiceInputMode }" :aria-pressed="isVoiceInputMode"
+            :disabled="isVoiceInputStarting" aria-label="语音输入" @click="emit('voiceToggle')">
             <svg width="14" height="19" viewBox="0 0 14 19" fill="none">
               <path
                 d="M7 12c-.83 0-1.54-.29-2.13-.88A2.9 2.9 0 0 1 4 9V3c0-.83.29-1.54.87-2.13A2.9 2.9 0 0 1 7 0c.83 0 1.54.29 2.13.87C9.71 1.46 10 2.17 10 3v6c0 .83-.29 1.54-.87 2.12A2.9 2.9 0 0 1 7 12Zm-1 7v-3.08a7.04 7.04 0 0 1-4.3-2.32A6.91 6.91 0 0 1 0 9h2c0 1.38.49 2.56 1.46 3.54A4.82 4.82 0 0 0 7 14c1.38 0 2.56-.49 3.54-1.46A4.82 4.82 0 0 0 12 9h2c0 1.75-.57 3.28-1.7 4.6A7.04 7.04 0 0 1 8 15.92V19H6Zm1-9c.28 0 .52-.1.71-.29.19-.19.29-.43.29-.71V3c0-.28-.1-.52-.29-.71A.97.97 0 0 0 7 2a.97.97 0 0 0-.71.29A.97.97 0 0 0 6 3v6c0 .28.1.52.29.71.19.19.43.29.71.29Z"
@@ -63,11 +62,8 @@
         <div class="actions-right">
           <!-- <DeepseekButton v-model="deepThinkValue" /> -->
 
-          <!-- [已停用] 测试用 MOCK 按钮组(资料 / 问卷 / 流测 / 回放 / 打字 / Scope / 测试输入)——
-               用户 2026-09-21 要求注释掉。要恢复只需取消本段注释:
-               事件(testRefLinks / testQuestionCard / testMockStream / testReplay / testTypewriter /
-               testScopeMerge / testInput)与处理函数(sendRefLinksStreamTestMessage 等)**都还在**,
-              连同 `showTestActions`(= `MODE === 'development'`)这条开关也没动。 -->
+          <!-- 测试用 MOCK 按钮组(资料 / 问卷 / 流测 / 回放 / 打字 / Scope / 测试输入):
+               要恢复只需取消下一段注释(事件、处理函数与 showTestActions 开关都还在) -->
           <!--
           <div v-if="showTestActions" class="mock-stream-test-actions">
             <button type="button" class="mock-stream-test-action" :disabled="isGenerating"
@@ -136,6 +132,17 @@ const props = defineProps<{
 const showTestActions = computed(() => props.showTestActions === true);
 const showMockStreamTestButton = computed(() => props.showMockStreamTestButton === true);
 
+/**
+ * 语音输入入口是否显示。
+ *
+ * 当前部署在 **HTTP** 上:这不是安全上下文,`getUserMedia` 会被浏览器直接拒绝
+ * (`getUserMedia` 只在 HTTPS 或 localhost 下可用)⇒ 入口先隐藏,避免用户点了没反应。
+ *
+ * 要恢复:改成 `window.isSecureContext`(HTTPS/localhost 下自动出现、HTTP 下自动隐藏)。
+ * 组件里的 props / emits / ASR 逻辑都还在,改这一行即可。
+ */
+const VOICE_INPUT_VISIBLE = false;
+
 const emit = defineEmits<{
   'update:modelValue': [value: string];
   'update:isDeepThink': [value: boolean];

+ 15 - 42
src/components/BusinessAssistantPC.vue

@@ -1,9 +1,6 @@
 <template>
   <div class="ba-pc-container">
-    <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
-    <!-- [已停用] 全局提示改由 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast)
-
-         —— 原来的 <Toast> 是全屏黑遮罩 + 居中白框,用户说"太丑了" -->
+    <!-- 全局提示:走 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast) -->
     <div class="bg-image" v-show="!currentSession" id="particle-bg" aria-hidden="true"></div>
 
     <aside class="aside-nav">
@@ -344,11 +341,8 @@
 import { ref, onMounted, onBeforeUnmount, nextTick, computed, provide, watch, reactive } from 'vue';
 import { useResizeObserver } from '@vueuse/core';
 import BusinessRecord from "./Chat/BusinessRecord.vue";
-// [已停用] import Toast from "./Common/Toast.vue";
 import ScrollList from './Common/ScrollList.vue';
-// 实时语音识别:**阿里云 NLS 直连**(2026-09-23 换)。
-// 对外契约与旧厂商 SDK(`@/three-libs/asr/index`)一致,所以这里只有这一行变了;
-// 切回旧实现也只改这一行。详见 harness/docs/reference/asr-nls.md
+// 实时语音识别:走阿里云 NLS(实现见 @/asr/nls,说明见 harness/docs/reference/asr-nls.md)
 import ASR from '@/asr/nls';
 import ParticleBackground from "./Common/ParticleBackground.vue";
 import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
@@ -363,10 +357,6 @@ import { createModalMutex, MODAL_MUTEX_KEY } from './business-assistant/modal-mu
 
 provide(MODAL_MUTEX_KEY, createModalMutex());
 
-provide("formOptionsControl", {
-  setFormOptions: () => { }
-});
-
 provide("speechControl", {
   stopSpeech: () => { },
   handleStopSpeech: () => { },
@@ -390,12 +380,8 @@ const {
   clearUploadDraft,
 } = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
 
-/**
- * 上传校验失败时的提示通道。
- * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
- * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
- */
-let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
+/** 上传校验失败时的提示通道:showToast 由后面的 useBusinessAssistantChat 提供,这里先用占位再接上 */
+let notifyUpload: (message: string) => void = (message) => { /* console.warn('[upload]', message); */ };
 
 const {
   isLoggedIn,
@@ -555,9 +541,8 @@ const handleMainDrop = (event: DragEvent) => {
   const fileList = Array.from(event.dataTransfer?.files || []);
   if (fileList.length === 0) return;
 
-  // 只按「是不是图片」分两条路,**格式校验交给 handleFiles 统一做** ——
-  // 这样拖进来的不支持格式会走同一条 toast 提示(此前是这里提前过滤掉、静默丢弃,
-  // 用户不知道文件为什么没进去;用户 2026-09-23 要求「不支持的格式要 toast 提示」)。
+  // 只按「是不是图片」分两条路,格式校验交给 handleFiles 统一做
+  // (这样拖进来的不支持格式会走同一条 toast 提示,而不是被静默丢弃)
   const imageFiles = fileList.filter(isSupportedImageFile);
   const attachmentFiles = fileList.filter((file) => !isSupportedImageFile(file));
 
@@ -658,13 +643,9 @@ const messageRowMinHeights = reactive<Record<string, number>>({});
 const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
 const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
 /**
- * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
- * 而不是贴到底部(用户 2026-09-21 选择的做法)。
- *
- * 为什么需要:卡片块 / 参考资料块是**一次插入**的(实测 300~900px),
- * 贴到底部等于一步滚过整块 —— 用户刚拿到卡片却看不到卡片。
- * 取值依据:正文流式增长是逐片累积的(单次 ResizeObserver 回调通常几十 px),
- * 240px 明显高于它、又低于卡片块,正好只拦「一整块」。
+ * 「一整块到达」的高度阈值:超过它就把块的**顶部**对到视口顶部,而不是贴到底部。
+ * 卡片 / 参考资料块是一次插入的整块(实测 300~900px),贴底等于一步滚过整块;
+ * 而正文流式增长单次回调只有几十 px,所以取 240px 正好只拦「一整块」。
  */
 const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
 /** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
@@ -992,7 +973,7 @@ const stopVoiceInput = async () => {
         inputText.value = result.text;
       }
     } catch (e) {
-      console.error(e);
+      /* console.error(e); */
     }
     asrInstance.close();
     asrInstance.clear();
@@ -1043,9 +1024,8 @@ const toggleVoiceInput = async () => {
     }
 
   } catch (error) {
-    console.error("Voice input error:", error);
-    // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、
-    // 样式也和全站其他提示不一致(用户 2026-09-23 要求语音这几处一并换掉)
+    /* console.error("Voice input error:", error); */
+    // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、样式也不一致
     showToast(error instanceof Error ? error.message : "语音输入初始化失败");
     await stopVoiceInput();
   }
@@ -1161,12 +1141,9 @@ const releaseScopeHeightLockIfReady = () => {
 };
 
 /**
- * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
- *
- * 为什么:这类块是一次插入的整块,贴到底部等于一步滚过整块 —— 用户刚拿到卡片却看不到卡片。
- * 对顶之后不调用 `syncAutoScrollFollowState()`:自动贴底保持关闭,正文继续在下方流式增长;
+ * 大块内容(卡片 / 参考资料)到达时,把它自己的顶部对到视口顶部。
+ * 对顶之后**不调用** `syncAutoScrollFollowState()`:自动贴底保持关闭,正文继续在下方流式增长;
  * 用户自己滚回底部附近时(`handleBusinessScrollPositionChange`)会自动恢复跟随。
- *
  * 拿不到元素 / DOM 时返回 false,调用方照旧走贴底逻辑。
  */
 const anchorLargeBlockTop = (heightGrowth: number): boolean => {
@@ -1287,14 +1264,12 @@ const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: numb
     heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
   ) {
     // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
-    // (用户 2026-09-21 选定;详见 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH 的说明)
     if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
       payload?.onComplete?.();
       return;
     }
 
-    // 高度增加后,判断新增前是否贴近底部。
-    // 如果现在距离底部极近 (<= 24px),则瞬间跳到底部,否则平滑滚动
+    // 高度增加后判断新增前是否贴近底部:极近(<= 24px)则瞬间跳到底部,否则平滑滚动
     if (distanceAfterGrowth <= 24) {
       scrollBusinessAssistantListToBottom(scrollListRef.value, "instant");
     } else {
@@ -2211,7 +2186,6 @@ textarea::placeholder {
   color: #3525cd;
 }
 
-/* Attachment Display Area */
 .attachments-area {
   display: flex;
   flex-wrap: wrap;
@@ -2353,7 +2327,6 @@ textarea::placeholder {
   color: #464555;
 }
 
-/* Tooltip Styles */
 .tooltip-container {
   position: relative;
   display: inline-flex;

+ 8 - 8
src/components/Chat/AccountInfo.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="account-info-shell">
-    <!-- 未登录 - USwG5 -->
+    <!-- 未登录 -->
     <div v-if="!isLoggedIn" class="account-row" @click="redirectToLogin" role="button" tabindex="0">
       <div class="avatar-circle avatar-not-logged-in">
         <svg
@@ -22,7 +22,7 @@
       </div>
     </div>
 
-    <!-- 已登录 - GCCdI / O9TTe on hover -->
+    <!-- 已登录(hover 显示退出按钮) -->
     <div
       v-else
       class="account-row account-row-logged-in"
@@ -57,7 +57,7 @@
       </button>
     </div>
 
-    <!-- 关注企业提示气泡 - Figma H0F6Y -->
+    <!-- 关注企业提示气泡 -->
     <Teleport to="body">
       <Transition name="enterprise-bubble">
         <div
@@ -98,7 +98,7 @@
       </Transition>
     </Teleport>
 
-    <!-- 退出确认弹窗 - f8Vjfz -->
+    <!-- 退出确认弹窗 -->
     <Teleport to="body">
       <Transition name="logout-fade">
         <div
@@ -206,7 +206,7 @@ const handleLogout = () => {
   emit("logout");
 };
 
-// 用户登录后清掉 dismiss 状态,便于后续再次触发
+// 登录后清掉 dismiss 状态,便于后续再次触发
 watch(isLoggedIn, (loggedIn) => {
   if (loggedIn) {
     dismissEnterpriseBubble();
@@ -345,7 +345,7 @@ onBeforeUnmount(() => {
   background: rgba(0, 0, 0, 0.04);
 }
 
-/* 关注企业气泡 - Figma H0F6Y */
+/* 关注企业气泡 */
 .enterprise-bubble-portal {
   position: fixed;
   z-index: 9999;
@@ -443,7 +443,7 @@ onBeforeUnmount(() => {
   transform: translateX(-100%);
 }
 
-/* 退出确认弹窗 - f8Vjfz */
+/* 退出确认弹窗 */
 .logout-overlay {
   position: fixed;
   top: 0;
@@ -549,7 +549,7 @@ onBeforeUnmount(() => {
   background: #2c1ea8;
 }
 
-/* Transition */
+/* 出现 / 消失动画:左侧划入 */
 .logout-fade-enter-active,
 .logout-fade-leave-active {
   transition: opacity 0.2s ease;

+ 41 - 161
src/components/Chat/BusinessRecord.vue

@@ -113,8 +113,6 @@
                   @typingFinished="handleTypingFinished(row.id)"
                   @typingStateChange="handleTypingStateChange(row.id, $event)"
                 />
-                <FormContent :content="row.content" v-if="row.type === RecordRowTypes.Form" />
-                <LinkURLContent :content="row.content" v-if="row.type === RecordRowTypes.LinkURL" />
                 <ImageContent
                   :content="row.content"
                   v-if="row.type === RecordRowTypes.ImageContent"
@@ -122,10 +120,6 @@
                   @revealFinished="handleImageRevealFinished(row.id)"
                   @revealStateChange="handleImageRevealStateChange(row.id, $event)"
                 />
-                <OfficeURLContent
-                  :content="row.content"
-                  v-if="row.type === RecordRowTypes.OfficeURL"
-                />
                 <div
                   v-if="row.type === RecordRowTypes.Scope"
                   class="scope-content-shell"
@@ -185,7 +179,6 @@
                 </div>
               </template>
 
-              <!-- media display holder -->
               <div
                 class="media-row"
                 :class="{ 'mt-1': visibleInlineDisplayRecordRows.length === 0 }"
@@ -232,7 +225,6 @@
                   <div v-if="each.type === 'video'" class="play-button"></div>
                 </div>
               </div>
-              <!-- media display holder end -->
             </template>
           </div>
           <div class="tips-wrap" v-if="record.isTips">
@@ -311,7 +303,7 @@
                 <div class="ref-links-card-title">{{ item.title || "参考资料" }}</div>
                 <div v-if="item.type" class="ref-links-card-tag">{{ item.type }}</div>
               </div>
-              <!-- 来源元信息:发布机构 / 发布或更新时间(见接口文档 §9 source 统一字段) -->
+              <!-- 来源元信息:发布机构 / 发布或更新时间 -->
               <div v-if="getRefLinkMeta(item)" class="ref-links-card-content">
                 {{ getRefLinkMeta(item) }}
               </div>
@@ -344,11 +336,6 @@
     </div>
   </Teleport>
   <DislikeDialog v-model:visible="showDislikeDialog" @confirm="handleDislikeConfirm" />
-  <!-- [已停用] 本组件挂的全局提示:①它用的是旧的 <Toast>(全屏黑遮罩+居中白框,用户说"太丑了")
-
-       ②下面的 showToast() 在本文件里**没有任何调用点**,等于死代码。
-
-       全站提示统一走 @/utils/app-message(Ant Design 的 message,顶部弹出) -->
 </template>
 <script setup lang="ts">
 import {
@@ -378,10 +365,7 @@ import ReasoningWidget from "../Common/ReasoningWidget.vue";
 import RelatedQuestions from "../Common/RelatedQuestionsWidget.vue";
 import References from "../Common/References.vue";
 import TextContent from "./TextContent.vue";
-import FormContent from "./FormContent.vue";
-import LinkURLContent from "./LinkURLContent.vue";
 import ImageContent from "./ImageContent.vue";
-import OfficeURLContent from "./OfficeURLContent.vue";
 import ScopeContent from "./ScopeContent.vue";
 import PolicyMatch from "./PolicyMatch.vue";
 import QuestionCard from "./QuestionCard.vue";
@@ -393,7 +377,6 @@ import {
   POLICY_TABLE_END,
   type RefLinkItem,
 } from "@/utils/stream-xml-filter";
-import { jsonrepair } from "jsonrepair";
 import { isInterruptedEmptyScopeMessage } from "@/utils/interrupted-message";
 import {
   splitStreamingTextContentRows,
@@ -409,7 +392,7 @@ import {
   shouldForcePendingScope,
 } from "@/utils/scope-record-rows";
 import { createModalMutex, MODAL_MUTEX_KEY } from "../business-assistant/modal-mutex";
-// 复制按钮用:把 markdown 语法符号转成纯文本(用户 2026-09-22:「复制别带 md 的符号语法」)
+// 复制按钮用:把 markdown 语法符号转成纯文本
 import { markdownToPlainText } from "./markdown-plain-text";
 
 enum RecordRowStates {
@@ -418,7 +401,6 @@ enum RecordRowStates {
   Displayed = "displayed",
 }
 
-// 定义 displayRecordRow 类型
 type DisplayRecordRow = {
   id: number;
   type: RecordRowTypes;
@@ -476,8 +458,6 @@ const textAnimWrapper = ref();
 const showTextAnim = ref(false);
 const localFeedback = ref<FeedbackType>(FeedbackType.None);
 const showDislikeDialog = ref(false);
-// [已停用] 见模板里的说明:无调用点,且用的是旧 Toast
-// const toastMessage = ref("");
 let pendingDislikeRecordId: string | number | null = null;
 let toastTimer: ReturnType<typeof setTimeout> | undefined;
 const isFormQueryMode = getQueryObj("mode") === "form";
@@ -493,23 +473,12 @@ const modalMutex = inject(MODAL_MUTEX_KEY, null) ?? createModalMutex();
 provide(MODAL_MUTEX_KEY, modalMutex);
 let unregisterRefLinksModal: (() => void) | undefined;
 
-// [已停用] 无调用点(全站提示统一走 @/utils/app-message 的 showAppMessage);
-// 它用的旧 <Toast> 是全屏黑遮罩 + 居中白框,用户 2026-09-23 说"太丑了"。
-// function showToast(message: string) {
-//   toastMessage.value = message;
-//   if (toastTimer) clearTimeout(toastTimer);
-//   toastTimer = setTimeout(() => {
-//     toastMessage.value = "";
-//   }, 3000);
-// }
-
 function openDislikeDialog(recordId: string | number) {
   pendingDislikeRecordId = recordId;
   showDislikeDialog.value = true;
 }
 
-// 反馈写入 DMS 的问答记录(栏目 1889 的 c_feedback_*),按 c_record_id 定位那一行。
-// recordId 就是 AI 消息 id,与写入时的 c_record_id 是同一个值(见 chat-sessions-dms.ts)
+// 反馈写入 DMS 的问答记录,按 c_record_id 定位那一行(recordId 即 AI 消息 id)
 function submitLikeFeedback(recordId: string | number) {
   void writeDmsFeedback({
     recordId: String(recordId),
@@ -541,11 +510,6 @@ function handleDislikeConfirm(option: string, remark: string) {
 
 provide("feedbackControl", { openDislikeDialog, submitLikeFeedback, cancelFeedback });
 provide("dislikeDialogControl", { openDislikeDialog });
-// [已停用] 没有任何子组件 inject("toastControl")(全仓库只有这一行 provide),
-// 与其配套的 showToast 也无人调用 —— 一起停用。
-// provide("toastControl", { showToast });
-
-const { setFormOptions } = inject("formOptionsControl") as any;
 
 const instance = getCurrentInstance();
 const $mediaViewer = instance?.proxy?.$mediaViewer as any;
@@ -559,22 +523,7 @@ const record = computed(() => {
 
   return _record;
 });
-const xmlFilter = new StreamXMLFilter({
-  customProcessor: new Map([
-    [
-      RecordRowTypes.UIForm,
-      (payload: { schema: string; attrs: Record<string, string> }) => {
-        setFormOptions({
-          visible: true,
-          enable: true,
-          mode: "form",
-          schema: JSON.parse(jsonrepair(payload.schema)) || [],
-          title: payload.attrs.title,
-        });
-      },
-    ],
-  ]),
-});
+const xmlFilter = new StreamXMLFilter();
 const { stopSpeech, handleEndSpeech } = inject("speechControl") as any;
 let isEffectHumanState: any;
 
@@ -594,18 +543,9 @@ enum SchedulerTaskState {
 }
 const getRefLinkItems = (content: string) => parseRefLinksContent(content);
 
-// 复制按钮只应复制可读正文:
-// ① 去掉 <scope>、<!-- POLICY_TABLE -->、<ref_links>、<question-cards>、<image-scope>
-//    (企业微信二维码)等协议标记,避免复制出一大段 JSON 或图片地址;
-// ② 再把 markdown **语法符号**转成纯文本(`###`、`**`、`[文字](链接)` 等)——
-//    用户 2026-09-22 反馈「复制出来的内容还带着 md 符号」。转换见 markdown-plain-text.ts。
-//
-// 注意不能用 getStreamPlainText():它对整段内容做一次性解析,而过滤器在遇到
-// POLICY_TABLE 那个 HTML 注释式的块起点时,会把同批 buffer 里位于它之前的内容
-// 直接当纯文本输出,导致 scope 标记被当成正文保留下来。
-// 这里直接剥离标记,结果与内容分片方式无关。
-// (⚠️ 本文件里**不要**写出裸的 HTML 注释起始符:Vite 的依赖预扫描会把 .vue 当 HTML,
-//   把「起始符…后面的某个结束符」整段吃掉 —— 2026-09-28 冷扫描就是被这里搞挂的)
+// 复制按钮只复制可读正文:先剥掉 scope / POLICY_TABLE / ref_links / question-cards /
+// image-scope 等协议标记(否则会把 JSON、图片地址一起复制出去),再转成纯文本。
+// ⚠️ 本文件不要写出裸的 HTML 注释起始符:Vite 的依赖预扫描会把 .vue 当 HTML,整段吃掉。
 const copyableContent = computed(() => {
   const raw = String(record.value.content || props.content || "");
   const withoutMarkers = raw
@@ -619,8 +559,7 @@ const copyableContent = computed(() => {
   return markdownToPlainText(withoutMarkers);
 });
 
-// 参考资料卡片的元信息行:发布机构 + 发布/更新时间。
-// 字段来自接口文档 §9「原始 source 对象中的统一字段」,经 originalData 透传。
+// 参考资料卡片的元信息行:发布机构 + 发布/更新时间(经 originalData 透传)
 const getRefLinkMeta = (item: any) => {
   const data = item?.originalData || {};
   const parts: string[] = [];
@@ -744,7 +683,6 @@ const textRowTypingActive = ref<Record<number, boolean>>({});
 const imageRowRevealFinished = ref<Record<number, boolean>>({});
 const imageRowRevealActive = ref<Record<number, boolean>>({});
 
-// displayRecordRows 类型增强
 const displayRecordRows = ref<DisplayRecordRow[]>([]);
 const updateRecordRowIndex = ref(0);
 const activeRecordState = computed(() => props.state ?? record.value.state);
@@ -926,9 +864,7 @@ const inlineDisplayRecordRows = computed(() => {
   return visibleDisplayRecordRows.value.filter((row) => row !== outsideRow);
 });
 const isTextRowFullyCompleted = (row: DisplayRecordRow) =>
-  // 文本行完成条件只认打字机动画结束事件(textRowTypingFinished[row.id])。
-  // 不能依赖 row.state === Displayed:段落边界(breakAfter)会把上一行提前标记为
-  // Displayed,但此时打字机可能还没播完最后几个字,会导致下一行提前开始渲染。
+  // 完成条件只认打字机结束事件:段落边界会把上一行提前标为 Displayed,但打字机可能还没播完
   row.type !== RecordRowTypes.Text || textRowTypingFinished.value[row.id] === true;
 const isImageRowFullyRevealed = (row: DisplayRecordRow) =>
   row.type !== RecordRowTypes.ImageContent || imageRowRevealFinished.value[row.id];
@@ -942,8 +878,7 @@ const hasUnfinishedRevealRows = computed(() =>
 const shouldDriveTypewriter = computed(
   () =>
     canAnimateAiText.value &&
-    // F073:正文本身是真实增量流式到达的,再逐字重放等于凭空多等一遍 —— 关掉打字机
-    // (TextContent 的 `instant || !activeTypewriter` 分支会转成即时渲染)
+    // 正文本身是增量流式到达的,再逐字重放等于多等一遍,故关掉打字机
     !record.value.contentStreaming &&
     (activeRecordState.value !== MessageState.Finish || hasUnfinishedRevealRows.value)
 );
@@ -960,31 +895,25 @@ const shouldWaitForTypewriterCompletion = computed(
   () => canAnimateAiText.value && hasUnfinishedRevealRows.value
 );
 
-// 反馈组件显示策略:
-// - 非 AI 消息:永不显示
-// - 已评价(赞/踩/未选中):始终显示
-// - 历史会话(sessionViewedBefore)的 AI 消息:始终显示(不需要等动画)
-// - 新消息:等 SSE 流结束 + 打字机/媒体动画都结束才显示
+// 反馈组件显示策略:非 AI 消息不显示;已评价或历史会话立即显示;新消息等流结束、动效播完才显示
 const shouldRenderFeedback = computed(() => {
   if (!record.value.isAI) return false;
   if (localFeedback.value !== FeedbackType.None) return true;
   if (props.sessionViewedBefore) return true;
   if (activeRecordState.value !== MessageState.Finish) return false;
-  // 打字机 / 图片 reveal 还没结束:不显示
   if (shouldWaitForTypewriterCompletion.value) return false;
-  // 媒体列表(图片/视频)还在渐进式加载:不显示
   if ((mediaList.value?.length || 0) > displayMediaList.value.length) return false;
   return true;
 });
 const emitTypewriterStateChange = (isTypingActive: boolean) => {
-  console.log("[BusinessRecord][typewriter-state]", {
+  /* console.log("[BusinessRecord][typewriter-state]", {
     recordId: record.value.id,
     isLastRecord: props.isLastRecord,
     isAI: record.value.isAI,
     isTypingActive,
     textRowTypingActive: { ...textRowTypingActive.value },
     imageRowRevealActive: { ...imageRowRevealActive.value },
-  });
+  }); */
   emit("typewriterStateChange", {
     recordId: record.value.id,
     isTypingActive,
@@ -998,12 +927,12 @@ const syncTypewriterState = () => {
       Object.values(imageRowRevealActive.value).some(Boolean))
   );
 
-  console.log("[BusinessRecord][typewriter-sync]", {
+  /* console.log("[BusinessRecord][typewriter-sync]", {
     recordId: record.value.id,
     isLastRecord: props.isLastRecord,
     isAI: record.value.isAI,
     isTypingActive,
-  });
+  }); */
 
   emitTypewriterStateChange(isTypingActive);
 };
@@ -1133,8 +1062,8 @@ const processRecordSections = (input: string) => {
     return;
   }
 
-  // 起止标记取自 `@/utils/stream-xml-filter`(**不要在 .vue 里再写字面量**:
-  // 裸的 HTML 注释起始符会被 Vite 的依赖预扫描当注释、吃掉后面若干行,见该文件顶部说明)
+  // 起止标记取自 @/utils/stream-xml-filter:不要在 .vue 里写裸的 HTML 注释起始符
+  // (Vite 的依赖预扫描会把 .vue 当 HTML,吃掉后面若干行)
   let rest = input;
 
   while (rest.length > 0) {
@@ -1160,14 +1089,7 @@ const processRecordSections = (input: string) => {
   }
 };
 
-/**
- * 诊断(仅非生产):**已经显示过的内容**被改小或被删掉时打一行。
- *
- * 为什么只盯这两种:用户报的「一小段重新渲染」在渲染层只可能来自两类操作 ——
- *   ① 整条消息被重置重解析(已有 `[record] 内容被整块重写` 那行)
- *   ② 某一行**已经显示过**的内容被改写 / 被删除(就是这里)
- * 纯追加(流式增长)不会重渲任何已有内容,所以完全不必打点(打了每帧都会刷屏)。
- */
+/** 诊断(仅非生产):已显示过的内容被改写或删掉时打一行(纯追加不会重渲,故不打点) */
 const debugRowMutation = (
   what: string,
   row: DisplayRecordRow | undefined,
@@ -1177,19 +1099,16 @@ const debugRowMutation = (
   if (import.meta.env.MODE === "production") {
     return;
   }
-  console.warn(`[record] ${what}`, {
+  /* console.warn(`[record] ${what}`, {
     rowId: row?.id,
     type: row?.type,
     before: String(before ?? "").length,
     after: String(after ?? "").length,
     head: String(before ?? "").slice(0, 40),
-  });
+  }); */
 };
 
-/**
- * 关闭一行文本:去掉尾部空白,并在**真的变短**时打一行诊断
- * —— 变短意味着这一行已经显示过的内容被改写,会重渲(纯追加不会)。
- */
+/** 关闭一行文本:去掉尾部空白;真的变短时打一行诊断(变短意味着已显示内容被改写会重渲) */
 const closeTextRow = (row: DisplayRecordRow) => {
   const before = row.content;
   const after = trimDisplayTextContentEnd(before);
@@ -1210,8 +1129,7 @@ const resetDisplayContent = () => {
   imageRowRevealFinished.value = {};
   imageRowRevealActive.value = {};
   hasVisibleAnswerContent.value = false;
-  // ⚠️ scope 退场补偿态也要清:`isScopeExitCompensating` 为真时**正文行是隐藏的**
-  // (见 shouldRenderTextContentRow),残留会让重写后的正文整段不显示
+  // scope 退场补偿态也要清:它为真时正文行是隐藏的(见 shouldRenderTextContentRow)
   clearScopeExitTimer();
   scopeExitHeight.value = 0;
   scopeExitWidth.value = 0;
@@ -1317,14 +1235,13 @@ const beginScopeExit = (scopeRow: DisplayRecordRow | null) => {
   scopeExitWidth.value = exitWidth;
   isScopeExitBodyTransitionDisabled.value = true;
   if (import.meta.env.MODE !== "production") {
-    // 诊断用:`isScopeExitCompensating` 为真时**所有正文行都不渲染**(见 shouldRenderTextContentRow),
-    // 补偿结束再挂回来。如果这一步发生在正文已经流出一段之后,观感就是「一小段正文重渲」。
-    // 出问题时看这行的 `已渲染正文行数`:>0 就说明确实有已显示的正文被藏起来又挂回。
-    console.warn("[record] scope 退场补偿开始(正文行会暂时隐藏)", {
+    // 诊断用:补偿态为真时所有正文行都不渲染(见 shouldRenderTextContentRow),
+    // textRows > 0 说明确有已显示的正文被藏起来又挂回
+    /* console.warn("[record] scope 退场补偿开始(正文行会暂时隐藏)", {
       exitHeight,
       textRows: displayRecordRows.value.filter((row) => row.type === RecordRowTypes.Text).length,
       policyRows: displayRecordRows.value.filter((row) => row.type === RecordRowTypes.PolicyTable).length,
-    });
+    }); */
   }
   emit("scopeHeightLockChange", {
     recordId: record.value.id,
@@ -1564,7 +1481,6 @@ const getLastRecordRow = () => {
   return displayRecordRows.value[displayRecordRows.value.length - 1];
 };
 
-// 优化和修正逻辑,确保 <form> 区间处理更严谨
 const handleTimerTick = (val: string) => {
   if (count.value >= val.length) {
     clearExistingTimer();
@@ -1576,14 +1492,10 @@ const handleTimerTick = (val: string) => {
 
   updateDisplayContent(val);
 
-  // 判断当前字符是否存在,防止越界
   if (typeof val[count.value] !== "undefined") {
     showTextAnim.value = false;
-    // 添加当前字符到动画显示区
     addText(val[count.value]);
-    // 通知外部内容有变化
     emit("displayContentChange");
-    // 计数器自增,准备下一个字符
     count.value++;
   }
 };
@@ -1599,30 +1511,17 @@ watch(
       return;
     }
 
-    /**
-     * ⚠️ 内容**不是纯追加**时必须整体重置再全量解析。
-     *
-     * 这里的增量解析只吃「比上次多出来的那一段」(`val.slice(count)`),
-     * 所以消息内容被**整块重写**(不是往后追加)时,切出来的会是新内容的**中段**——
-     * 那段中段会被当正文渲染出来(踩过:参考资料 JSON 的中段变成一坨文本显示在界面上)。
-     *
-     * 会整块重写的场景(F073 起):
-     * - 流式收尾的**卡片重排**(把政策卡片挪到正文之上)
-     * - `answer_end` 与增量拼接不一致时的整块重建
-     * - 撤草稿(内容被截短回基线)
-     * 纯追加(流式 delta / answer_end 的尾缀补齐)仍走增量路径,不重置、不闪。
-     */
+    // 内容不是纯追加时必须整体重置再全量解析:增量解析只吃多出来的那一段(val.slice(count)),
+    // 整块重写时切出来的是新内容的中段,会被当正文渲染出来。
     if (!val.startsWith(originContent.value)) {
       changeFlag++;
       resetDisplayContent();
-      xmlFilter.reset(); // 解析器也是有状态的(buffer / 未闭合标签),一并清
+      xmlFilter.reset(); // 解析器也有状态(buffer / 未闭合标签),一并清
       if (import.meta.env.MODE !== "production") {
-        // 诊断用:整块重写会让整条消息重新解析(观感上是"内容被换掉")。
-        // 出问题时看这行就知道是哪个长度差触发的。
-        console.warn("[record] 内容被整块重写,已重置并全量重解析", {
+        /* console.warn("[record] 内容被整块重写,已重置并全量重解析", {
           before: originContent.value.length,
           after: val.length,
-        });
+        }); */
       }
     }
 
@@ -1636,18 +1535,8 @@ watch(
 );
 
 /**
- * F073:流式轮收尾(`contentStreaming` 由真变假)时,把**已经在屏幕上的文本行**补记为
- * 「打字完成」—— 它们在流式期间是**即时渲染**的(打字机关着),早就完整显示过了。
- *
- * 不补记的后果(2026-09-21 用户报的两个观感问题都源于此):
- * ① `hasUnfinishedRevealRows` 恒为真 → `shouldDriveTypewriter` 在收尾那一刻翻真
- * ② `visibleInlineDisplayRecordRows` 于是走「阻塞」分支:**最后一条未完成文本行之后的行**
- *    (正文之后就是 notices / 参考资料那几行)被从 DOM 里拿掉 → 容器变矮 → 滚动位置被钳制,
- *    视图突然跳一下;随后挂回来时又是**新组件实例**
- * ③ 新实例走 TextContent 的 `watch-initial` → `startTypewriter(0)` 把内容重打一遍,
- *    并上报「打字中」→ 上级据此把视图**拽到最后一条 AI 消息的顶部**("瞬间返回到回答顶部")
- *
- * 非流式轮(mock / 旧后端)从不置 `contentStreaming`,这个 watch 不会触发,行为不变。
+ * 流式轮收尾时把已经在屏幕上的文本行补记为「打字完成」——它们在流式期间是即时渲染的。
+ * 不补记会让收尾那一刻打字机突然翻真,未完成行之后的行被移出 DOM,视图跳动且内容重打一遍。
  */
 const markStreamedRowsAsTypingFinished = () => {
   let changed = false;
@@ -1673,9 +1562,6 @@ watch(
   }
 );
 
-// 反馈组件显示由 shouldRenderFeedback 控制:历史会话立即展示,
-// 新消息等打字机/媒体动画结束(triggerMediaAndFeedback 内把
-// localFeedback 切到 Unselected)后才展示。
 watch(
   () => record.value.state,
   async (val) => {
@@ -1771,15 +1657,14 @@ const addText = (text: string) => {
   setTimeout(onAnimationEnd, 200);
 };
 
-// Handle TextContent typing finished
 const handleTypingFinished = (rowId: number) => {
   textRowTypingFinished.value[rowId] = true;
   textRowTypingActive.value[rowId] = false;
-  console.log("[BusinessRecord][typing-finished]", {
+  /* console.log("[BusinessRecord][typing-finished]", {
     recordId: record.value.id,
     rowId,
     isLastRecord: props.isLastRecord,
-  });
+  }); */
   syncTypewriterState();
   emit("refreshScroll");
 };
@@ -1789,23 +1674,23 @@ const handleTypingStateChange = (rowId: number, isTypingActive: boolean) => {
   if (isTypingActive) {
     textRowTypingFinished.value[rowId] = false;
   }
-  console.log("[BusinessRecord][typing-state-change]", {
+  /* console.log("[BusinessRecord][typing-state-change]", {
     recordId: record.value.id,
     rowId,
     isTypingActive,
     isLastRecord: props.isLastRecord,
-  });
+  }); */
   syncTypewriterState();
 };
 
 const handleImageRevealFinished = (rowId: number) => {
   imageRowRevealFinished.value[rowId] = true;
   imageRowRevealActive.value[rowId] = false;
-  console.log("[BusinessRecord][image-reveal-finished]", {
+  /* console.log("[BusinessRecord][image-reveal-finished]", {
     recordId: record.value.id,
     rowId,
     isLastRecord: props.isLastRecord,
-  });
+  }); */
   syncTypewriterState();
   emit("refreshScroll");
 };
@@ -1815,12 +1700,12 @@ const handleImageRevealStateChange = (rowId: number, isRevealActive: boolean) =>
   if (isRevealActive) {
     imageRowRevealFinished.value[rowId] = false;
   }
-  console.log("[BusinessRecord][image-reveal-state-change]", {
+  /* console.log("[BusinessRecord][image-reveal-state-change]", {
     recordId: record.value.id,
     rowId,
     isRevealActive,
     isLastRecord: props.isLastRecord,
-  });
+  }); */
   syncTypewriterState();
 };
 
@@ -1974,9 +1859,6 @@ function recordParser(input: string) {
   });
 }
 
-// 流式处理文本,自动分段
-// 处理流式和完整输入,支持断续和完整的 val
-
 function openPreview(media: any, index: number, e: any) {
   emit("changeIdleState", "interruptSpeech");
 
@@ -2005,8 +1887,6 @@ function handleError(e: any) {
 
 async function handleFeedbackChange(feedback: FeedbackType) {
   localFeedback.value = feedback;
-  // [已移除] 反馈统计埋点:统计接口(老接口)已按用户要求删除(2026-09-18)。
-  // 反馈本身仍写入 DMS(见 submitLikeFeedback / cancelFeedback / handleDislikeConfirm)
   emit("feedback", feedback, record.value.id);
 }
 </script>

+ 0 - 229
src/components/Chat/FormContent.vue

@@ -1,229 +0,0 @@
-<template>
-  <div class="form-content-wrap" :style="{ height: `${dynamicHeight}px` }">
-    <Transition name="fade">
-      <div class="form-content" ref="formContentRef" v-if="Object.values(parsedContent).length">
-        <div v-for="(value, key) in parsedContent" :key="key" class="form-field">
-          <template v-if="typeof value === 'boolean'">
-            <span>
-              <template v-if="value">
-                <CheckOutlined />
-              </template>
-              <template v-else>
-                <CloseOutlined />
-              </template>
-            </span>
-            <strong style="margin-left: 8px;">{{ key }}</strong>
-          </template>
-          <template v-else>
-            <strong>{{ key }}:</strong>
-            <template v-if="Array.isArray(value)">
-              <ul>
-                <li v-for="(item, idx) in value" :key="idx">
-                  <template v-if="typeof item === 'object' && item !== null">
-                    <span v-for="(v, k, i) in item" :key="k">
-                      <strong v-if="i === 0">{{ k }}:</strong>
-                      <span v-else>{{ k }}:</span>
-                      {{ v }}
-                      <span v-if="i < Object.keys(item).length - 1">, </span>
-                    </span>
-                  </template>
-                  <template v-else>
-                    {{ item }}
-                  </template>
-                </li>
-              </ul>
-            </template>
-            <template v-else>
-              <template v-if="typeof value === 'object' && value !== null">
-                <div style="text-indent: 2em;" v-for="(v, k, i) in value" :key="k">
-                  <template v-if="typeof v === 'boolean'">
-                    <span>
-                      <template v-if="v">
-                        <CheckOutlined />
-                      </template>
-                      <template v-else>
-                        <CloseOutlined />
-                      </template>
-                    </span>
-                    <strong style="margin-left: 8px;">{{ k }}</strong>
-                  </template>
-                  <template v-else>
-                    <strong>{{ k }}:</strong> {{ v }}
-                  </template>
-                </div>
-              </template>
-              <template v-else>
-                <span>{{ value }}</span>
-              </template>
-            </template>
-          </template>
-        </div>
-      </div>
-      <div v-else-if="error" class="error">{{ error }}</div>
-      <div v-else-if="loading" class="loading">
-        <LoadingOutlined style="color: #fff;" />
-      </div>
-    </Transition>
-  </div>
-</template>
-
-<script setup>
-import { ref, watch, onMounted, nextTick } from "vue";
-import { LoadingOutlined, CheckOutlined, CloseOutlined } from "@ant-design/icons-vue";
-
-const props = defineProps({
-  content: {
-    type: String,
-    default: "",
-  },
-});
-
-const loading = ref(true);
-const error = ref("");
-const parsedContent = ref({});
-const dynamicHeight = ref(150)
-const formContentRef = ref(null);
-
-let loadingTimer = null;
-const LOADING_THROTTLE_DELAY = 200;
-
-function updateDynamicHeight() {
-  nextTick(() => {
-    // 只有在内容渲染后才测量高度
-    if (formContentRef.value) {
-      // 额外加上padding等
-      const contentHeight = formContentRef.value.offsetHeight;
-      // 150为最小高度
-      dynamicHeight.value = Math.max(contentHeight + 32, 150);
-    } else {
-      dynamicHeight.value = 150;
-    }
-  });
-}
-
-async function fetchFormData() {
-  error.value = "";
-  loading.value = true;
-  if (loadingTimer) clearTimeout(loadingTimer);
-
-  try {
-    // 解析 <form ...>...</form> 结构
-    const formMatch = (props.content || "").match(
-      /<form\s*([^>]*)>([\s\S]*?)<\/form>/i
-    );
-    let template = "";
-    let jsonStr = "";
-    if (formMatch) {
-      template = formMatch[1] || "";
-      jsonStr = formMatch[2] ? formMatch[2].trim() : "";
-    }
-
-    // 解析 JSON 内容
-    let params = {};
-    if (jsonStr) {
-      try {
-        params = JSON.parse(jsonStr);
-      } catch (e) {
-        throw new Error("内容格式错误,无法解析JSON");
-      }
-    }
-
-    // 可选:将 template 参数加入 params
-    // if (template) params._template = template;
-
-    parsedContent.value = params;
-    updateDynamicHeight();
-  } catch (e) {
-    console.error("数据加载失败", e);
-    error.value = e.message || "数据加载失败";
-    parsedContent.value = {};
-    updateDynamicHeight();
-  } finally {
-    loadingTimer = setTimeout(() => {
-      loading.value = false;
-    }, LOADING_THROTTLE_DELAY);
-  }
-}
-
-watch(
-  () => props.content,
-  () => {
-    fetchFormData();
-  },
-  { immediate: true }
-);
-
-watch(parsedContent, () => {
-  updateDynamicHeight();
-});
-
-onMounted(() => {
-  fetchFormData();
-});
-</script>
-
-<style lang="scss" scoped>
-.form-content-wrap {
-  padding: 16px;
-  border-radius: 12px;
-  background-color: rgba(255, 255, 255, 0.3);
-  min-height: 150px;
-  margin: 16px 0;
-  position: relative;
-  min-width: 60%;
-  overflow: hidden;
-  transition: all 800ms;
-}
-
-.loading {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #888;
-  text-align: center;
-  padding: 20px 0;
-}
-
-.error {
-  color: #fff;
-  text-align: center;
-  padding: 20px 0;
-}
-
-.form-field {
-  margin-bottom: 10px;
-
-  strong {
-    margin-right: 8px;
-  }
-}
-
-.form-content {
-  display: inline-block;
-  white-space: pre-wrap;
-  word-break: break-word;
-  transition: all 0.2s;
-  opacity: 1;
-}
-</style>
-<style>
-.fade-enter-active,
-.fade-leave-active {
-  transition: opacity 0.3s ease-in-out;
-}
-
-.fade-enter-from,
-.fade-leave-to {
-  opacity: 0;
-}
-
-.fade-enter-to,
-.fade-leave-from {
-  opacity: 1;
-}
-</style>

+ 0 - 175
src/components/Chat/LinkURLContent.vue

@@ -1,175 +0,0 @@
-<template>
-  <div class="link-content-wrap">
-    <Transition name="fade">
-      <div class="link-content" :class="{ active: url }">
-        <a v-if="url" :href="url" target="_blank" rel="noopener" class="link-url-link">
-          <img src="@/assets/zip-icon.png" class="link-icon" />
-          <span class="link-text">{{ displayName }}</span>
-        </a>
-      </div>
-    </Transition>
-  </div>
-</template>
-
-<script setup>
-import { ref, watch, onMounted, computed } from "vue";
-
-const props = defineProps({
-  content: {
-    type: String,
-    default: "",
-  },
-});
-
-const loading = ref(false);
-const error = ref("");
-const url = ref("");
-const displayName = computed(() => {
-  // 提取 <link-url> 标签中的内容作为显示名称
-  if (url.value) {
-    // 提取 URL 中的文件名部分
-    const urlParts = url.value.split("/");
-    const fileName = urlParts[urlParts.length - 1];
-    // 如果 URL 中有文件名,则返回文件名,否则返回 URL
-    if (fileName) {
-      return fileName;
-    }
-  }
-  // 如果没有 URL,则返回默认文本
-  return "材料.zip";
-});
-
-function extractLinkURL(content) {
-
-  // return 'http://1.95.178.212:8910/download_forms/6918bc00-e9ed-46d1-9714-f302cf285f02'
-  // 匹配 <link-url>...</link-url>,允许前后有空格和换行
-  const match = (content || "").match(/<link-url[^>]*>([\s\S]*?)<\/link-url>/i);
-  if (match && match[1]) {
-    // 去除首尾空白
-    return match[1].trim();
-  }
-  return "";
-}
-
-async function fetchLinkURL() {
-  loading.value = true;
-  error.value = "";
-  try {
-    // 解析 url
-    console.log("正在提取链接内容:", props.content);
-    const extractedUrl = extractLinkURL(props.content);
-    console.log("提取的表单链接:", props.content, extractedUrl);
-    if (!extractedUrl) {
-      error.value = "";
-      url.value = "";
-    } else {
-      // 这里可以添加额外的校验或处理
-      url.value = extractedUrl;
-    }
-    // 模拟异步加载
-    await new Promise((resolve) => setTimeout(resolve, 300));
-  } catch (e) {
-    console.error("数据加载失败", e);
-    error.value = "数据加载失败";
-    url.value = "";
-  } finally {
-    loading.value = false;
-  }
-}
-
-watch(
-  () => props.content,
-  () => {
-    fetchLinkURL();
-  },
-  { immediate: true }
-);
-
-onMounted(() => {
-  fetchLinkURL();
-});
-</script>
-
-<style lang="scss" scoped>
-.link-content-wrap {
-  margin: 16px 0;
-  position: relative;
-
-  .link-content {
-    display: inline-block;
-    padding: 6px;
-    border-radius: 12px;
-    min-height: 48px;
-    min-width: 200px;
-
-    &.active {
-      background-color: rgba(255, 255, 255, 1);
-    }
-  }
-}
-
-.loading {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #888;
-  text-align: center;
-  padding: 20px 0;
-}
-
-.error {
-  color: #fff;
-  text-align: center;
-  padding: 20px 0;
-}
-
-.link-content {
-  display: inline-block;
-  white-space: pre-wrap;
-  word-break: break-word;
-  transition: opacity 0.2s;
-  opacity: 1;
-}
-
-.link-url-link {
-  color: #1677ff;
-  font-size: 16px;
-  word-break: break-all;
-  text-decoration: underline;
-  display: flex;
-  align-items: center;
-
-  .link-icon {
-    width: 36px;
-    height: 36px;
-    margin-right: 8px;
-    vertical-align: middle;
-  }
-
-  .link-text {
-    color: hsla(218, 100%, 40%, 1);
-    font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
-  }
-}
-</style>
-<style>
-.fade-enter-active,
-.fade-leave-active {
-  transition: opacity 0.3s ease-in-out;
-}
-
-.fade-enter-from,
-.fade-leave-to {
-  opacity: 0;
-}
-
-.fade-enter-to,
-.fade-leave-from {
-  opacity: 1;
-}
-</style>

+ 0 - 222
src/components/Chat/OfficeURLContent.vue

@@ -1,222 +0,0 @@
-<template>
-  <div class="office-content-wrap">
-    <Transition name="fade">
-      <div class="office-content" :class="{ active: url }">
-        <div v-if="url" rel="noopener" class="link-url-link" @click="handleClick">
-          <img src="@/assets/word-icon.png" class="link-icon" />
-          <span class="link-text">{{ displayName }}</span>
-          <span v-if="active" class="link-text"> - 查看中</span>
-        </div>
-      </div>
-    </Transition>
-  </div>
-</template>
-
-<script setup>
-import { ref, watch, onMounted, computed, inject } from "vue";
-
-const props = defineProps({
-  content: {
-    type: String,
-    default: "",
-  },
-});
-
-const { open, close } = inject('documentControl')
-
-const loading = ref(false);
-const error = ref("");
-const url = ref("");
-
-function extractOfficeName(content) {
-  const match = (content || "").match(/<file\-docx\b[^>]*\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))[^>]*>/i);
-  const name = (match && (match[1] ?? match[2] ?? match[3])) || "";
-  return name ? decodeURIComponent(name) : "";
-}
-
-const displayName = computed(() => {
-  const officeName = extractOfficeName(props.content);
-  if (officeName) {
-    return officeName;
-  }
-
-  if (url.value) {
-    // 提取 URL 中的文件名部分
-    const urlParts = url.value.split("/");
-    const fileName = urlParts[urlParts.length - 1];
-    // 如果 URL 中有文件名,则返回文件名,否则返回 URL
-    if (fileName) {
-      return decodeURIComponent(fileName);
-    }
-  }
-  // 如果没有 URL,则返回默认文本
-  return "材料.zip";
-});
-const active = ref(false);
-
-function extractOfficeURL(content) {
-
-  // return 'http://1.95.178.212:8910/download_forms/6918bc00-e9ed-46d1-9714-f302cf285f02'
-  // 匹配 <office>...</office>,允许前后有空格和换行
-  const match = (content || "").match(/<file\-docx\b[^>]*>([\s\S]*?)<\/file\-docx>/i);
-  if (match && match[1]) {
-    // 去除首尾空白
-    return match[1].trim();
-  }
-  return "";
-}
-
-async function fetchOfficeURL() {
-  loading.value = true;
-  error.value = "";
-  try {
-    // 解析 url
-    console.log("正在提取链接内容:", props.content);
-    const extractedUrl = extractOfficeURL(props.content);
-    console.log("提取的表单链接:", props.content, extractedUrl);
-    if (!extractedUrl) {
-      error.value = "";
-      url.value = "";
-    } else {
-      // 这里可以添加额外的校验或处理
-      url.value = extractedUrl;
-    }
-    // 模拟异步加载
-    await new Promise((resolve) => setTimeout(resolve, 300));
-  } catch (e) {
-    console.error("数据加载失败", e);
-    error.value = "数据加载失败";
-    url.value = "";
-  } finally {
-    loading.value = false;
-  }
-}
-
-const handleClose = () => {
-  active.value = false;
-  removeCloseEvent();
-}
-
-const addCloseEvent = () => {
-  document.addEventListener("docClose", handleClose);
-}
-
-const removeCloseEvent = () => {
-  document.removeEventListener("docClose", handleClose);
-}
-
-const handleClick = () => {
-  const current = active.value;
-  active.value = !active.value;
-  if (current) {
-    close();
-    removeCloseEvent();
-  } else {
-    document.dispatchEvent(new CustomEvent("docClose"));
-    open(url.value, { fileName: displayName.value });
-    addCloseEvent();
-  }
-}
-
-watch(
-  () => props.content,
-  () => {
-    fetchOfficeURL();
-  },
-  { immediate: true }
-);
-
-onMounted(() => {
-  fetchOfficeURL();
-});
-</script>
-
-<style lang="scss" scoped>
-.office-content-wrap {
-  margin: 8px 0;
-  position: relative;
-
-  .office-content {
-    display: inline-block;
-    padding: 12px 16px 12px 12px;
-    border-radius: 12px;
-    min-height: 24px;
-    min-width: 200px;
-    box-sizing: content-box;
-    background-color: rgba(255, 255, 255, 0.2);
-    border: 2px solid;
-    border-color: rgba(255, 255, 255, 0.5);
-    cursor: pointer;
-
-    &:hover {
-      border-color: rgba(255, 255, 255, 1);
-    }
-  }
-}
-
-.loading {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #888;
-  text-align: center;
-  padding: 20px 0;
-}
-
-.error {
-  color: #fff;
-  text-align: center;
-  padding: 20px 0;
-}
-
-.office-content {
-  display: inline-block;
-  white-space: pre-wrap;
-  word-break: break-word;
-  transition: opacity 0.2s;
-  opacity: 1;
-}
-
-.link-url-link {
-  color: #1677ff;
-  font-size: 16px;
-  word-break: break-all;
-  text-decoration: none;
-  display: flex;
-  align-items: center;
-
-  .link-icon {
-    width: 24px;
-    height: 24px;
-    margin-right: 10px;
-    vertical-align: middle;
-  }
-
-  .link-text {
-    font-size: 14px;
-    color: rgba(255, 255, 255, 1);
-    font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
-  }
-}
-</style>
-<style>
-.fade-enter-active,
-.fade-leave-active {
-  transition: opacity 0.3s ease-in-out;
-}
-
-.fade-enter-from,
-.fade-leave-to {
-  opacity: 0;
-}
-
-.fade-enter-to,
-.fade-leave-from {
-  opacity: 1;
-}
-</style>

+ 41 - 133
src/components/Chat/PolicyMatch.vue

@@ -58,8 +58,7 @@
             </div>
           </div>
 
-          <!-- list(旧「政策事项列表」)与 cardList(「更多」→「政策列表」)共用这个 body,
-               靠下面的 v-if 分叉内容;样式类完全复用,未新增 CSS -->
+          <!-- list / cardList 共用这个 body(靠下面的 v-if 分叉内容),样式类完全复用 -->
           <div v-if="panelMode !== 'detail'" class="panel-scroll list-body" ref="listBodyRef"
             :class="{ 'list-body-record': recordMode }">
             <div class="list-content">
@@ -81,7 +80,7 @@
                     </svg>
                     <input type="text" placeholder="输入政策关键词" class="search-input" v-model="searchKeyword" />
                   </div>
-                  <!-- 部门 / 分类筛选:只在旧列表面板出现;「政策列表」侧边栏按需求不带筛选 -->
+                  <!-- 部门 / 分类筛选:只在旧列表面板出现 -->
                   <div class="filter-selects" v-if="panelMode === 'list'" @click.stop>
                     <div class="filter-select-wrapper">
                       <div class="filter-option" @click="toggleFilterMenu" :class="{ 'is-open': showFilterMenu }">
@@ -265,7 +264,6 @@
             <div class="detail-section-title" v-if="!recordMode">政策解析</div>
 
             <div :class="recordMode ? 'detail-body-grid' : ''">
-              <!-- 支持对象 -->
               <div class="detail-row card-style">
                 <div class="record-condition-title">
                   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -283,7 +281,6 @@
                 </div>
               </div>
 
-              <!-- 支持方式和标准 -->
               <div class="detail-row card-style">
                 <div class="record-condition-title">
                   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -362,7 +359,7 @@
               </template>
             </div>
 
-            <!-- 条件核验(来自 /api/chat 的 item.conditions),复用面板原有卡片样式 -->
+            <!-- 条件核验(来自 /api/chat 的 item.conditions) -->
             <div v-if="recordMode && selectedConditions.length" class="detail-row card-style">
               <div class="record-condition-title">
                 <span>条件核验</span>
@@ -407,21 +404,6 @@
               </div>
             </div>
 
-            <!-- [已移除] 原文引用板块(数据来自 /api/chat 的 item.citations)——按用户要求去掉 -->
-            <!--
-            <div v-if="recordMode && selectedCitations.length" class="detail-row card-style">
-              <div class="record-condition-title">
-                <span>原文引用</span>
-              </div>
-              <div class="condition-content-wrapper">
-                <div v-for="(citation, index) in selectedCitations" :key="`citation-${index}`"
-                  class="condition-text-box">
-                  {{ citation?.quote }}
-                </div>
-              </div>
-            </div>
-            -->
-
             <div class="detail-row" v-if="!recordMode">
               <div class="row-label">
                 <div class="dot-icon" v-if="!recordMode">
@@ -527,15 +509,11 @@ const emit = defineEmits<{
 const policies = ref<PolicyItem[]>([]);
 const policyListItems = ref<PolicyItem[]>([]);
 const policyListTotal = ref(0);
-// [已移除] policyListLoading / policyListRequestId —— 它们是「第三条腿」拉远端列表时的加载态与
-// 竞态守卫;那条链路 2026-09-28 删除后就没有写入点了(模板里也不再读)
 const panelVisible = ref(false);
 const policyModalId = Symbol("policy-viewer");
 const modalMutex = inject(MODAL_MUTEX_KEY, null);
 let unregisterPolicyModal: (() => void) | undefined;
-// list   = 旧列表(惠企政策库 262 条 + 部门/分类筛选),详情面板「返回政策事项列表」用
-// cardList = 卡片区「更多」打开的新侧边栏(本会话全部卡片聚合,无筛选,只有搜索+热门搜索)
-// detail = 单条政策详情
+// list = 旧列表(惠企政策库 + 部门/分类筛选);cardList = 卡片区「更多」侧边栏;detail = 单条详情
 const panelMode = ref<"list" | "cardList" | "detail">("list");
 const selectedPolicy = ref<number>(0);
 const selectedPolicyOverride = ref<PolicyItem | null>(null);
@@ -555,28 +533,19 @@ const currentFilterOption = ref<PolicyListFilterOption>("所有分类");
 const showFilterMenu = ref(false);
 
 /**
- * 部门筛选项:**只列数据里实际存在的部门**(用户 2026-09-24 明确要求)。
- *
- * 历史:这里曾经是一份**硬编码的 15 个部门**(用户早先要求"保持最初的样子"),
- * 于是新数据里出现别的部门就筛不到、固定列表里也可能有库里根本没有的部门(选了空列表)。
- * 现在改为从「惠企政策库 + 本会话卡片」现算。
+ * 部门筛选项:只列数据里实际存在的部门。
  *
- * ⚠️ 用 ref + 手动刷新,**不用 computed**:`globalThis.policiesPublic` 由 index.html 异步加载、
- * 不是响应式依赖(详见 `getLibraryPolicies` 的注释),computed 会把首次读到的空列表缓存住。
- * 所以在**打开菜单时**现算一次(那时数据早已加载完)。
+ * ⚠️ 用 ref + 手动刷新,**不用 computed**:`globalThis.policiesPublic` 由
+ * `ensurePolicyLibrary` 异步加载、不是响应式依赖(见 getLibraryPolicies),
+ * computed 会把首次读到的空列表缓存住。
+ * 所以在打开菜单时现算一次(那时数据早已加载完)。
  */
 const deptOptions = ref<string[]>(["全部部门"]);
 
 const collectDeptOptions = (): string[] => {
   /**
-   * 只取**政策库**里 `政策所属部门` 的取值。
-   *
-   * ⚠️ 曾经还把"本会话卡片里出现过的部门"并进来当兜底 —— 那是个 bug(用户 2026-09-28 报
-   * 「部门筛选出现了没有内容的项」):卡片的 `source` 可能是**多部门串**,实测库里就有
-   * `"上海市商务委员会、上海市发展和改革委员会、…\n市商务委等"` 这种值,
-   * 并进来会变成一个又长又筛不出东西的选项。
-   *
-   * 另外顺手挡掉这类脏值(含顿号/逗号/换行、或过长):宁可少一个选项,也不要列出没内容的假部门。
+   * 只取**政策库**里 `政策所属部门` 的取值:卡片的 `source` 可能是**多部门串**
+   * (含顿号/逗号/换行),并进来会变成又长又筛不出东西的假部门,一并挡掉。
    */
   const looksLikeSingleDept = (value: string) => value.length <= 12 && !/[,,、\s]/.test(value);
 
@@ -594,7 +563,7 @@ const collectDeptOptions = (): string[] => {
 const refreshDeptOptions = () => {
   const next = collectDeptOptions();
   deptOptions.value = next;
-  // 选中的部门若已不在数据里(数据换了),退回"全部部门",否则会筛出空列表还没提示
+  // 选中的部门若已不在数据里(数据换了),退回"全部部门"
   if (!next.includes(currentDeptFilter.value)) {
     currentDeptFilter.value = "全部部门";
   }
@@ -647,11 +616,7 @@ onBeforeUnmount(() => {
   document.removeEventListener("click", closeMenus);
 });
 
-// ─── 「更多」→ 政策列表 侧边栏(**本条回答的卡片**)─────────────────────
-//
-// ⚠️ 2026-09-18 用户调整:**只列这条回答里出现的政策**,不再跨消息聚合整个会话。
-// 因此数据源与本组件的 syncPolicies 同源(props.policyData / props.policyContent),
-// 不再往上层取「会话级聚合」。
+// 卡片区「更多」→ 政策列表侧边栏:只列本条回答里出现的政策,数据源同 syncPolicies
 const cardListItems = ref<PolicyItem[]>([]);
 
 /** 重建列表:取本条回答的卡片 → 与政策库合并补全 → 规范化 → 去重(首见优先)→ 按匹配分排序 */
@@ -680,7 +645,6 @@ const filteredPolicies = computed(() => {
   }
 
   return listPolicyItems.value.filter(p => {
-    // 关键词筛选
     if (searchKeyword.value) {
       const keyword = searchKeyword.value.toLowerCase();
       const matchTitle = p.title && p.title.toLowerCase().includes(keyword);
@@ -691,14 +655,12 @@ const filteredPolicies = computed(() => {
       }
     }
 
-    // 分类筛选
     if (currentFilterOption.value === "只看免申即享" && p.tag !== "免申即享") {
       return false;
     } else if (currentFilterOption.value === "非免申即享" && p.tag === "免申即享") {
       return false;
     }
 
-    // 部门筛选
     if (currentDeptFilter.value !== "全部部门" && p.source !== currentDeptFilter.value) {
       return false;
     }
@@ -785,8 +747,8 @@ const visiblePages = computed(() => {
 });
 
 /**
- * 检测是否为精简数据格式(仅含 match_degree / match_reason / match_score / id)
- * 精简格式通常来自新版 chat 接口的 POLICY_TABLE 块
+ * 是否为精简数据格式(仅含 match_degree / match_reason / match_score / id)
+ * —— 来自新版 chat 接口的 POLICY_TABLE 块
  */
 const isMinimalFormat = (item: RawPolicyItem): boolean => {
   return (
@@ -825,11 +787,10 @@ const normalizePolicy = (item: RawPolicyItem): PolicyItem => {
   const suggestedConditions = item.data?.["梳理后建议申报条件"] || item?.['申报条件'] || item.data?.["申报条件"] || [];
   const policyDetails = parsePolicyDetails(item);
 
-  // 处理申报条件,兼容JSON字符串格式、数组格式和普通字符串格式
+  // 处理申报条件:兼容 JSON 字符串、数组、普通字符串三种格式
   let processedConditions = suggestedConditions || [];
   if (typeof suggestedConditions === 'string') {
     try {
-      // 尝试解析JSON字符串
       const parsed = JSON.parse(suggestedConditions.replace(/'/g, '"'));
       if (Array.isArray(parsed)) {
         processedConditions = parsed;
@@ -837,7 +798,6 @@ const normalizePolicy = (item: RawPolicyItem): PolicyItem => {
         processedConditions = suggestedConditions;
       }
     } catch (e) {
-      // 解析失败,保持原字符串
       processedConditions = suggestedConditions;
     }
   }
@@ -856,7 +816,7 @@ const normalizePolicy = (item: RawPolicyItem): PolicyItem => {
     declaration_item: stripPolicyIdText(
       item.declaration_item ||
       item.originalData?.declaration_item || item?.['申报事项'] ||
-      // 2026-09-24 本地数据换成 v2 后的字段名(一个政策下多个申报事项,这两列实测 170 条全一致)
+      // v2 数据的字段名(一个政策下多个申报事项,这两列实测 170 条全一致)
       item?.['部门上报事项'] || item?.['大模型拆解申报事项'] ||
       (minimal ? "查看政策详情" : "暂无申报项")
     ),
@@ -876,7 +836,7 @@ const normalizePolicy = (item: RawPolicyItem): PolicyItem => {
         : item.tag || (minimal ? item.match_degree || "" : ""),
     source: stripPolicyIdText(
       item.department || item?.['部门'] || item.source ||
-      // 2026-09-24 本地数据换成 v2 后的部门字段名(取值形如「区经委」,与筛选列表口径一致)
+      // v2 数据的部门字段名(取值形如「区经委」,与筛选列表口径一致)
       item?.['政策所属部门'] || ""
     ),
     match_score: Number(item.match_score || 0),
@@ -898,13 +858,13 @@ const normalizePolicy = (item: RawPolicyItem): PolicyItem => {
         (item.declaration_start_time && item.declaration_end_time)
         ? `${item.declaration_start_time || item?.['开始时间'] || ""} 至 ${item.declaration_end_time || item?.['结束时间'] || ""}`
         : "",
-    // 2026-09-24 本地数据 v2 的日期字段是「开始时间 / 结束时间」(结束时间可能是"未标明"这类文本)
+    // v2 数据的日期字段是「开始时间 / 结束时间」(结束时间可能是"未标明"这类文本)
     end_date: item.declaration_end_time || item.date || item?.['申报结束时间'] || item?.['结束时间'] || "",
     apply_link:
       item.apply_link ||
       item?.['政策原文地址'] ||
       item.data?.['政策原文地址'] ||
-      // 库里 apply_link 全空,用「市级政策id」拼政策详情页地址(261/262 条有该 id)
+      // 库里 apply_link 全空,用「市级政策id」拼政策详情页地址
       buildPolicyDetailUrl(item) ||
       "",
     originalData: item,
@@ -912,9 +872,8 @@ const normalizePolicy = (item: RawPolicyItem): PolicyItem => {
 };
 
 /**
- * 将 chat 接口返回的增量政策数据与预下载的完整政策列表合并。
- * 完整列表从 globalThis.policiesPublic 读取(仅在 zhaoshang 等模式下可用)。
- * 合并策略:以完整列表中的条目为基础,chat 返回的字段(匹配度等)覆盖其上。
+ * chat 增量政策数据与预下载的完整列表(globalThis.policiesPublic)合并:
+ * 以完整列表中的条目为基础,chat 返回的字段(匹配度等)覆盖其上。
  */
 const mergeWithPublicPolicies = (item: RawPolicyItem): RawPolicyItem => {
   const fullList = globalThis.policiesPublic;
@@ -928,7 +887,6 @@ const mergeWithPublicPolicies = (item: RawPolicyItem): RawPolicyItem => {
   );
   if (!fullEntry) return item;
 
-  // 完整条目为基础,chat 返回的增量字段覆盖其上
   return { ...fullEntry, ...item };
 };
 
@@ -937,23 +895,13 @@ const listPolicyItems = computed(() => {
 });
 
 
-/**
- * 对话内(记录模式)的“更多”列表直接复用卡片数据:
- * 顺序与卡片完全一致(不再另行拉取与卡片无关的公共政策库),
- * 部门筛选也一定能筛到内容。
- */
-/* ──────────────────────────────────────────────────────────────────────────
- * 惠企政策库相关
- *
- * 惠企政策 = policies_public.v2.json 里的那些政策(170 条申报事项 / 25 个政策,
- * 由 index.html 预加载到 globalThis.policiesPublic)。
- * 判定优先用接口下发的 is_policy_library 字段,缺失时回退到"标题能在库里找到"。
- * ────────────────────────────────────────────────────────────────────────── */
+/* 惠企政策库:policies_public.v2.json 里的政策(由 index.html 预加载到 globalThis.policiesPublic)。
+ * 判定优先用接口下发的 is_policy_library 字段,缺失时回退到"标题能在库里找到"。 */
 
 /**
  * ⚠️ 这里是**函数**不是 computed,是刻意的。
  *
- * `globalThis.policiesPublic` 由 index.html 异步 fetch 加载(失败时退到本地兜底),
+ * `globalThis.policiesPublic` 由 `ensurePolicyLibrary` 异步加载(失败时退到本地兜底),
  * 且应用**不等待**它——不是响应式依赖。写成 computed 会把首次读到的值
  * (很可能是空数组)缓存住,加载完成后也不会更新。
  * 所以每次调用时现读。
@@ -963,7 +911,7 @@ const getLibraryPolicies = (): RawPolicyItem[] =>
 
 const getLibraryNameSet = (): Set<string> => buildLibraryNameSet(getLibraryPolicies());
 
-/** 本轮卡片里匹配到的惠企政策名(用于在库列表里置顶) */
+/** 本轮卡片里匹配到的惠企政策名(用于在政策库列表里置顶) */
 const matchedLibraryNames = computed(() =>
   collectMatchedLibraryNames(policies.value, getLibraryNameSet())
 );
@@ -973,10 +921,7 @@ const selectedIsPolicyLibrary = computed(() =>
   isPolicyLibraryItem(selectedPolicyItem.value, getLibraryNameSet())
 );
 
-/**
- * 构建惠企政策库列表(从 policies_public.v2.json)。
- * 本轮匹配到的惠企政策置顶,其余保持库内原顺序。
- */
+/** 构建惠企政策库列表:本轮匹配到的置顶,其余保持库内原顺序 */
 const buildLibraryPolicyList = () => {
   const keyword = searchKeyword.value.trim().toLowerCase();
   const dept = currentDeptFilter.value === "全部部门" ? "" : currentDeptFilter.value;
@@ -999,7 +944,6 @@ const buildLibraryPolicyList = () => {
     return true;
   });
 
-  // 置顶:本轮匹配到的惠企政策在前,其余保持原顺序
   const ordered = pinMatchedFirst(filtered, matched, (item) => item.title);
   const start = (currentPage.value - 1) * itemsPerPage;
   policyListItems.value = ordered.slice(start, start + itemsPerPage);
@@ -1011,7 +955,7 @@ const buildRecordPolicyList = () => {
   const dept = currentDeptFilter.value === "全部部门" ? "" : currentDeptFilter.value;
 
   const filtered = policies.value.filter((item) => {
-    // 与最初一致:部门按字面完全相等比较
+    // 部门按字面完全相等比较
     if (dept && String(item.source || "") !== dept) {
       return false;
     }
@@ -1049,7 +993,7 @@ const syncPolicies = () => {
   currentPage.value = 1; // 重置页码
   selectedPolicyOverride.value = null;
 
-  // 换了一批卡片后,让“更多”列表重建(部门筛选项是固定的,无需重置)
+  // 换了一批卡片后让“更多”列表重建
   if (props.recordMode) {
     if (panelVisible.value && panelMode.value === "list") {
       buildRecordPolicyList();
@@ -1062,21 +1006,7 @@ const syncPolicies = () => {
 };
 
 /**
- * 刷新「政策事项列表」。
- *
- * 数据只有两个来源(2026-09-28 起):
- *   ① 惠企政策库(`policies_public.v2.json`,远端 DMS / 本地副本)—— 主来源
- *   ② 本会话卡片 —— 库还没加载完时的兜底,避免列表空白
- *
- * ⚠️ 原来还有**第三条腿**:前端自己拼 `{VITE_MODEL_SERVER}/v1/policies` 去拉一份列表
- * (配 `buildPolicyListSearchParams` / `buildPolicyListUrl` / `extractPolicyListResponse` /
- * `getLocalPolicyListResponse` 四个 helper)。它需要「库为空 **且** 卡片也为空」才会走到,
- * 实测从未触发;用户 2026-09-28 要求「不再依赖原来的政策相关接口」→ **整条删掉**
- * (连带两份 env 里的 `VITE_MODEL_SERVER`)。
- * 注意:原来还有一条**后端驱动**的路径 —— 后端在正文里下发 `<policy-list-updated>{link,payload}`,
- * 由另一个组件(`PolicyListUpdatedMatch.vue`)照着 link 去取(地址同样是上面那条已删接口)。
- * 2026-09-28 用户确认「旧对话仅测试、后续会全部清空」→ 该组件与过滤器的标签规则一并删除,
- * 接口清单 #11 归零。现在没有「后端给地址、前端再去取」这种二次取数了。
+ * 刷新「政策事项列表」:主来源是惠企政策库,库还没加载完时退回本会话卡片(避免列表空白)。
  */
 const fetchPolicyList = async () => {
   if (!props.recordMode || !panelVisible.value || panelMode.value !== "list") return;
@@ -1094,9 +1024,7 @@ const panelData = computed(() => {
     .sort((a, b) => b.match_score - a.match_score)
     .slice(0, 6)
     .map((item) => ({
-      // 卡片区标题:用「政策名」(normalizePolicy 的 title)。
-      // ⚠️ 这里不能再优先取 declaration_item —— 自 2026-09-17 起它表示**具体申报事项**
-      // (「政策名 - 申报事项」),取值会连带改掉卡片区的观感。卡片区保持原样。
+      // 卡片区标题用「政策名」:declaration_item 表示具体申报事项,优先取它会改掉卡片区观感
       title: item.title || item.originalData?.declaration_item || item.declaration_item,
       desc: item.desc || item.match_reason || "",
       match_score: item.match_score,
@@ -1117,8 +1045,8 @@ const selectedPolicyItem = computed(() => {
 });
 
 /**
- * /api/chat 协议独有的附加数据,随 POLICY_TABLE 一起传入,
- * 由 normalizePolicy 保留在 originalData 里。仅记录模式(对话内)展示。
+ * /api/chat 协议独有、随 POLICY_TABLE 一起传入的附加数据(由 normalizePolicy 留在 originalData 里),
+ * 仅记录模式(对话内)展示。
  */
 const selectedConditions = computed<any[]>(() => {
   const list = selectedPolicyItem.value?.originalData?.conditions;
@@ -1130,12 +1058,6 @@ const selectedMatchBasis = computed<any[]>(() => {
   return Array.isArray(list) ? list : [];
 });
 
-// [已移除] 原文引用板块随模板一并去掉,此计算属性暂留备用
-// const selectedCitations = computed<any[]>(() => {
-//   const list = selectedPolicyItem.value?.originalData?.citations;
-//   return Array.isArray(list) ? list : [];
-// });
-
 // conditions.status 是中文,复用面板已有的匹配等级配色
 const getConditionLevelClass = (status?: string) => {
   if (status === '满足') return 'match-high';
@@ -1185,7 +1107,7 @@ const parseStringArray = (str: string) => {
       return parsed;
     }
   } catch (e) {
-    console.error('Failed to parse array string:', e);
+    /* console.error('Failed to parse array string:', e); */
   }
   // Fallback to basic string split if JSON parse fails
   return str.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, ''));
@@ -1218,9 +1140,7 @@ const openDetail = async (index: number) => {
 };
 
 const openDetailByItem = async (item: { originalData?: RawPolicyItem; support_objects?: string }) => {
-  // 反查下标:先按对象同一性(精确),退化的字段比对只在唯一命中时采信 ——
-  // 同一政策的多个申报事项在字段上无法区分,原先的写法会让第二张卡片也跳到第一张
-  // (表现:两张卡片详情内容一模一样)。见 policy-match-utils.ts 的说明。
+  // 反查下标(唯一命中才采信),详见 policy-match-utils.ts 的 resolvePolicyDetailIndex
   const index = resolvePolicyDetailIndex(policies.value, item);
 
   if (index >= 0) {
@@ -1239,13 +1159,8 @@ const openDetailByItem = async (item: { originalData?: RawPolicyItem; support_ob
 };
 
 /**
- * 打开「政策事项列表」(旧列表)。
- *
- * ⚠️ 2026-09-17 起,**卡片区的「更多 >>」不再走这里** —— 它改为打开
- * `openCardList()` 的「政策列表」侧边栏(本会话卡片聚合、无筛选)。
- * 本条路径现在只由惠企政策详情的「返回政策事项列表」使用。
- *
- * 列表数据统一来自惠企政策库(policies_public.v2.json),本轮匹配到的惠企政策置顶。
+ * 打开「政策事项列表」(旧列表)。数据来自惠企政策库,本轮匹配到的置顶。
+ * 卡片区的「更多 >>」走 openCardList,不走这里。
  */
 const openPolicyList = async () => {
   selectedPolicyOverride.value = null;
@@ -1253,8 +1168,7 @@ const openPolicyList = async () => {
   // 面板打开即刷新部门清单(库是异步加载的,见 deptOptions 的说明)
   refreshDeptOptions();
 
-  // 每次进入都重置筛选:部门 / 分类 / 关键词 / 页码
-  // 否则上次的筛选会残留,表现为"条目少"或"筛不出东西"
+  // 每次进入都重置筛选:否则上次的筛选残留会表现为"条目少"或"筛不出东西"
   currentPage.value = 1;
   searchKeyword.value = "";
   currentDeptFilter.value = "全部部门";
@@ -1270,9 +1184,7 @@ const openPolicyList = async () => {
 
 /**
  * 打开「政策列表」侧边栏 —— 卡片区「更多 >>」的入口。
- *
- * 与 openPolicyList 的区别:内容是本会话内**所有消息**出现过的卡片(跨消息聚合、去重),
- * 只有搜索框与热门搜索,**没有部门/分类筛选**。
+ * 只有搜索框与热门搜索,没有部门/分类筛选。
  *
  * ⚠️ 必须**先切 panelMode 再重置共享状态**:重置会触发 watch → fetchPolicyList,
  * 而它开头有 `panelMode !== "list"` 的守卫,切了模式才能让旧列表逻辑短路,
@@ -2370,9 +2282,7 @@ onBeforeUnmount(() => {
   align-items: center;
   gap: 32px;
   padding-top: 8px;
-  /* 2026-09-20 按用户要求**拉满整行**:原来这一行只有约 520px 宽
-     (名称列 max-width 462 + 部门列写死 110),右侧空出一大块,与下方两张
-     卡片(.detail-body-grid,整行宽)右边缘对不齐 */
+  /* 拉满整行:与下方卡片(.detail-body-grid,整行宽)右边缘对齐 */
   width: 100%;
 }
 
@@ -2383,14 +2293,12 @@ onBeforeUnmount(() => {
 }
 
 .record-meta-item.meta-name {
-  /* 原来是 max-width: 462px —— 它把整行宽度卡死了;改为占满剩余空间 */
   flex: 1 1 auto;
   min-width: 0;
 }
 
 .record-meta-item.meta-no {
-  /* 原来是写死的 110px —— 部门写全称时(recordMode 下的「政策所属部门」)
-     会被挤成三行;给它可伸展的宽度,下限仍是 110px */
+  /* 可伸展宽度,下限 110px(部门写全称时才不会被挤成三行) */
   flex: 0 1 340px;
   min-width: 110px;
 }

+ 15 - 56
src/components/Chat/QuestionCard.vue

@@ -1,11 +1,7 @@
 <template>
-  <!-- 只读态**不再另起一套模板**:同一个问卷模板 + 只读开关(禁用交互、还原选中样式)。
-      用户 2026-09-24:「问卷长什么样,选完后还是长什么样,样式不能变」——
-      之前只读态走的是另一套更简陋的标记(没有 (单选) 标签、没有选项列表、没有提交按钮),
-      作答后卡片会"缩水"成一个小框,看起来像坏了。 -->
+  <!-- 只读态复用同一个问卷模板 + 只读开关(禁用交互、还原选中样式),样式与作答前保持一致 -->
   <div v-if="questions.length > 0" class="question-card-shell"
     :class="{ submitted: submitted || isReadonly, readonly: isReadonly }">
-    <!-- Progress dots outside questions -->
     <div class="qc-dots-row">
       <div v-for="(card, cardIdx) in questions" :key="cardIdx" class="qc-dot"
         :class="{ active: isQuestionAnswered(cardIdx) }" @click="scrollToQuestion(cardIdx)" />
@@ -13,7 +9,6 @@
 
     <div v-for="(card, cardIdx) in questions" :key="cardIdx" class="question-card"
       :ref="(el) => setQuestionRef(cardIdx, el)" :class="{ highlighted: highlightCardIdx === cardIdx }">
-      <!-- Question -->
       <div class="qc-body">
         <div class="qc-question" v-if="card.question">
           <span v-if="questions.length > 1" class="qc-question-seq">{{ cardIdx + 1 }}. </span>
@@ -21,9 +16,7 @@
           {{ card.question }}
         </div>
 
-        <!-- Options -->
         <div class="qc-options">
-          <!-- Custom single-select dropdown for >= 6 options -->
           <div v-if="isDropdownCard(card)" class="qc-dropdown-wrapper">
             <div class="qc-option qc-custom-dropdown"
               :class="{ selected: isDropdownSelected(cardIdx), open: openDropdowns[cardIdx] }"
@@ -45,7 +38,6 @@
             </div>
           </div>
 
-          <!-- Custom multi-select dropdown for >= 6 options -->
           <div v-else-if="isMultiDropdownCard(card)" class="qc-dropdown-wrapper">
             <div class="qc-option qc-custom-dropdown"
               :class="{ selected: isMultiDropdownSelected(cardIdx), open: openDropdowns[cardIdx] }"
@@ -67,15 +59,13 @@
             </div>
           </div>
 
-          <!-- Regular buttons -->
           <template v-else>
             <!-- 输入框常驻且在最上:用于补充信息(如公司未查到时补公司名/信用代码) -->
             <div v-if="card.freeformInputOnTop" class="qc-option"
               :class="{ selected: isFreeformSelected(cardIdx) }">
               <span class="qc-option-label">{{ getOtherOptionLabel(card) }}</span>
               <div class="qc-option-content">
-                <!-- ⚠️ focus / click 都要接管:点一下输入框就等于"选了 A",
-                     否则点了输入框却不高亮、右边的 B 还一直亮着(用户 2026-09-24 反馈) -->
+                <!-- focus / click 都要接管:点一下输入框就等于"选了 A",否则不高亮 -->
                 <textarea :ref="(el) => setFreeformRef(cardIdx, el)"
                   class="qc-freeform-input inline"
                   :placeholder="card.freeformPlaceholder || '请输入补充信息...'"
@@ -96,7 +86,7 @@
               </div>
             </button>
 
-            <!-- "其他" freeform option(freeformInputOnTop 时输入框已在上方,不再重复渲染) -->
+            <!-- "其他" 自由输入项(freeformInputOnTop 时输入框已在上方,不再重复渲染) -->
             <div v-if="!card.freeformInputOnTop && card.allowFreeformInput !== false" class="qc-option"
               :class="{ selected: isFreeformSelected(cardIdx) }" @click="toggleFreeform(cardIdx)">
               <span class="qc-option-label">{{ getOtherOptionLabel(card) }}</span>
@@ -114,7 +104,6 @@
       </div>
     </div>
 
-    <!-- Submit -->
     <div class="qc-footer">
       <div class="qc-divider" />
       <div class="qc-footer-row">
@@ -131,9 +120,8 @@
 <script setup lang="ts">
 import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
 import { FREEFORM_INPUT_FOCUS_KEY } from "../business-assistant/freeform-focus-control";
-// 解析(含 <question-cards> 标记的解包)抽成纯函数:见 utils/question-cards-content.ts。
-// ⚠️ 两个解析必须走同一个解包逻辑 —— 之前一个剥标记、一个不剥,导致"作答后写回"的内容
-// 两边都解析不出来,整张卡片渲染成空(用户报「提交完问卷直接看不见了」)
+// 解析(含 <question-cards> 标记解包)抽成纯函数,见 utils/question-cards-content.ts。
+// ⚠️ 块级标记行产出的 content 是 openTag + inner + closeTag,两个解析都必须先解包
 import {
   parseAnswersFromContent,
   parseQuestionsFromContent,
@@ -148,9 +136,7 @@ const props = defineProps<{
 
 const questions = computed(() => parseQuestionsFromContent(props.content));
 
-// Selected option indices per card (for single select, store one value; for multi, store set)
 const selectedOptions = ref<Record<number, Set<number>>>({});
-// Freeform selected per card
 const isFreeformActive = ref<Record<number, boolean>>({});
 const freeformTexts = ref<Record<number, string>>({});
 const freeformRefs: Record<number, HTMLTextAreaElement | null> = {};
@@ -167,11 +153,8 @@ const contentAnswers = computed(() => parseAnswersFromContent(props.content));
 const isReadonly = computed(() => !!contentAnswers.value);
 
 /**
- * 只读态:把写回内容里的 answers **还原成界面上的选中状态**,让它渲染成"就是刚才那份问卷"。
- *
- * 为什么需要:只读态现在复用同一个问卷模板(用户 2026-09-24:「问卷长什么样,选完后还是
- * 长什么样,样式不能变」),而这个模板的选中样式来自 `selectedOptions` / `freeformTexts`。
- * 写回的 answers 只有选项**文字**,所以这里按文字反查选项下标。
+ * 只读态:把写回内容里的 answers 还原成界面上的选中状态(选中样式来自 selectedOptions)。
+ * answers 里只有选项**文字**,所以按文字反查选项下标。
  */
 const hydrateFromAnswers = () => {
   const answers = contentAnswers.value;
@@ -259,7 +242,6 @@ function triggerHighlight(cardIdx: number) {
 
 const injectSubmit = inject<(answers: any[]) => void>('submitQuestionAnswers', undefined);
 
-// PC keeps its existing selection-driven input visibility behavior.
 const legacyFreeformInputActive = inject<{ value: boolean } | undefined>('isFreeformInputActive', undefined);
 watch(isFreeformActive, () => {
   if (!legacyFreeformInputActive) return;
@@ -272,10 +254,7 @@ const getOptionLabel = (idx: number) => {
   return OPTION_LABELS[idx] || String(idx + 1);
 };
 
-/**
- * 卡片内某个选项的序号。
- * freeformInputOnTop 时输入框排在最上、占用 A,因此选项序号整体后移一位。
- */
+/** 卡片内某个选项的序号:freeformInputOnTop 时输入框占用 A,选项序号整体后移一位 */
 const getCardOptionLabel = (card: Question | undefined, optIdx: number) =>
   getOptionLabel(card?.freeformInputOnTop ? optIdx + 1 : optIdx);
 
@@ -293,8 +272,6 @@ const isMultiDropdownCard = (card: Question): boolean => {
   return card.multiSelect === true && (card.options?.length || 0) >= 6;
 };
 
-// --- Custom dropdown helpers ---
-
 function closeAllDropdowns() {
   openDropdowns.value = {};
 }
@@ -321,7 +298,6 @@ onUnmounted(() => {
   document.removeEventListener('click', onDocumentClick);
 });
 
-// Single-select dropdown
 const isDropdownSelected = (cardIdx: number): boolean => {
   const selected = selectedOptions.value[cardIdx];
   return !!(selected && selected.size > 0);
@@ -355,7 +331,6 @@ function selectDropdownOption(cardIdx: number, optIdx: number) {
   closeAllDropdowns();
 }
 
-// Multi-select dropdown
 const isMultiDropdownSelected = (cardIdx: number): boolean => {
   const selected = selectedOptions.value[cardIdx];
   return !!(selected && selected.size > 0);
@@ -394,8 +369,6 @@ function toggleMultiDropdownOption(cardIdx: number, optIdx: number) {
   selectedOptions.value = { ...selectedOptions.value, [cardIdx]: new Set(selected) };
 }
 
-// --- End custom dropdown helpers ---
-
 function ensureSelected(cardIdx: number): Set<number> {
   if (!selectedOptions.value[cardIdx]) {
     selectedOptions.value[cardIdx] = new Set();
@@ -407,19 +380,12 @@ const isOptionSelected = (cardIdx: number, optIdx: number): boolean => {
   return ensureSelected(cardIdx).has(optIdx);
 };
 
-// 输入框常驻的卡片(freeformInputOnTop)不需要点击激活,直接视为自由输入已启用
 const isCardFreeformAlwaysOn = (cardIdx: number): boolean =>
   questions.value[cardIdx]?.freeformInputOnTop === true;
 
 /**
- * 自由输入那一行是否算"选中"(同时决定它的边框样式、以及答案是否算作出自输入框)。
- *
- * ⚠️ 2026-09-24 两次修正,别改回去:
- * ① 最早写的是 `常驻输入框 || 被点开` —— **常驻输入框恒为 true**,那一行永远带选中边框;
- * ② 改成"只有填了内容才算"又过头了:**点了输入框却没有高亮**,而且点了它右边的选项还亮着
- *    (用户反馈「左边可以输入,但是边框没有高亮;点击右边高亮后,再点左边右边依旧高亮」)。
- * 现在:**被点/聚焦过(isFreeformActive)或填了内容**都算选中它;
- * 单选卡片在 `handleAlwaysOnFreeformInteract` 里会把别的选项清掉,所以高亮仍然只跟一个走。
+ * 自由输入那一行是否算"选中"(决定它的边框样式、以及答案是否算作出自输入框):
+ * 被点/聚焦过或填了内容都算;单选卡片的互斥由 handleAlwaysOnFreeformInteract 保证。
  */
 const isFreeformSelected = (cardIdx: number): boolean => {
   const card = questions.value[cardIdx];
@@ -435,9 +401,8 @@ const isFreeformSelected = (cardIdx: number): boolean => {
 };
 
 /**
- * 点/聚焦"常驻输入框"这一行 = 选了 A:
- *   · 记下"自由输入已激活"(高亮它)
- *   · **单选卡片顺手清掉已选选项** —— 这正是"一次只有一个选项是选中样式"
+ * 点/聚焦"常驻输入框"这一行 = 选了 A:记下自由输入已激活(高亮它),
+ * 单选卡片顺手清掉已选选项(保证一次只有一个选项是选中样式)。
  */
 const handleAlwaysOnFreeformInteract = (cardIdx: number) => {
   if (submitted.value || isReadonly.value) return;
@@ -467,22 +432,18 @@ const toggleOption = (cardIdx: number, optIdx: number) => {
   const card = questions.value[cardIdx];
   if (!card) return;
 
-  // Clear freeform when selecting options
   isFreeformActive.value[cardIdx] = false;
   handleFreeformBlur();
 
   const selected = ensureSelected(cardIdx);
   if (card.multiSelect) {
-    // Multi-select: toggle
     if (selected.has(optIdx)) {
       selected.delete(optIdx);
     } else {
       selected.add(optIdx);
     }
-    // Trigger reactivity
     selectedOptions.value = { ...selectedOptions.value, [cardIdx]: new Set(selected) };
   } else {
-    // Single select: toggle or replace
     if (selected.has(optIdx)) {
       selected.clear();
     } else {
@@ -498,7 +459,6 @@ const toggleFreeform = (cardIdx: number) => {
   const card = questions.value[cardIdx];
   if (!card) return;
 
-  // Clear options when selecting "其他"
   isFreeformActive.value[cardIdx] = !isFreeformActive.value[cardIdx];
   isFreeformActive.value = { ...isFreeformActive.value };
 
@@ -514,7 +474,7 @@ const toggleFreeform = (cardIdx: number) => {
 };
 
 const canSubmit = computed(() => {
-  // 只读态(已作答、内容已写回)不允许再提交 —— 提交按钮保持可见但禁用(样式与问卷一致)
+  // 只读态不允许再提交,但提交按钮保持可见(样式与问卷一致)
   if (submitted.value || isReadonly.value) return false;
   return questions.value.some((_card, cardIdx) => {
     const selected = selectedOptions.value[cardIdx];
@@ -552,7 +512,7 @@ const handleSubmit = () => {
     return {
       header: card.header,
       selected: selectedLabels,
-      // 常驻输入框不需要点击激活,输入的内容照样要带上
+      // 常驻输入框不需要点击激活,但输入的内容照样要带上
       freeText: isFreeformSelected(cardIdx) ? (freeformTexts.value[cardIdx] || '') : '',
       skipped: false,
     };
@@ -564,7 +524,7 @@ const handleSubmit = () => {
   if (injectSubmit) {
     injectSubmit(answers);
   } else {
-    console.warn('[QuestionCard] No submit handler provided via inject');
+    /* console.warn('[QuestionCard] No submit handler provided via inject'); */
   }
   isSubmitting.value = false;
 };
@@ -687,7 +647,6 @@ const handleSubmit = () => {
   }
 }
 
-// Custom dropdown
 .qc-dropdown-wrapper {
   flex: 1 1 calc(50% - 4px);
   min-width: 220px;

+ 1 - 2
src/components/Chat/ScopeContent.vue

@@ -730,8 +730,7 @@ onBeforeUnmount(() => {
     overflow-wrap: anywhere;
   }
 
-  // 标题恒定单行:卡片撑满容器宽度时原本会切成 pre-wrap 换行,
-  // 而阶段文案长度不一,切换时表现为“突然换行”、卡片高度跳动。
+  // 标题恒定单行:阶段文案长度不一,换成 pre-wrap 会表现为“突然换行”、卡片高度跳动
   .scope-header-title {
     white-space: nowrap;
     overflow: hidden;

+ 10 - 20
src/components/Chat/TextContent.vue

@@ -63,7 +63,7 @@ renderer.heading = function ({ tokens, depth }) {
 };
 
 renderer.link = function ({ href, title, text }) {
-  // 增加 rel="noopener noreferrer" 以防止钓鱼攻击,增加 target="_blank" 以在新标签页中打开链接
+  // rel="noopener noreferrer" 防钓鱼;target="_blank" 在新标签页打开
   return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
 };
 
@@ -84,17 +84,11 @@ let pendingIndex = 0
 let lastTypingTime = 0
 const isFading = ref(false)
 const isTypingActive = ref(false)
-const lastContent = ref('') // 记录上一次的 content
+const lastContent = ref('')
 const batchRevealTerminalTags = new Set(["PRE", "CODE", "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD"])
 const batchRevealListTags = new Set(["UL", "OL"])
-// 打字机速度。
-//
-// 基准参考主流 AI 的实测输出速度(字符口径):Claude 3.5 约 60 字符/秒、
-// DeepSeek-R1 约 75 字符/秒,主流区间 50~120,快速模型取的 120。
-//
-// 这里取区间**上沿**(4 字/33ms ≈ 121 字符/秒),原因:主流产品是模型边生成边流出,
-// 用户感知的等待约等于生成时间;而本项目的后端**一次返回整段**,打字机是纯额外延迟——
-// 同样 60 字符/秒会比主流慢一整段。取上沿让展开速度不再是瓶颈。
+// 打字机速度:4 字/33ms ≈ 121 字符/秒(主流区间上沿)。
+// 后端是一次返回整段,打字机属于纯额外延迟,取上沿让展开速度不再是瓶颈。
 const typingInterval = 33
 const typingCharsPerTick = 4
 // 积压越多打得越快,避免长回答的展开明显落后于阅读
@@ -108,7 +102,7 @@ const typingBurstThresholds = [
 const pendingCompactThreshold = 1000
 let lastTypewriterTickTime = 0
 const logTyping = (event, payload = {}) => {
-  console.log(`[TextContent][typewriter][${event}]`, payload)
+  /* console.log(`[TextContent][typewriter][${event}]`, payload) */
 }
 
 function setTypingActive(next) {
@@ -145,13 +139,13 @@ function appendBatchReveal(batchText) {
     return
   }
 
-  console.log("[TextContent][batch-reveal:start]", {
+  /* console.log("[TextContent][batch-reveal:start]", {
     batchLength: batchText.length,
     pendingLength: getPendingLength(),
     typingFrame: Boolean(typingFrame),
     isFading: isFading.value,
     blockMarkdown: props.blockMarkdown,
-  })
+  }) */
 
   if (activeBatchRevealNode) {
     activeBatchRevealNode.textContent += batchText
@@ -174,12 +168,12 @@ function appendBatchReveal(batchText) {
         return
       }
 
-      console.log("[TextContent][batch-reveal:end]", {
+      /* console.log("[TextContent][batch-reveal:end]", {
         batchLength: batchText.length,
         pendingLength: getPendingLength(),
         typingFrame: Boolean(typingFrame),
         hasActiveNode: activeBatchRevealNode === batchNode,
-      })
+      }) */
 
       if (activeBatchRevealNode === batchNode) {
         activeBatchRevealNode = null
@@ -459,7 +453,6 @@ function clearTypewriter(withFade = true) {
   minHeight.value = 0
   setTypingActive(false)
   if (typewriterRef.value && withFade) {
-    // 触发淡出动画
     isFading.value = true
     if (fadeTimer) {
       clearTimeout(fadeTimer)
@@ -478,9 +471,7 @@ watch(
   () => props.content,
   async (val, oldVal) => {
     await nextTick()
-    // 判断是清空还是追加
     if (!val) {
-      // 内容被清空
       logTyping("watch-empty", {
         oldLength: String(oldVal || "").length,
         activeTypewriter: props.activeTypewriter,
@@ -521,7 +512,7 @@ watch(
       return;
     }
 
-    // 如果是追加(新内容比旧内容长,且前缀一致),则从上次结束处继续
+    // 追加(新内容以旧内容为前缀且更长)时从上次结束处继续
     let fromIndex = 0
     if (
       typeof oldVal === 'string' &&
@@ -536,7 +527,6 @@ watch(
       activeTypewriter: props.activeTypewriter,
       identityChanged: false,
     })) {
-      // 内容被替换或缩短,重新打字
       logTyping("watch-restart", {
         contentLength: String(val || "").length,
         oldLength: String(oldVal || "").length,

+ 7 - 16
src/components/Chat/markdown-plain-text.ts

@@ -1,17 +1,8 @@
 /**
- * markdown → **纯文本**(回答的「复制」按钮用)。
+ * markdown → **纯文本**(回答的「复制」按钮用):去掉 `#`、`**`、`` ` ``、`- ` 等语法符号,
+ * 但**保留链接地址**(写成「文字(地址)」)与有序列表的序号。
  *
- * 用户 2026-09-22 反馈:复制出来的内容还带着 `###`、`**`、`[文字](链接)` 这些语法符号,
- * 粘到别处很难看。这里把它们去掉。
- *
- * **为什么走 marked 的 token 树、而不是渲染成 HTML 再取文本**:
- * 取 textContent 会把**链接地址丢掉**(`[查看产品详情](https://…)` 只剩「查看产品详情」),
- * 而复制场景里地址恰恰是要留的 —— 所以取 token 树,把链接写成 `文字(地址)`。
- *
- * 去掉:标题的 `#`、`**加粗**`、`*斜体*`、`` `代码` ``、`[文字](链接)` 的括号语法、
- * 无序列表的 `- `、引用符号、分隔线、HTML 标签。
- * 保留:全部可见文字、**有序列表的序号**(它承载步骤顺序,不是纯装饰)、
- * 链接地址(写成 `文字(地址)`)。
+ * 走 marked 的 token 树而非「渲染成 HTML 再取 textContent」——后者会把链接地址丢掉。
  */
 
 import { marked, type Token } from "marked";
@@ -43,7 +34,7 @@ const inlineTokenToText = (token: Token): string => {
     }
     case "image": {
       const href = String(t.href ?? "");
-      if (/^data:/i.test(href)) return ""; // data URL 复制出去没意义
+      if (/^data:/i.test(href)) return "";
       return String(t.text ?? "") || href;
     }
     case "html":
@@ -117,12 +108,12 @@ export const markdownToPlainText = (markdown: string): string => {
       .map((token) => blockToText(token))
       .join("");
   } catch (err) {
-    console.warn("[markdown-plain-text] 解析失败,退回简单剥离:", (err as Error)?.message || err);
+    /* console.warn("[markdown-plain-text] 解析失败,退回简单剥离:", (err as Error)?.message || err); */
     text = fallbackStrip(source);
   }
 
   return text
-    .replace(/[ \t]+\n/g, "\n") // 行尾空白
-    .replace(/\n{3,}/g, "\n\n") // 多余空行
+    .replace(/[ \t]+\n/g, "\n")
+    .replace(/\n{3,}/g, "\n\n")
     .trim();
 };

+ 7 - 16
src/components/Chat/policy-library-utils.ts

@@ -1,11 +1,7 @@
 /**
- * 惠企政策库的纯逻辑。
- *
- * 惠企政策 = `policies_public.v1.json` 里的那些政策
- * (由 index.html 预加载到 `globalThis.policiesPublic`)。
- *
- * 抽成独立模块的目的:让 `harness/tools/` 里的验证脚本能直接引用同一份实现,
- * 而不是复制一份——复制会漂移,测的就不是真代码了。
+ * 惠企政策库的纯逻辑(`policies_public.v2.json` 里的政策,由 index.html 预加载到
+ * `globalThis.policiesPublic`)。
+ * 抽成独立模块,是为了让 `harness/tools/` 的验证脚本能直接引用同一份实现。
  */
 
 /** 政策详情页地址前缀(一网通办·青浦惠企政策专区) */
@@ -13,11 +9,8 @@ export const POLICY_DETAIL_URL_BASE =
   "https://zwdt.sh.gov.cn/qykj/shell_oc_policy_zq/policy/policy-detail";
 
 /**
- * 由库条目的「市级政策id」拼出政策详情页地址。
- *
- * 背景:`policies_public.v1.json` 里 `apply_link` 字段 262 条**全为空**,
- * 而 `资源申请备注` 里的 URL 是按申报事项零散对应、且混有内网地址,不能作为主要来源。
- * `data.市级政策id` 则有 261/262 条(40 个不同 id),是可靠来源。
+ * 由库条目的「市级政策id」拼出政策详情页地址(库里的 `apply_link` 全为空,
+ * 「资源申请备注」里的 URL 零散且混有内网地址,只有这个 id 可靠)。
  *
  * 没有 id 时返回空串 —— **不猜**:宁可没有链接,也不给一个打不开的地址。
  */
@@ -35,10 +28,8 @@ export const normPolicyName = (text: unknown): string =>
 
 /**
  * 从惠企政策库原始条目里取出用于匹配的政策名集合。
- *
- * ⚠️ 两个键都要认:老数据是 `name`,**2026-09-24 换成的 v2 用中文键「政策名称」**
- * —— 只认 `name` 会让集合变空,`isPolicyLibraryItem` 的"标题能在库里找到"这条回退路径
- * 就永远为假(实测踩过:v2 换进来后库里前 3 条全判成"非惠企政策")。
+ * ⚠️ 两个键都要认:老数据是 `name`,v2 用中文键「政策名称」——
+ * 只认 `name` 会让集合变空,"标题能在库里找到"这条回退路径就永远为假。
  */
 export const buildLibraryNameSet = (libraryPolicies: unknown[] | null | undefined): Set<string> =>
   new Set(

+ 11 - 31
src/components/Chat/policy-match-utils.ts

@@ -3,20 +3,9 @@ export type PolicyListFilterOption = "所有分类" | "只看免申即享" | "
 /**
  * 由「被点击的条目」反查它在 `policies` 数组里的下标;查不到返回 -1。
  *
- * 🐛 为什么需要这个函数(2026-09-17 修的真实 bug):
- * 同一条政策下的**多个申报事项**会各生成一张卡片,但适配层把卡片的
- * `title` 与 `declaration_item` 都取自 `card.name.text`(**只有政策名**),
- * 而后端又没有下发 `id` / `policy_id`(实测恒为 undefined)。
- * 于是这些卡片在「按字段比对」这条路上**完全无法区分**——
- * 原先的 findIndex 只要任一条件命中就返回,导致点第二张卡片也跳到第一张,
- * 表现为「两张卡片的详情内容一模一样」。
- *
- * 匹配顺序(从可靠到不可靠):
- *   1. **对象同一性**:卡片区传入的就是 policies 里的那个对象,这一条是精确的
- *   2. 字段比对(id / policy_id / declaration_item):**只有唯一命中才采用**。
- *      多命中说明这组数据本就区分不了(例如同一政策的多个申报事项),
- *      此时返回 -1 交给调用方用「直接展示被点条目自己」的兜底路径 ——
- *      宁可走兜底,也不要张冠李戴地展示另一条的内容。
+ * 先按**对象同一性**(精确);再按字段比对,但**只有唯一命中才采信** ——
+ * 同一政策的多个申报事项在字段上无法区分,多命中说明这组数据本就区分不了,
+ * 此时返回 -1 让调用方走「直接展示被点条目自己」的兜底,避免张冠李戴。
  */
 export const resolvePolicyDetailIndex = <T extends { originalData?: any; declaration_item?: string }>(
   policies: T[] | null | undefined,
@@ -46,24 +35,16 @@ export const resolvePolicyDetailIndex = <T extends { originalData?: any; declara
   return matched.length === 1 ? matched[0] : -1;
 };
 
-// ─── 「更多」→ 政策列表 侧边栏用的纯逻辑 ────────────────────────────────
-//
-// 侧边栏列的是**本条回答里出现的政策**(用户 2026-09-18 调整;此前一度做成
-// 「整个会话跨消息聚合」,已撤回)。所以这里只留去重 / 排序 / 关键词过滤这几个
-// 通用零件,数据源由 PolicyMatch 自己提供(props.policyData / policyContent)。
+// 「更多」→ 政策列表侧边栏用的纯逻辑零件(去重 / 排序 / 关键词过滤),
+// 数据源由 PolicyMatch 自己提供(props.policyData / policyContent)。
 
 /**
- * 卡片去重键。
- *
- * 主键用 `declaration_item`:自 2026-09-17 起它是「政策名 - 申报事项」,每条唯一
- * (之前它与政策名同值,同政策的多个申报事项无法区分 —— 见 policy-match-utils
- * 里 resolvePolicyDetailIndex 的说明)。
- *
+ * 卡片去重键:主键用 `declaration_item`(「政策名 - 申报事项」,每条唯一)。
  * 精简格式(minimal)的 `declaration_item` 恒为占位符「查看政策详情」,
  * 用它去重会把不同卡片错并成一条,故退化为「标题|匹配度|匹配理由|id」组合键。
  */
-// 入参刻意用 any:进来的是从消息里解析出的无类型 JSON(也可能是已 normalize 的 PolicyItem),
-// 收窄成具体类型反而会让 dedupePolicyItems 的泛型推断跑偏(会把 T 推成 Record<string, any>)
+// 入参刻意用 any:进来的是从消息里解析出的无类型 JSON,收窄成具体类型会让
+// dedupePolicyItems 的泛型推断跑偏(会把 T 推成 Record<string, any>)
 export const buildPolicyCardDedupKey = (item: any): string => {
   const declaration = String(item?.declaration_item ?? "").trim();
   if (declaration && declaration !== "查看政策详情") {
@@ -77,7 +58,7 @@ export const buildPolicyCardDedupKey = (item: any): string => {
   ).slice(0, 60)}|${id}`;
 };
 
-/** 按 key 去重,**首见优先**(保证聚合结果与会话内出现顺序一致、可预期) */
+/** 按 key 去重,**首见优先**(保证聚合顺序与会话内出现顺序一致) */
 export const dedupePolicyItems = <T>(items: T[] | null | undefined, keyOf: (item: T) => string): T[] => {
   const seen = new Set<string>();
   return (Array.isArray(items) ? items : []).filter((item) => {
@@ -101,9 +82,8 @@ export const sortPolicyCardsByMatchScore = <T extends { match_score?: number | s
     .map(({ item }) => item);
 
 /**
- * 卡片关键词匹配。检索面与旧列表 buildRecordPolicyList 保持一致
- * (title / desc / declaration_item / match_reason / source / enterprise_benefit)。
- * 空关键词恒真;大小写不敏感。
+ * 卡片关键词匹配。检索面与旧列表 buildRecordPolicyList 保持一致;
+ * 空关键词恒真,大小写不敏感。
  */
 export const matchesPolicyCardKeyword = (
   item: {

+ 1 - 3
src/components/Chat/text-content-utils.ts

@@ -25,14 +25,12 @@ const urlStopChars = new Set([
   ";",
 ]);
 
-// 感叹号 ! 经常用于 struts/java 应用的 action 映射中,如 !toQuestion.action
-// 这里将其从 urlStopChars 中移除,不再作为 URL 的中断字符
+// `!` 不当 URL 中断字符(常出现在地址里,如 !toQuestion.action)
 const urlStopCharsWithoutAllowed = new Set([...urlStopChars].filter(char => char !== '!'));
 
 function splitUrlTail(value: string) {
   for (let i = 0; i < value.length; i++) {
     const char = value[i];
-    // 使用过滤后的集合进行判断
     if (urlStopCharsWithoutAllowed.has(char)) {
       return {
         url: value.slice(0, i),

+ 33 - 26
src/components/Common/FeedbackWidget.vue

@@ -19,7 +19,7 @@
       </div>
     </div>
 
-    <!-- 点赞结果 (axwuS) -->
+    <!-- 点赞结果 -->
     <div
       class="feedback-result"
       v-if="localFeedback === FeedbackType.Like"
@@ -36,7 +36,7 @@
       <span class="result-text">感谢您的认可,我们会继续为您提供更加高效优质的服务。</span>
     </div>
 
-    <!-- 点踩结果 (jF1Ax) -->
+    <!-- 点踩结果 -->
     <div
       class="feedback-result"
       v-if="localFeedback === FeedbackType.Dislike"
@@ -53,7 +53,7 @@
       <span class="result-text">感谢您的反馈,我们会持续精进!</span>
     </div>
 
-    <!-- W99j5 取消提示 -->
+    <!-- 取消提示(悬浮气泡) -->
     <Teleport to="body">
       <transition name="popover-fade">
         <div
@@ -118,8 +118,7 @@ watch(() => props.feedback, () => {
 });
 
 function updateCancelPopoverAt(e: MouseEvent) {
-  // 定位点为鼠标指针 (clientX, clientY),再由 .cancel-popover 的 transform
-  // 把它偏移到"鼠标正上方居中 8px 处"。
+  // 定位点取鼠标指针(clientX/clientY),偏移量见 .cancel-popover 的 transform
   popoverStyle.value = {
     position: "fixed",
     left: e.clientX + "px",
@@ -161,19 +160,35 @@ function handleDislike() {
   }
 }
 
+/**
+ * 写剪贴板。
+ * ⚠️ `navigator.clipboard` **只在安全上下文存在**(HTTPS / localhost)——HTTP 部署下它是
+ * `undefined`,`navigator.clipboard.writeText(...)` 会**同步抛错**,所以不能只靠 `.catch()`
+ * 兜底(那只管异步失败)。这里显式判断 + try/catch,最后落到 `execCommand` 老办法。
+ */
+async function writeClipboard(text: string) {
+  try {
+    if (navigator.clipboard?.writeText) {
+      await navigator.clipboard.writeText(text);
+      return;
+    }
+  } catch {
+    /* 落到下面的兜底 */
+  }
+  const textarea = document.createElement("textarea");
+  textarea.value = text;
+  textarea.style.position = "fixed";
+  textarea.style.opacity = "0";
+  document.body.appendChild(textarea);
+  textarea.select();
+  document.execCommand("copy");
+  document.body.removeChild(textarea);
+}
+
 function handleCopy() {
   if (!props.content) return;
   const target = copyBtnRef.value;
-  navigator.clipboard.writeText(props.content).catch(() => {
-    const textarea = document.createElement("textarea");
-    textarea.value = props.content;
-    textarea.style.position = "fixed";
-    textarea.style.opacity = "0";
-    document.body.appendChild(textarea);
-    textarea.select();
-    document.execCommand("copy");
-    document.body.removeChild(textarea);
-  });
+  void writeClipboard(props.content);
   emit("copy", props.content);
 
   if (target) {
@@ -261,9 +276,7 @@ function handleCancelFeedback() {
   padding: 2px 8px;
   pointer-events: none;
   z-index: 99999;
-  // 定位点 (left, top) = 鼠标指针 (clientX, clientY)。
-  // X 方向 -50% 让气泡水平居中在鼠标 X 上;Y 方向 -100% - 8px 让气泡
-  // 底部位于鼠标 Y 上方 8px,从而"悬浮在鼠标正上方"。
+  // 定位点 = 鼠标指针,translate 把它抬到鼠标正上方居中 8px 处
   transform: translate(-50%, calc(-100% - 8px));
   white-space: nowrap;
 
@@ -276,9 +289,7 @@ function handleCancelFeedback() {
   }
 }
 
-// 复制成功提示气泡,定位点为复制按钮中心,向上 4px,
-// 通过 translate 把气泡放在按钮上方居中位置。
-// 默认 transform 的 Y = -100% - 18px(气泡底部距按钮顶部 18px)。
+// 复制成功提示气泡:定位点为复制按钮中心,translate 把它放在按钮上方居中处
 .copy-popover {
   background: #ffffff;
   border-radius: 2px;
@@ -298,11 +309,7 @@ function handleCancelFeedback() {
   }
 }
 
-// 复制气泡专用 transition:
-// 起点 = 紧贴按钮上沿(Y = -100%,即气泡底部正好在按钮顶部 4px 位置),
-// 终点 = 默认位置(Y = -100% - 18px,气泡底部在按钮顶部上方 18px)。
-// 仅 Y 分量变化 28px,X 始终 -50%,保证纯垂直方向的上滑轨迹。
-// 用 cubic-bezier 缓出强化"向上飘出"的视觉感受。
+// 复制气泡专用 transition:只做 Y 方向上滑(X 恒为 -50%,保持水平居中)
 .copy-popover-fade-enter-from,
 .copy-popover-fade-leave-to {
   opacity: 0;

+ 1 - 1
src/components/Common/MediaViewer.vue

@@ -370,7 +370,7 @@ const openModal = async (payload) => {
 }
 
 const hideModal = async () => {
-  console.log('hide modal')
+  /* console.log('hide modal') */
   movingHorizontal.value = false
   movingVertical.value = false
   handlePause()

+ 1 - 1
src/components/Common/References.vue

@@ -32,7 +32,7 @@ onMounted(() => {
 const { handleSendQuestion } = inject("messageControl");
 
 const handleSend = (text) => {
-  console.log(text);
+  /* console.log(text); */
   visible.value = false;
   handleSendQuestion(text);
 };

+ 1 - 1
src/components/Common/RelatedQuestionsWidget.vue

@@ -32,7 +32,7 @@ onMounted(() => {
 const { handleSendQuestion } = inject("messageControl");
 
 const handleSend = (text) => {
-  console.log(text);
+  /* console.log(text); */
   visible.value = false;
   handleSendQuestion(text);
 };

+ 1 - 1
src/components/Common/ScrollList.vue

@@ -646,7 +646,7 @@ defineExpose({
 .list-scroll-wrap {
   box-sizing: border-box;
   pointer-events: initial;
-  /* 不再直接写 transition,交由绑定的 style 控制 */
+  /* transition 由绑定的 style 控制 */
   position: relative;
 }
 

+ 53 - 122
src/components/answer-stream.ts

@@ -1,31 +1,17 @@
 /**
- * 正文真实增量流式输出(F073)的**纯逻辑层**。
+ * 正文真实增量流式输出(F073)的纯逻辑层:`answer_start / answer_delta /
+ * answer_end / answer_abort` 事件的解析、状态机与 markdown 增量规整器。
  *
- * 契约见 `harness/docs/reference/token-streaming.md`:后端对 `/api/chat` 增加
- * `answer_start / answer_delta / answer_end / answer_abort` 四个事件,正文逐片段到达。
- *
- * 本文件放三样东西(**不 import vue、不发网络请求**,可被 harness 的 esbuild 直接打包断言):
- * 1. `parseAnswerStreamEvent` —— 事件形状解析(容忍缺字段,形状不符返回 null 走旧路径)
- * 2. `applyAnswerStreamEvent` —— 状态机:把事件折算成「状态 + 效果」,副作用交给协调器执行
- * 3. `createIncrementalMarkdownNormalizer` —— **增量规整器**(见下)
- *
- * 另把原本写在协调器里的 `normalizeChatMarkdown` / `LIST_ITEM_RE` 移到这里
- * (协调器改为从这里 re-export,对外 API 不变)。
+ * 不 import vue、不发网络请求,可被 harness 的 esbuild 直接打包断言。
  */
 
-/* ------------------------------------------------------------------ *
- * markdown 规整(原先在 api-chat-coordinator.ts)
- * ------------------------------------------------------------------ */
+/* markdown 规整 */
 
 export const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
 
 /**
- * 规整 markdown 换行。
- *
- * 正文块用的是 marked + `white-space: normal`,单个 \n 会被当成软换行直接折叠,
- * 于是"列表后紧跟的说明行"会被并进上一个列表项(表现为多段挤成一段)。
- * 这里把非列表项之间的单换行提升为空行,让它成为独立段落;
- * 连续列表项之间仍保留单换行,避免打断有序列表的编号。
+ * 规整 markdown 换行:渲染层是 `white-space: normal`,单个 \n 会被折叠,
+ * 于是把非列表项之间的单换行提升为空行;连续列表项之间保留单换行,不打断编号。
  */
 export function normalizeChatMarkdown(text: string): string {
   const value = String(text || '');
@@ -51,31 +37,22 @@ export function normalizeChatMarkdown(text: string): string {
 }
 
 /**
- * **增量规整器**:把逐片段到达的原始正文,变成「与 `normalizeChatMarkdown(全文)`
- * 逐步同前缀」的增量文本。
- *
- * 为什么需要:上面那个规整函数是**插入**空行的(不是清洗),所以「把 delta 直接拼起来」
- * 与「整段规整后的结果」在**多段落回答上必然不同** → `answer_end` 的整块替换会与已流文本
- * 分歧 → 应用层只能整段重建 → `<scope>` 标记被重解析 → 思考中卡片闪现并重播退场动画。
- *
- * 做法(每处插入决策只依赖相邻两行,且行的判定在该行结束(收到 \n)时才稳定):
- * - 已经结束的行:立即按完整决策发出(含它前面该不该插空行)
- * - 当前**未结束**的行:先扣住;只有当能判定它「已经是列表项前缀」或「不可能成为列表项」
- *   才释放(扣住的长度最多几字符)
+ * 增量规整器:把逐片段到达的原始正文变成「与 `normalizeChatMarkdown(全文)` 逐步同前缀」
+ * 的增量文本 —— 否则 `answer_end` 的整块替换会与已流文本分歧,导致整段重建与卡片闪现。
  *
  * 不变式:**任一时刻 `已发出的全部文本 === normalizeChatMarkdown(已输入的全部文本)`**。
  */
 export interface IncrementalMarkdownNormalizer {
   /** 输入一段原始增量,返回应立即展示的规整增量(可能为空串——还在扣前缀) */
   push(text: string): string;
-  /** 收尾:释放仍在扣住的部分(EOF / 正常结束前调用;end 用最终全文覆盖,故非必须) */
+  /** 收尾:释放仍在扣住的部分(end 会用最终全文覆盖,故非必须) */
   finish(): string;
 }
 
-/** 判断 `line` 是否**已能确定**是列表项(正则无 `$` 锚,命中即不会再被后续字符推翻) */
+/** `line` 是否已能确定是列表项(正则无 `$` 锚,命中即不会被后续字符推翻) */
 const isListItemSoFar = (line: string): boolean => LIST_ITEM_RE.test(line);
 
-/** 判断 `line` 是否**还可能**变成列表项(是则不能提前释放) */
+/** `line` 是否还可能变成列表项(是则不能提前释放) */
 const couldBecomeListItem = (line: string): boolean =>
   /^\s*$/.test(line) || // 只有空白,后面可能跟 - 或数字
   /^\s*[-*+]\s*$/.test(line) ||
@@ -89,12 +66,12 @@ export const createIncrementalMarkdownNormalizer = (): IncrementalMarkdownNormal
   let prevLine: string | null = null;
   /** 当前输入行(可能还没结束) */
   let pendingLine = '';
-  /** 当前行的「空行分隔 + 内容」是否已发出(若已发出,后续字符直接续写) */
+  /** 当前行的内容是否已发出 */
   let lineOpen = false;
-  /** 是否收到过任何输入(空输入不该在 finish 里凭空产出空行) */
+  /** 是否收到过输入(空输入不该在 finish 里凭空产出空行) */
   let received = false;
 
-  /** 判断两行之间是否需要插入空行(与 normalizeChatMarkdown 同一判据) */
+  /** 两行之间是否需要插入空行(与 normalizeChatMarkdown 同一判据) */
   const needsBlankBetween = (prev: string, line: string): boolean => {
     const prevBlank = prev.trim() === '';
     const lineBlank = line.trim() === '';
@@ -103,10 +80,9 @@ export const createIncrementalMarkdownNormalizer = (): IncrementalMarkdownNormal
   };
 
   /**
-   * 开出一行:先补「需要的话多一个 \n」(即插入空行),再写内容。
-   * ⚠️ 换行是**行与行之间的分隔符**,所以行末的 `\n` 由「下一行出现」或
-   * 「输入里的 `\n`」来补,不能在这一行结束时就直接加上一个(否则末行会多出换行,
-   * 与 `normalizeChatMarkdown` 的结果不一致)。
+   * 开出一行:先补空行(需要的话),再写内容。
+   * 换行是**行与行之间的分隔符**,行末的 `\n` 由「下一行出现」或「输入里的 `\n`」补,
+   * 不能在本行结束时直接加一个(否则末行多出换行,与 normalizeChatMarkdown 不一致)。
    */
   const openLine = (line: string): void => {
     if (prevLine !== null && needsBlankBetween(prevLine, line)) {
@@ -172,17 +148,11 @@ export const createIncrementalMarkdownNormalizer = (): IncrementalMarkdownNormal
   };
 };
 
-/* ------------------------------------------------------------------ *
- * 应用层的内容对齐(纯函数,供 useBusinessAssistantChat 调用)
- * ------------------------------------------------------------------ */
+/* 应用层的内容对齐(纯函数,供 useBusinessAssistantChat 调用) */
 
 /**
- * `answer_end` 到了:把已上屏的内容对齐到最终全文。
- *
- * 正常路径是**纯尾缀追加**(增量规整器保证了「已上屏的 = 最终全文的逐步前缀」),
- * 界面零抖动。罕见情况下后端最终文本与增量拼接不一致 → 整块重建:
- * 基线里可能含 `<scope>` 进度标记,重解析会让「思考中」卡片闪现并重播退场动画,
- * 所以重建时先把这段剥掉(正文内容不受影响)。
+ * `answer_end`:把已上屏的内容对齐到最终全文 —— 正常是纯尾缀追加(零抖动);
+ * 与增量拼接不一致时整块重建,重建前先剥掉基线里的 `<scope>`(否则卡片会闪现重播)。
  */
 export const applyAnswerStreamEnd = (
   content: string,
@@ -192,25 +162,21 @@ export const applyAnswerStreamEnd = (
   const text = String(content || '');
   const body = String(streamedBody || '');
 
-  // 兜底:上屏内容末尾不是这段流式正文(被别的路径改过)→ 什么都不切,直接把最终正文接上,**绝不丢字**
+  // 兜底:末尾不是这段流式正文(被别的路径改过)→ 什么都不切,直接接上最终正文(绝不丢字)
   if (!body || !text.endsWith(body)) {
     return body ? text + finalText : text + finalText;
   }
   if (finalText.startsWith(body)) {
     return text + finalText.slice(body.length); // 正常路径:纯尾缀追加
   }
-  // 罕见分歧(后端最终校验改过草稿):整块重建;基线里的 <scope> 先剥掉,免得卡片闪现重播
+  // 罕见分歧(后端最终校验改过草稿):整块重建,基线里的 <scope> 先剥掉
   const base = text.slice(0, text.length - body.length).replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, '');
   return base + finalText;
 };
 
 /**
- * 流式轮里,`answer` 事件那段**开场概述**要不要展示?
- *
- * 实测(2026-09-20 两份真实载荷):后端把正文作为 `summary` 流式下发,而 `answer`
- * 往往是**同一段开头的压缩版**(一份 134 字 vs 正文 2199 字,差别只是空白折叠),
- * 显示它会出现"开头重复一遍"。所以判据是:**去掉空白后它是不是流式正文的前缀**——
- * 是 → 压缩版,跳过;不是 → 是另一段内容,**必须保留**(宁可多一段,不能丢字)。
+ * 流式轮里 `answer` 事件那段开场概述要不要展示:去掉空白后是流式正文的前缀 → 同一段的
+ * 压缩版,跳过;不是 → 另一段内容,必须保留(宁可多一段,不能丢字)。
  */
 export const shouldKeepAnswerOverview = (overview: string, streamBody: string): boolean => {
   const collapsed = String(overview || '').replace(/\s+/g, '');
@@ -218,9 +184,7 @@ export const shouldKeepAnswerOverview = (overview: string, streamBody: string):
   const body = String(streamBody || '').replace(/\s+/g, '');
   if (!body) return true; // 正文为空:绝不能丢
 
-  // 判据:与正文的**公共前缀**占概述的比重。取 90% 是因为实测的压缩版是
-  // 「截断在词中间」的(正文接着 '###',而概述结尾是半个词 '部分候'),
-  // 严格前缀比较认不出来;而真·另一段内容的公共前缀通常只有几个字。
+  // 判据:与正文的公共前缀占概述的比重(压缩版常截断在词中间,严格前缀比不出来)
   const max = Math.min(collapsed.length, body.length);
   let common = 0;
   while (common < max && collapsed[common] === body[common]) common++;
@@ -228,14 +192,9 @@ export const shouldKeepAnswerOverview = (overview: string, streamBody: string):
 };
 
 /**
- * 流式轮结束时的**最终组装**:把「正文之上」「正文」「正文之下」三块拼成完整内容。
- *
- * 为什么要分「之上/之下」:正文(`summary`)是整篇长文,若把政策卡片照旧排在正文之后,
- * 用户要先滚过整篇文章才看到卡片 —— 与改造前的观感(短开场 → 卡片 → 综合说明)不一致。
- * 所以流式轮把卡片放到**正文之上**(用户 2026-09-20 指出),参考资料/二维码等仍在正文之下。
- *
- * 组装时把基线上的 `<scope>` 进度标记剥掉:这一步是整块重写,留着会让「思考中」卡片
- * 被重新解析出来、闪现并重播一次退场动画。
+ * 流式轮的最终组装:把「正文之上」「正文」「正文之下」三块拼成完整内容 ——
+ * 正文是整篇长文,卡片需排在正文之上,参考资料/二维码仍在正文之下。
+ * 拼接前剥掉基线里的 `<scope>` 进度标记(整块重写会让卡片被重新解析、闪现重播)。
  */
 export const composeStreamedContent = (
   baseText: string,
@@ -251,15 +210,8 @@ export const composeStreamedContent = (
 };
 
 /**
- * 这段「开场概述」**是不是已经会在别处展示**?(是的话就不必再摆一遍)
- *
- * 为什么需要:实测(2026-09-21)后端的 `answer.text` 有时**就是** `summary.notices` 里的
- * 一句 —— 「需求拆解失败,本次仅按原问题检索。」两边都有。都展示的话同一句话会出现两次;
- * 更要紧的是它会走「保留概述」分支 → 整块重排 → 正文重渲一遍。
- *
- * 判据只有两条,都是「这句已经在屏幕上」的意思,**不会丢内容**:
- * ① 与某条 notice 归一化后完全相同
- * ② 正文里已经含这句(说明它本来就是正文的一段)
+ * 这段开场概述是不是已经会在别处展示(是就不必再摆一遍,否则会走整块重排):
+ * 与某条 notice 归一化后相同,或正文里已经含这句。
  */
 export const isOverviewAlreadyShown = (
   overview: string,
@@ -274,15 +226,9 @@ export const isOverviewAlreadyShown = (
 };
 
 /**
- * `answer_end` 之后,把应用侧记录的「流式正文」同步成**上屏正文**。
- *
- * 为什么必须同步:`answer_end` 的最终文本**可能比增量拼接更长**(实测真后端在末尾补了
- * 追问建议,1309 → 1610 字)。应用侧 `stream.body` 若仍停在增量拼接的长度上,它就不再是
- * 上屏内容的后缀 → 到 `answerStreamCompose` 重排时后缀匹配失败 → 卡片被插到正文**后面**、
- * 正文上屏**两份**(用户 2026-09-20 从截图报告,用 `verify-answer-stream-payload.mjs` 复现)。
- *
- * 三种收尾(尾缀追加 / 整块重建 / 对不上兜底)的结果都**以 `finalText` 结尾**,
- * 所以这里取 `finalText`;只有它为空时才保持原值(宁可保留旧值,也不能清空而丢正文)。
+ * `answer_end` 之后把应用侧记录的「流式正文」同步成**上屏正文**:最终文本可能比增量拼接更长,
+ * 不同步会让重排时后缀匹配失败(卡片插到正文后面、正文上屏两份)。
+ * 取 `finalText`;它为空时保持原值(宁可保留旧值,也不能清空而丢正文)。
  */
 export const resolveStreamedBodyAfterEnd = (streamedBody: string, finalText: string): string => {
   const next = String(finalText ?? '');
@@ -298,35 +244,29 @@ export interface StreamComposeResult {
 
 /**
  * 流式轮重排:把「正文之上(卡片)」与「正文之下(参考资料等)」摆到正文两侧,
- * 得到用户要求的版式 **卡片 → 正文 → 参考资料**。
+ * 得到版式 卡片 → 正文 → 参考资料。
  *
- * 正常路径是「上屏内容以这段正文结尾」→ 切掉正文、把三块重新拼。
- *
- * 兜底路径(正文不在末尾)**绝不把正文再插一份**:它多半已经在上屏内容里了
- * (`answer_end` 追加尾巴后就长这样),再插就是用户看到的那种「同一段内容出现两次」。
- * 所以以它的**最后一次出现**为锚点切开:锚点之前当基线、锚点之后(尾部追加的内容)
- * 夹在正文与参考资料之间。正文确实找不到(极端路径)才补插,宁可多一段也不丢字。
+ * 上屏内容以这段正文结尾时切掉正文重拼;否则**绝不把正文再插一份**,而是以它的
+ * 最后一次出现为锚点切开(正文确实找不到才补插,宁可多一段也不丢字)。
  */
 export const composeAroundStreamBody = (
   content: string,
   streamedBody: string,
   leading: string,
   trailing: string,
-  /** 需要先从内容里抹掉的、**之前已上屏**的「正文之上」内容(卡片集合变了时用;见下) */
+  /** 需要先从内容里抹掉的、之前已上屏的「正文之上」内容(卡片集合变了时用) */
   replacedLeading = '',
-  /** 同上,但抹的是「正文之下」:尾部已提前上屏、现在需要重排时用(见 `flushTailEarly`) */
+  /** 同上,但抹的是「正文之下」:尾部已提前上屏、现在需要重排时用(见 flushTailEarly) */
   replacedTrailing = ''
 ): StreamComposeResult => {
   let before = String(content ?? '');
   const body = String(streamedBody ?? '');
   const stale = String(replacedLeading ?? '');
-  // 卡片集合变了(开流后才又来了 item):旧卡片块已被重排的 `leading` 取代,先抹掉,
-  // 否则新旧两份卡片会同时留在内容里。集合没变时调用方不会传,这里是纯兜底。
+  // 卡片集合变了(开流后才又来了 item):旧卡片块已被 `leading` 取代,先抹掉,否则两份并存
   if (stale && stale !== leading && before.includes(stale)) {
     before = before.replace(stale, '').replace(/\n{3,}/g, '\n\n');
   }
-  // 尾部提前上屏过,但现在要按新顺序重排(典型:二维码只能等 done,而它得排在参考资料**之前**)——
-  // 先把旧的尾部抹掉,新的 `trailing` 会把它按正确顺序重新拼进来
+  // 尾部提前上屏过、现在要按新顺序重排(典型:二维码要排在参考资料之前)→ 先抹掉旧尾部
   const staleTail = String(replacedTrailing ?? '');
   if (staleTail && before.includes(staleTail)) {
     before = before.replace(staleTail, '').replace(/\n{3,}/g, '\n\n');
@@ -344,7 +284,7 @@ export const composeAroundStreamBody = (
 
   const at = before.lastIndexOf(body);
   if (at < 0) {
-    // 正文不在上屏内容里:补插(不丢字优先),但这是不该出现的路径,调用方会告警
+    // 正文不在上屏内容里:补插(不丢字优先)—— 不该出现的路径,调用方会告警
     return { next: composeStreamedContent(before, leading, body, trailing), matched: false };
   }
   const base = before.slice(0, at);
@@ -361,10 +301,8 @@ export const composeAroundStreamBody = (
 };
 
 /**
- * `answer_abort` / 断流:撤掉未定稿的草稿。
- *
- * 同 `applyAnswerStreamEnd`:用**后缀匹配**从当前内容里去掉这段流式正文,
- * 而不是按「开流时的基线长度」切(基线可能已被别的路径改过,切了会错位)。
+ * `answer_abort` / 断流:撤掉未定稿的草稿 —— 用**后缀匹配**从当前内容里去掉这段流式正文,
+ * 不按「开流时的基线长度」切(基线可能已被别的路径改过,切了会错位)。
  */
 export const applyAnswerStreamAbort = (content: string, streamedBody: string): string => {
   const text = String(content || '');
@@ -373,9 +311,7 @@ export const applyAnswerStreamAbort = (content: string, streamedBody: string): s
   return text.slice(0, text.length - body.length);
 };
 
-/* ------------------------------------------------------------------ *
- * 事件解析
- * ------------------------------------------------------------------ */
+/* 事件解析 */
 
 export type AnswerStreamTarget = 'summary' | 'answer';
 export type AnswerAbortReason =
@@ -432,10 +368,8 @@ const ABORT_REASONS: AnswerAbortReason[] = [
 ];
 
 /**
- * 解析流式事件。
- *
- * 形状不符(`stream_id` 缺失/空、`target` 非法)→ 返回 null,协调器**不处理**该事件
- * (正文会退回到原有的 answer/summary 完整事件路径,不会丢内容)。
+ * 解析流式事件。形状不符(`stream_id` 缺失/空、`target` 非法)→ 返回 null,协调器不处理
+ * 该事件(正文退回 answer/summary 完整事件路径,不丢内容)。
  * 其余字段宽松:`sequence` 缺失按 0,`text` 非字符串按空串,`status`/`reason` 非法取兜底。
  */
 export function parseAnswerStreamEvent(
@@ -473,9 +407,7 @@ export function parseAnswerStreamEvent(
   }
 }
 
-/* ------------------------------------------------------------------ *
- * 状态机
- * ------------------------------------------------------------------ */
+/* 状态机 */
 
 export interface AnswerStreamState {
   /** 当前活动流的 id;null = 本轮没有活动流(走旧路径) */
@@ -508,13 +440,12 @@ export type AnswerStreamEffect =
   | { kind: 'discard'; streamId: string; reason: AnswerAbortReason };
 
 /**
- * 把事件折算成「新状态 + 要执行的效果」。规则(对应契约第 5 节):
- *
- * - **start**:任何 start 都重置为新流(含换 id 的新流)——契约「新建或清空对应的正文块」
- * - **delta**:过期流 / 已完成流 / 已消费序号(`sequence <= lastSequence`)一律忽略;
-   **序号缺口**(`sequence > lastSequence + 1`)置 `hasGap` 但仍按序拼接(等最终替换兜底)
- * - **end**:同 id 才处理;无论 `completed` 还是 `fallback` 都**整块替换**
- * - **abort**:同 id 才处理;复位状态(丢弃临时正文)
+ * 把事件折算成「新状态 + 要执行的效果」:
+ * - start:重置为新流
+ * - delta:过期流 / 已完成流 / 已消费序号(`sequence <= lastSequence`)一律忽略;
+ *   序号缺口(`sequence > lastSequence + 1`)置 `hasGap` 但仍按序拼接(等最终替换兜底)
+ * - end:同 id 才处理;无论 `completed` 还是 `fallback` 都**整块替换**
+ * - abort:同 id 才处理;复位状态(丢弃临时正文)
  */
 export function applyAnswerStreamEvent(
   state: AnswerStreamState,

+ 133 - 262
src/components/api-chat-coordinator.ts

@@ -1,36 +1,18 @@
 /**
  * 新版聊天协议客户端(POST /api/chat,SSE)。
  *
- * 这一层是「协议适配层」:对上层(useBusinessAssistantChat 与整棵渲染树)
- * 保持和旧协调器 StreamMessageCoordinator 完全一致的接口与事件格式,
- * 把新协议的 SSE 事件翻译成原界面本来就能渲染的内容标记,
- * 因此 BusinessRecord / PolicyMatch / QuestionCard / ScopeContent 等
- * 渲染组件一行都不用改。
+ * 协议适配层:把新协议的 SSE 事件翻译成原界面本来就能渲染的内容标记
+ * (item → POLICY_TABLE 块、source → ref_links、interrupt → question-cards、
+ * result.cipa → image-scope 二维码、progress/heartbeat → scope 卡片、answer_* → answerStream* 事件),
+ * 对上层保持与原协调器一致的接口,渲染组件一行都不用改。
  *
- * 翻译规则:
- * - progress / heartbeat → <scope title="阶段文字"></scope>(原“思考中”卡片,标题动态替换)
- * - item                 → <!-- POLICY_TABLE {json} POLICY_TABLE -->(原政策卡片 + 原详情面板)
- * - source               → <ref_links>[…]</ref_links>(原“参考资料”)
- * - interrupt            → <question-cards>{…}</question-cards>(原选项卡)
- * - result.cipa(布尔)  → <image-scope>…</image-scope>(企业微信二维码,
- *                          位置在**参考资料之前**;仅当后端判定问的是 CIPA /
- *                          海外服务平台 / 出海服务时为 true)
- * - answer / summary     → 纯文本
- * - answer_start / delta / end / abort → answerStreamStart / **message(增量)** /
- *   answerStreamEnd / answerStreamAbort(F073 正文真实增量流式;正文走 message 通道逐片段上屏,
- *   结束后由应用侧按最终全文整块替换。见 ./answer-stream.ts)
- *
- * 正文与卡片在 done 时按原顺序一次性输出:新接口本身不逐字流式返回,
- * 这样处理期间中间位置只保留一张不断更新标题的 scope 卡片。
- *
- * 协议要点见 接口文档/api-chat.md:
+ * 硬约束(详见 接口文档/api-chat.md):
  * - 请求体只接受 {thread_id, question},多余字段返回 422
  * - answer.text 与 result.response 相同,只能展示一次
  * - 未收到 done/error 就断开 → 按“连接中断”处理,不自动重试
  */
 import mitt, { Emitter } from 'mitt';
-// 企业微信二维码:后端 cipa=true(问的是 CIPA / 海外服务平台 / 出海服务)时展示。
-// 走 Vite 资源导入 → 构建时变成带 hash 的产物地址,不要写死路径。
+// 企业微信二维码(Vite 资源导入 → 构建时变成带 hash 的产物地址,不要写死路径)
 import cipaQrcodeUrl from '@/assets/qiyeweixin.png';
 import {
   applyAnswerStreamEvent,
@@ -53,9 +35,7 @@ import type { ChatAttachmentUrls } from './business-assistant/shared';
 const cleanUrlList = (urls?: readonly string[]): string[] =>
   (urls || []).map((url) => String(url ?? '').trim()).filter(Boolean);
 
-/* ------------------------------------------------------------------ *
- * 事件负载类型(全部字段可选,保证与后端降级/缺字段时兼容)
- * ------------------------------------------------------------------ */
+/* 事件负载类型(全部字段可选,兼容后端缺字段/降级) */
 
 export interface ChatCitation {
   path?: string;
@@ -112,7 +92,6 @@ export interface ChatCardPayload {
 export interface ChatItemPayload {
   source_id?: string;
   title?: string;
-  /** 知识类型(接口新字段) */
   knowledge_type?: string;
   /** 是否来自惠企政策库:true 时卡片右上角打“惠企政策”标识 */
   is_policy_library?: boolean;
@@ -161,11 +140,7 @@ export interface ChatInterruptCandidate {
 
 export interface ChatInterruptPayload {
   kind?: 'company_need' | 'company_selection' | string;
-  /**
-   * 补问状态。后端保证与 kind 一一对应:
-   * - `found` + `company_selection` → 有候选可选
-   * - `not_found` + `company_need`  → 没有候选,需要用户补充公司信息
-   */
+  /** 补问状态,后端保证与 kind 一一对应:found = 有候选,not_found = 需用户补充公司信息 */
   status?: 'found' | 'not_found' | string;
   question?: string;
   input_help?: string;
@@ -184,16 +159,9 @@ export interface ChatResultPayload {
   response?: string;
   recommendation?: unknown;
   errors?: unknown[];
-  /**
-   * 企业资料(company / profile / honors 的原始结构)。
-   * 协调器**不解析**它,只在 done 时原样交给应用侧——拿到后由
-   * `syncCompanyInfoFromChat` 去调分类接口、落 DMS(见 company-info-sync.ts)。
-   */
+  /** 企业资料(协调器不解析,只在 done 时原样交给应用侧 → syncCompanyInfoFromChat) */
   company_info?: unknown;
-  /**
-   * 是否需要展示企业微信二维码(2026-09-20 后端新增):
-   * 问的是 CIPA / 海外服务平台 / 出海服务时为 `true`,其他情况 `false`。
-   */
+  /** 是否展示企业微信二维码:问的是 CIPA / 海外服务平台 / 出海服务时为 true */
   cipa?: unknown;
 }
 
@@ -214,9 +182,7 @@ export interface ChatProgressPayload {
   elapsed_seconds?: number;
 }
 
-/* ------------------------------------------------------------------ *
- * 错误码 → 中文文案
- * ------------------------------------------------------------------ */
+/* 错误码 → 中文文案 */
 
 export const CHAT_ERROR_TEXT: Record<string, string> = {
   application_json_required: '请求格式不正确,请稍后重试',
@@ -243,16 +209,13 @@ export function mapChatErrorText(code?: string, message?: string): string {
   return CHAT_ERROR_TEXT[code || ''] || '服务异常,请稍后再试';
 }
 
-/** 二维码上方的一句引导语(文案由用户 2026-09-20 指定) */
+/** 二维码上方的一句引导语 */
 export const CIPA_QRCODE_TIP =
   '如果您还有其他疑问,欢迎随时向我咨询,也可以联系CIPA海外服务平台获取支持。';
 
 /**
- * 企业微信二维码的内容标记(**上文下码**)。
- *
- * 渲染层本来就认 `<image-scope>` 块(→ `ImageContent` 行类型,支持点击放大),
- * 所以这里不需要新增组件,只要按它的约定把图片地址放进去。
- * 引导语是普通文本,与图片标记之间空一行 —— 渲染层按空行切成「文本行 + 图片行」。
+ * 企业微信二维码的内容标记(上文下码):渲染层认 `<image-scope>` 块,
+ * 引导语与图片标记之间**空一行**(渲染层按空行切成「文本行 + 图片行」)。
  */
 export function buildCipaQrcodeContent(): string {
   return `${CIPA_QRCODE_TIP}\n\n<image-scope>\n${cipaQrcodeUrl}\n</image-scope>`;
@@ -261,10 +224,8 @@ export function buildCipaQrcodeContent(): string {
 export const CHAT_ENDPOINT_PATH = '/api/chat';
 
 /**
- * 拼出聊天接口的完整地址。
- * VITE_CHAT_API 允许两种写法,避免重复拼接:
- * - 服务前缀: "http://192.168.2.23:8000" 或 "/chat-api"  → 追加 /api/chat
- * - 完整接口: "http://192.168.2.23:8000/api/chat"        → 原样使用
+ * 拼出聊天接口的完整地址。VITE_CHAT_API 允许两种写法:
+ * 服务前缀(追加 /api/chat)或已带 /api/chat 的完整地址(原样使用)。
  */
 export function resolveChatEndpoint(baseUrl?: string): string {
   const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '');
@@ -277,9 +238,7 @@ export function resolveChatEndpoint(baseUrl?: string): string {
   return `${trimmed}${CHAT_ENDPOINT_PATH}`;
 }
 
-/* ------------------------------------------------------------------ *
- * 新协议 → 原界面内容标记 的翻译
- * ------------------------------------------------------------------ */
+/* 新协议 → 原界面内容标记 的翻译 */
 
 export function resolveFieldText(field: ChatFieldLike): string {
   if (!field) {
@@ -299,9 +258,8 @@ export function resolveFieldCitations(field: ChatFieldLike): ChatCitation[] {
 }
 
 /**
- * scope 标题:原组件的标题正则 /title\s*=\s*(['"]?)([^'"\s>]+)\1/i
- * 不允许空白与引号/尖括号,因此这里全部剔除。
- * 阶段提示都是中文短句,去掉空白不影响可读性。
+ * scope 标题:原组件的标题正则不允许空白与引号/尖括号(`/title\s*=\s*(['"]?)([^'"\s>]+)\1/i`),
+ * 所以这里全部剔除。
  */
 export function toScopeTitle(message?: string): string {
   const cleaned = String(message || '').replace(/[\s<>"'&]/g, '');
@@ -314,7 +272,7 @@ const KIND_TEXT: Record<string, string> = {
   service: '服务事项',
 };
 
-/** 来源知识类型 → 中文(见接口文档 §9) */
+/** 来源知识类型 → 中文 */
 const SOURCE_TYPE_TEXT: Record<string, string> = {
   policy: '政策',
   finance: '金融产品',
@@ -324,9 +282,7 @@ const SOURCE_TYPE_TEXT: Record<string, string> = {
   service_catalog: '服务目录',
 };
 
-// 规整 markdown 换行(含「列表后紧跟的说明行被并进上一条列表项」的修复)与
-// F073 的增量规整器,都搬到了 ./answer-stream(纯函数模块,便于单独断言)。
-// 这里保持对外 API 不变:
+// markdown 规整与增量规整器都在 ./answer-stream,这里 re-export 保持对外 API 不变
 export { normalizeChatMarkdown } from './answer-stream';
 
 const POLICY_TABLE_START = '<!-- POLICY_TABLE';
@@ -349,39 +305,30 @@ const toPolicyTableItem = (item: ChatItemPayload, sourceUrl: string) => {
   const nameText = resolveFieldText(card?.name);
   const title = nameText || item.title || '';
 
-  // 详情面板的「申报事项」:用后端每条唯一的 `data.title`。
-  // 同一政策下有多个申报事项时,后端会下发「政策名 - 申报事项」的形式
-  // (如「…的通知 - 创业开办费补贴」),而 `card.name.text` 只有政策名。
-  // 之前这里跟 title 取同一个值,导致同政策的多个申报事项在详情面板上
-  // **标题完全一样、也认不出自己看的是哪一条**(2026-09-17 修)。
+  // 详情面板的「申报事项」要用后端每条唯一的 `item.title`(同政策多个申报事项时是
+  // 「政策名 - 申报事项」),`card.name.text` 只有政策名
   const declarationItem = item.title || title;
 
-  // 卡片区按 match_score 倒序取前 6 条。新接口给出的是 priority.rank
-  // (1 为第一位),这里换算成递减分值,使卡片顺序与列表顺序、
-  // 与接口给定的展示优先级完全一致。
+  // 卡片区按 match_score 倒序取前 6 条;新接口给的是 priority.rank(1 为第一位),
+  // 这里换算成递减分值,使卡片顺序与接口给的展示优先级一致
   const rank = Number(item.priority?.rank);
   const matchScore = Number.isFinite(rank) && rank > 0 ? 1000 - rank : 0;
 
   return {
     title,
     match_score: matchScore,
-    // 来源标识:来自惠企政策库的条目,卡片右上角会打标
     is_policy_library: item.is_policy_library === true,
-    // 详情面板大标题:具体到申报事项(见上 declarationItem 的说明)
+    // 以下字段沿用原 PolicyMatch 的字段名,由详情面板直接消费
     declaration_item: declarationItem,
-    // 卡片描述
     desc: item.match_reason || card?.match_reason?.text || '',
-    // 详情面板“政策所属部门”
     source: resolveFieldText(card?.department),
-    // 卡片徽标
     enterprise_benefit: item.recommendation_status || KIND_TEXT[card?.kind || ''] || '',
     support_objects: resolveFieldText(card?.support_objects),
     support_measures: resolveFieldText(card?.support_methods_standards),
     apply_conditions: resolveFieldText(card?.application_conditions),
-    // 详情面板“匹配原因”
     match_reason: card?.match_reason?.text || item.match_reason || '',
     apply_link: sourceUrl,
-    // 新协议独有字段,供详情面板新增区块使用(normalizePolicy 会保留在 originalData 里)
+    // 新协议独有字段,经 normalizePolicy 保留在 originalData 里供详情面板新增区块使用
     conditions: Array.isArray(item.conditions) ? item.conditions : [],
     match_basis: Array.isArray(item.match_basis) ? item.match_basis : [],
     citations: Array.isArray(item.citations) && item.citations.length
@@ -432,11 +379,8 @@ const getSourceIntro = (source?: ChatSourcePayload): string => {
 };
 
 /**
- * 生成“参考资料”标记。
- * 字段映射依据接口文档 §9「原始 source 对象中的统一字段」:
- * title 标题 / type 知识类型 / publisher 发布机构 / published_text 发布日期 /
- * updated_text 更新时间 / url 原始网页地址 / full_record 介绍性文字。
- * 原有面板只消费 title、type、content、linkUrl,其余字段经 originalData 透传。
+ * 生成“参考资料”标记(字段映射见接口文档 §9)。
+ * 原面板只消费 title、type、content、linkUrl,其余字段经 originalData 透传。
  */
 export const buildRefLinksContent = (sources: ChatSourcePayload[]): string => {
   const links = sources
@@ -463,7 +407,7 @@ export const buildRefLinksContent = (sources: ChatSourcePayload[]): string => {
         analysis_complete: source?.analysis_complete === true,
       };
     })
-    // 没有标题也没有链接的来源无法展示,直接跳过(不自行拼接 URL)
+    // 没有标题也没有内容、或没有链接的来源无法展示,直接跳过(不自行拼接 URL)
     .filter((link) => (link.title || link.content) && link.link);
 
   if (!links.length) {
@@ -476,21 +420,17 @@ export const buildRefLinksContent = (sources: ChatSourcePayload[]): string => {
 const CANDIDATE_ACTION_LABELS = ['查看更多', '重新查询', '取消公司查询'] as const;
 
 /**
- * 固定动作选项 label → 协议要求的 question 字符串。
- *
- * 只保留后端明确要求的控制串(input_help 里写着"支持 /next、/retry、/cancel")。
- * 其余选项一律提交选项的文字内容 —— 例如"跳过公司查询",后端 input_help 明确写着
- * "输入"跳过公司查询"或 /skip 继续",两种都收,那就发文本。
+ * 固定动作选项 label → 协议要求的 question 字符串:只映射后端明确的控制串,
+ * 其余选项一律提交选项本身的文字内容。
  */
 const OPTION_LABEL_TO_ANSWER: Record<string, string> = {
-  // 该变体的后端 input_help 明确写着"不需要则输入'不需要'",故按它给的字符串发
   不需要公司信息: '不需要',
   查看更多: '/next',
   重新查询: '/retry',
   取消公司查询: '/cancel',
 };
 
-/** 把一条公司候选拼成提交文本:选项上能看到的全部内容 */
+/** 把一条公司候选拼成提交文本:选项上能看到的全部内容(公司名 + 信用代码 + 状态 + 负责人) */
 const buildCandidateText = (candidate: ChatInterruptCandidate): string =>
   [candidate.Name, candidate.CreditCode, candidate.Status, candidate.OperName]
     .map((part) => String(part || '').trim())
@@ -500,17 +440,11 @@ const buildCandidateText = (candidate: ChatInterruptCandidate): string =>
 /**
  * 把原 <question-cards> 的提交结果翻译成新协议要求的 question 字符串。
  *
- * 公司候选提交的是**选项的全部内容拼接成的文本**(公司名 + 信用代码 + 状态 + 负责人)。
- * QuestionCard 只回传 label、不回传 description,所以要用 interrupt 里的候选
- * 按 label 反查后重建完整文本。
- *
- * 其余情况:固定动作选项映射成协议控制串;自由输入原样发送(那本就是"换关键词"路径)。
- *
- * ⚠️ 重要:接口文档(docs/reference/api-chat.md)写的是"API 由序号映射到原候选",
- * 即选择候选应提交 `"1"`。当前实现按产品要求改为提交文本,**属刻意偏离**。
- * 若后端仍按"序号选中 / 文本则重新检索"的语义处理,提交文本会触发 refine,
- * 表现为"选一次又弹回同一批候选"的死循环。
- * 出现死循环时应核对后端实际语义,或改回提交序号。
+ * ⚠️ 公司候选提交的是**选项的全部内容拼成的文本**(公司名 + 信用代码 + 状态 + 负责人)——
+ * QuestionCard 只回传 label,所以要用 interrupt 里的候选按 label 反查后重建。
+ * 这是对接口文档「按序号映射」的**刻意偏离**:若后端仍按「序号选中 / 文本则重新检索」
+ * 处理,会表现为"选一次又弹回同一批候选"的死循环。
+ * 其余情况:固定动作选项映射成协议控制串;自由输入原样发送。
  */
 export const toChatQuestionAnswer = (
   answers: unknown,
@@ -524,7 +458,7 @@ export const toChatQuestionAnswer = (
 
   return answers
     .map((answer: any) => {
-      // 自由输入优先:用户直接敲的内容最明确
+      // 自由输入优先
       const freeText = String(answer?.freeText || '').trim();
       if (freeText) {
         return freeText;
@@ -537,7 +471,7 @@ export const toChatQuestionAnswer = (
 
       return labels
         .map((label: string) => {
-          // 固定动作选项优先映射成协议控制串
+          // 固定动作选项映射成协议控制串
           if (OPTION_LABEL_TO_ANSWER[label]) {
             return OPTION_LABEL_TO_ANSWER[label];
           }
@@ -553,7 +487,7 @@ export const toChatQuestionAnswer = (
     .join(';');
 };
 
-/** 公司查询错误码 → 可读说明(见接口文档的错误码表) */
+/** 公司查询错误码 → 可读说明 */
 const COMPANY_ERROR_TEXT: Record<string, string> = {
   network_failure: '网络连接失败或超时',
   provider_failure: '公司数据服务返回失败',
@@ -573,6 +507,7 @@ const COMPANY_ERROR_TEXT: Record<string, string> = {
 /**
  * 把公司查询错误码翻成一句人话。
  * 错误码可能带 search: / basic: / honors: 前缀,表示出错的阶段。
+ * 返回文本末尾保留原错误码,便于排查。
  */
 export const describeCompanyError = (code?: string): string => {
   const raw = String(code || '').trim();
@@ -594,27 +529,17 @@ export const describeCompanyError = (code?: string): string => {
     COMPANY_ERROR_TEXT[key] ||
     (httpMatch ? `上游服务返回 ${httpMatch[1]}` : '查询失败');
 
-  // 保留错误码:便于排查,也给用户一个可复述的凭据
   return `${stage}${reason}(${raw})`;
 };
 
 /**
  * 把补问翻译成原选项卡。
  *
- * 判定依据是 `status`(后端保证与 `kind` 一一对应):
- * - `company_selection` + `found`     → **有候选**:列出候选,可查看更多 / 取消公司查询
- * - `company_need`      + `not_found` → **没有候选**:上方常驻输入框补充公司信息,下方跳过公司查询
- *
- * 没有候选时,还要区分"查无结果"与"查询失败"(接口文档要求二者分别展示,
- * 且"有此错误时,不把空候选说成查无公司"):
- * - `result.error` 有值 → 说明失败原因(`describeCompanyError`),不说成"没搜到"
- * - 无错误           → 说"未查询到匹配的公司"
+ * ⚠️ 形态判定只认 `status`(后端保证与 `kind` 一一对应:found = 有候选,not_found / failed
+ * = 无候选),**不要拿 `input_help` 的中文文案做正则判断**,后端改一个字判定就会失效。
  *
- * ⚠️ 曾因未区分而引发死循环:没有候选时卡片仍写"请选择您所指的公司"且无可选项,
- * 用户只能手打公司名 → 走 refine 路径 → 后端再查 → 又弹同一张卡。
- *
- * ⚠️ 不要再用 `input_help` 的文案做正则判断(曾用 /跳过|skip/ 猜按钮文案)——
- * 后端改一个字的措辞判定就会失效,`status` 才是为这件事准备的字段。
+ * 没有候选时还要区分"查询失败"与"查无结果"(接口文档要求):`result.error` 有值 → 说明失败
+ * 原因(`describeCompanyError`),无错误才说"未查询到匹配的公司"。
  */
 export const buildQuestionCardsContent = (
   interrupt: ChatInterruptPayload,
@@ -628,19 +553,15 @@ export const buildQuestionCardsContent = (
   const options: { label: string; description?: string }[] = [];
   let question = interrupt?.question || '';
 
-  // 后端常把同一段话既放进 answer.text(渲染在消息正文)、又放进 interrupt.question。
-  // 若正文已经展示过,卡片里就不再重复一遍。
+  // 后端可能把同一段话既放进 answer.text、又放进 interrupt.question;正文展示过就不再重复
   const normalize = (text: string) => String(text || '').replace(/\s+/g, '');
   const questionAlreadyShown =
     !!question && !!shownText && normalize(shownText).includes(normalize(question));
   if (questionAlreadyShown) {
     question = '';
   }
-  // 权威判定依据是 status,后端列举的全部可能出现的情况:
-  //   company_selection + found      → 有候选待选择
-  //   company_need      + not_found  → 未找到候选,需要补充信息
-  //   company_need      + failed     → 查询服务失败
-  //   company_need      + 无 status  → 初次询问是否需要公司信息
+  // 后端可能出现的组合:selection+found(有候选)、need+not_found(未找到)、
+  // need+failed(查询失败)、need+无 status(初次询问是否需要公司信息)
   // status 缺失时退回"有没有候选",兼容旧版本。
   const status = String(interrupt?.status || '');
   const hasCandidates = candidates.length > 0;
@@ -650,17 +571,16 @@ export const buildQuestionCardsContent = (
   let message = inputHelp || (showCandidateList ? '也可直接输入新关键词' : '输入工商注册全名或统一社会信用代码');
   const freeformInputOnTop = !showCandidateList;
 
-  // 已去重时保持为空,别用兜底文案把它填回来
+  // 已去重时保持为空,兜底文案不要把它填回来
   const fallbackQuestion = (text: string) => (questionAlreadyShown ? '' : question || text);
 
   if (showCandidateList) {
-    // ── 有候选:列出候选供选择 ──
     candidates.forEach((candidate) => {
       const detail = [candidate.CreditCode, candidate.Status, candidate.OperName]
         .filter(Boolean)
         .join(' · ');
       options.push({
-        // 选项显示公司名称,不加 “N. ” 前缀
+        // 选项只显示公司名称,不加 “N. ” 前缀
         label: candidate.Name || '名称未提供',
         description: detail || undefined,
       });
@@ -671,7 +591,7 @@ export const buildQuestionCardsContent = (
     }
     options.push({ label: CANDIDATE_ACTION_LABELS[2] });
   } else if (status === 'failed') {
-    // ── 查询服务失败:必须说成"失败",不能说成"未查询到"(接口文档明确要求)──
+    // 查询失败必须说成"失败",不能说成"未查询到"(接口文档明确要求)
     question = fallbackQuestion(
       queryError
         ? `${describeCompanyError(queryError)},暂时无法查询公司信息。`
@@ -680,12 +600,11 @@ export const buildQuestionCardsContent = (
     message = '可在上方补充公司信息稍后重试,或跳过公司查询。';
     options.push({ label: '跳过公司查询' });
   } else if (status === 'not_found') {
-    // ── 未找到候选:提示补充信息 ──
     question = fallbackQuestion('未查询到匹配的公司。');
     message = '可在上方补充公司信息(工商注册全名或统一社会信用代码),或跳过公司查询。';
     options.push({ label: '跳过公司查询' });
   } else {
-    // ── 无 status:初次询问是否需要公司信息 ──
+    // 无 status:初次询问是否需要公司信息
     question = fallbackQuestion('是否需要结合具体公司情况?');
     message = inputHelp || '可输入公司名称或统一社会信用代码';
     options.push({ label: '不需要公司信息' });
@@ -708,19 +627,13 @@ export const buildQuestionCardsContent = (
   return `<question-cards>${JSON.stringify(payload)}</question-cards>`;
 };
 
-/* ------------------------------------------------------------------ *
- * SSE 解析器
- * ------------------------------------------------------------------ */
+/* SSE 解析器 */
 
 type SseEventHandler = (event: string, data: string) => void;
 
 /**
- * 逐块解析 SSE 文本流。处理:
- * - UTF-8 字符跨块(由调用方用 TextDecoder({stream:true}) 解码后传入)
- * - LF / CRLF 分隔
- * - data 多行(按 \n 拼接)
- * - 以 ":" 开头的注释/心跳行
- * - 最后一块未以空行结尾时,end() 仍需 flush
+ * 逐块解析 SSE 文本流:UTF-8 字符由调用方解码后传入,支持 LF / CRLF、
+ * data 多行(按 \n 拼接)、":" 开头的注释/心跳行;最后一块未以空行结尾时 end() 仍需 flush。
  */
 export class SseStreamParser {
   private buffer = '';
@@ -790,9 +703,7 @@ export class SseStreamParser {
   }
 }
 
-/* ------------------------------------------------------------------ *
- * 协调器
- * ------------------------------------------------------------------ */
+/* 协调器 */
 
 export interface ApiChatTotalResponsePayload {
   question: string;
@@ -802,7 +713,7 @@ export interface ApiChatTotalResponsePayload {
 }
 
 export type ApiChatEventMap = {
-  /** F073:正文真实增量流开始(后端 answer_start)——应用侧据此记录流式基线 */
+  /** 正文真实增量流开始(后端 answer_start)—— 应用侧据此记录流式基线 */
   answerStreamStart: { streamId: string; target: AnswerStreamTarget };
   /** 正文定稿(后端 answer_end):`text` 是**已规整**的最终全文,应用侧整块替换 */
   answerStreamEnd: {
@@ -814,8 +725,8 @@ export type ApiChatEventMap = {
   /** 撤去本轮临时正文(后端 answer_abort / 断流 / 出错) */
   answerStreamAbort: { streamId: string; reason: AnswerAbortReason };
   /**
-   * 流式轮的**最终重排**:正文已上屏,但「卡片」要排在正文**之上**、
-   * 「参考资料/二维码」在正文之下 —— 应用层按 基线 + leading + 正文 + trailing 重写一次内容。
+   * 流式轮的最终重排:正文已上屏,但卡片要排在正文**之上**、参考资料/二维码在正文之下 ——
+   * 应用层按 基线 + leading + 正文 + trailing 重写一次内容。
    */
   answerStreamCompose: {
     streamId: string;
@@ -823,7 +734,7 @@ export type ApiChatEventMap = {
     trailing: string;
     /** 已在屏幕上、但已被 `leading` 取代的旧内容(卡片集合变了时非空) */
     replacedLeading?: string;
-    /** 已在屏幕上、但已被 `trailing` 取代的旧尾部(尾部提前上屏过、现在要重排时非空) */
+    /** 已在屏幕上、但已被 `trailing` 取代的旧尾部(尾部提前上屏过且现在要重排时非空) */
     replacedTrailing?: string;
   };
   /** 已翻译成原界面内容标记的文本 */
@@ -846,7 +757,7 @@ export class ApiChatCoordinator {
   threadId: string;
   question = '';
   totalResponse = '';
-  /** 新协议没有 record_id,保留属性仅为兼容历史逻辑 */
+  /** 新协议没有 record_id,保留属性仅为兼容旧逻辑 */
   record_id = '';
   knowledge_ids: string[] = [];
   generating = false;
@@ -866,15 +777,15 @@ export class ApiChatCoordinator {
   private pendingCompanyInfo: unknown = null;
   /** 本轮是否需要展示企业微信二维码(result.cipa) */
   private pendingCipa = false;
-  /** F073 正文流状态(start/delta/end/abort 折算;见 answer-stream.ts) */
+  /** 正文流状态(start/delta/end/abort 折算;见 answer-stream.ts) */
   private answerStream: AnswerStreamState = createAnswerStreamState();
-  /** F073 增量规整器:让 delta 通道发出的内容恒等于最终文本的逐步前缀 */
+  /** 增量规整器:让 delta 通道发出的内容恒等于最终文本的逐步前缀 */
   private answerStreamNormalizer: IncrementalMarkdownNormalizer =
     createIncrementalMarkdownNormalizer();
   /** 正文是否已输出(输出后不再发送 scope 进度卡片) */
   private contentFlushed = false;
   /**
-   * F079 卡片先于正文:**开流前就已上屏**的「正文之上」内容(政策卡片块);`''` = 没有。
+   * 卡片先于正文:**开流前就已上屏**的「正文之上」内容(政策卡片块);`''` = 没有。
    * 记下来是为了 ①`flushContent` 不重复输出 ②`totalResponse` 里卡片仍排在正文之前。
    */
   private preStreamLeading = '';
@@ -930,12 +841,10 @@ export class ApiChatCoordinator {
   /**
    * 组装 `/api/chat` 的请求体。
    *
-   * - 基础字段**只有** `thread_id` 与 `question`(历史包袱:早期多一个字段就 422)
-   * - 多模态时追加 `pictures` / `files`(**字符串 URL 数组**,2026-09-23 实测契约):
-   *   传对象数组会被 `422 invalid_request` 拒掉;`pictures` 里的图由后端自己去取,
-   *   所以必须是可公网访问的地址
-   * - **空列表不发**:没有附件时请求体与从前**逐字节一致** ——
-   *   既少传无意义字段,也让「后端若回滚到旧版本」只影响带附件的那几轮
+   * ⚠️ 基础字段**只有** `thread_id` 与 `question`,多一个字段就 422。
+   * 多模态时追加 `pictures` / `files`(**字符串 URL 数组**,传对象数组会被 422
+   * `invalid_request` 拒掉;`pictures` 里的图由后端自己去取,必须公网可达)。
+   * **空列表不发**:没有附件时请求体与不带附件时逐字节一致。
    */
   private buildRequestBody(text: string, attachments?: ChatAttachmentUrls): Record<string, unknown> {
     const body: Record<string, unknown> = { thread_id: this.threadId, question: text };
@@ -948,8 +857,8 @@ export class ApiChatCoordinator {
 
   async generateAnswer(text: string, attachments?: ChatAttachmentUrls): Promise<void> {
     if (this.generating) {
-      // 同窗口上一请求仍在执行(后端会返回 409 thread_busy)。
-      // 这里只提示,不能发 close/error,否则会误结束正在进行的这一轮。
+      // 上一请求仍在执行(后端返回 409 thread_busy):只提示,不能发 close/error,
+      // 否则会误结束正在进行的这一轮
       this._eventbus.emit('info', CHAT_ERROR_TEXT.thread_busy);
       return;
     }
@@ -1001,7 +910,7 @@ export class ApiChatCoordinator {
       }
       const retryAfter = response.headers.get('Retry-After');
       if (retryAfter) {
-        console.warn(`[api-chat] 服务端建议 ${retryAfter}s 后重试(code=${code})`);
+        /* console.warn(`[api-chat] 服务端建议 ${retryAfter}s 后重试(code=${code})`); */
       }
       this.failTurn(token, code);
       return;
@@ -1062,11 +971,10 @@ export class ApiChatCoordinator {
       return;
     }
 
-    // 没有收到 done/error 就结束 → 连接中断。
-    // 已到达的内容仍然展示,不因断流丢弃。
+    // 没有收到 done/error 就结束 → 连接中断;已到达的内容仍然展示,不因断流丢弃
     if (this.generating && !this.doneReceived) {
-      // F073:未定稿的流式草稿要撤掉(契约:没有正常终止的 EOF 不能显示为成功答复),
-      // 之后的 flushContent 走兼容路径——完整 answer/summary 事件到达过的部分照常输出
+      // 未定稿的流式草稿要撤掉(没有正常终止的 EOF 不能显示为成功答复);
+      // 之后的 flushContent 走兼容路径 —— 完整 answer/summary 事件到达过的部分照常输出
       this.discardActiveStream('not_finalized');
       await this.flushContent();
       this.failTurn(token, 'disconnected');
@@ -1087,8 +995,6 @@ export class ApiChatCoordinator {
     this._eventbus.emit('close');
   }
 
-  /* ---------------- 内部 ---------------- */
-
   private resetTurnContent() {
     this.answerText = '';
     this.pendingSources = [];
@@ -1136,12 +1042,11 @@ export class ApiChatCoordinator {
   }
 
   /**
-   * 把本轮累积的结果按原界面顺序输出:
-   * 正文 → 政策卡片 → 参考资料 → 综合说明 → 补问选项卡
+   * 把本轮累积的结果按原界面顺序输出:正文 → 政策卡片 → 参考资料 → 综合说明 → 补问选项卡。
    *
-   * ⚠️ F073 后正文可能已经在**流式过程中实时输出过**了(`answerStream.completed`):
-   * 那时这里**不再重发正文**(否则界面上会出现两份),但仍负责输出卡片/参考资料等,
-   * 并把「最终正文 + 其余部分」拼进 `totalResponse`(DMS 落库与最终校准都靠它)。
+   * ⚠️ 正文若已在流式过程中实时输出过(`answerStream.completed`),这里**不再重发正文**
+   * (否则界面上会出现两份),但仍负责输出卡片/参考资料等,并把最终正文 + 其余部分
+   * 拼进 `totalResponse`(DMS 落库与最终校准都靠它)。
    */
   private async flushContent(): Promise<void> {
     if (this.contentFlushed) {
@@ -1149,14 +1054,12 @@ export class ApiChatCoordinator {
     }
     this.contentFlushed = true;
 
-    // 流式轮:正文已实时上屏,这里只补卡片等;未完成/未启用流式:正文走原来的整段路径
+    // 流式轮:正文已实时上屏,这里只补卡片等;无流式:正文走整段路径
     const streaming = this.answerStream.completed;
     const streamBody = streaming ? this.answerStream.text || '' : null;
 
-    // 「正文之上」与「正文之下」分开收集:
-    // - 旧路径(无流式):正文与卡片都算「之上」,顺序 = 正文 → 卡片 → …(保持原样)
-    // - 流式轮:正文已上屏,**卡片改放正文之上**(用户 2026-09-20 指出:正文是整篇长文,
-    //   卡片排在其后要先滚过全文才看得到,与改造前「短开场 → 卡片 → 综合说明」的观感不一致)
+    // 「正文之上」与「正文之下」分开收集:流式轮把卡片放到正文之上
+    // (正文是整篇长文,卡片排在其后要先滚过全文才看得到),旧路径顺序不变
     const leading: string[] = [];
     const trailing: string[] = [];
 
@@ -1167,21 +1070,19 @@ export class ApiChatCoordinator {
     } else if (
       this.answerText &&
       shouldKeepAnswerOverview(this.answerText, streamBody) &&
-      // 后端偶尔把同一句话同时放进 `answer.text` 与 `summary.notices`(实测 2026-09-21)。
-      // 已经在别处展示的话就别再摆一遍:既是同一句出现两次,又会白搭一次整块重排。
+      // 后端偶尔把同一句话同时放进 `answer.text` 与 `summary.notices`:已在别处展示就不摆第二遍
       !isOverviewAlreadyShown(this.answerText, streamBody, this.pendingSummary?.notices ?? [])
     ) {
-      // 概况不是流式正文的前缀(= 是另一段内容,不是压缩版)→ 保留在正文之上,不能丢
-      console.warn('[answer-stream] answer 概述不属于流式正文,已保留展示');
+      // 概况不是流式正文的前缀(是另一段内容,不是压缩版)→ 保留在正文之上,不能丢
+      /* console.warn('[answer-stream] answer 概述不属于流式正文,已保留展示'); */
       leading.push(normalizeChatMarkdown(this.answerText));
     }
 
-    // F079:卡片已在开流前上屏(`emitCardsBeforeStream`)→ 这里不再重复输出。
-    // 没上屏的情形有三:非流式轮(旧后端/无 answer_start)、answer_start 时还没有 item、
-    // 或开流后才又来了新 item —— 那时仍走「插到正文之上」的整块重排(见下)
+    // 卡片已在开流前上屏(emitCardsBeforeStream)→ 这里不再重复输出;
+    // 没上屏时(非流式轮 / answer_start 时还没有 item / 开流后才到新 item)仍走插到正文之上的整块重排
     const policyTable = buildPolicyTableContent(this.pendingItems, this.pendingSources);
     // ⚠️ 判据**不能**加 `streaming`:撤稿/断流后 streaming 会变回 false,那时卡片其实已经上屏过,
-    // 再加进 trailing 就会在界面上出现第二份卡片(契约要求「草稿撤回时已校验卡片保留」)
+    // 再加进 trailing 就会在界面上出现第二份卡片
     const cardsAlreadyShown = !!policyTable && policyTable === this.preStreamLeading;
     if (policyTable && !cardsAlreadyShown) {
       (streaming ? leading : trailing).push(policyTable);
@@ -1194,7 +1095,7 @@ export class ApiChatCoordinator {
     // 尾部按块组装(顺序:notices → 补问卡 → 二维码 → 参考资料)
     const tailParts = this.buildTrailingParts(streamBody);
     const pendingTail = tailParts.filter((part) => !this.tailSent.has(part.key));
-    // 尾部整体(含已提前上屏的部分)—— `totalResponse` 与重排都用它,保证落库顺序与最终版式一致
+    // 尾部整体(含已提前上屏的部分):`totalResponse` 与重排都用它,保证落库顺序与版式一致
     const allTailText = tailParts.map((part) => part.text).join('\n\n');
     trailing.push(allTailText);
 
@@ -1202,12 +1103,12 @@ export class ApiChatCoordinator {
     const trailingText = trailing.filter((part) => String(part || '').trim().length > 0).join('\n\n');
 
     if (streaming) {
-      // totalResponse 按**上屏顺序**拼:开流前已上屏的卡片 + 仍需插到正文之上的内容 + 正文 + 尾部
+      // totalResponse 按上屏顺序拼:开流前已上屏的卡片 + 插到正文之上的内容 + 正文 + 尾部
       const aboveText = [this.preStreamLeading, leadingText].filter(Boolean).join('\n\n');
       this.totalResponse += composeStreamedContent('', aboveText, streamBody ?? '', trailingText);
 
-      // 已提前上屏的块里,若排在某个还没发的块**之后**,就必须重排(否则顺序颠倒)。
-      // 实际只发生在一种情况:参考资料已提前发,而二维码要等 result 才到 —— 它得排在参考资料之前。
+      // 已提前上屏的块若排在某个还没发的块之后,就必须重排(实际只发生在:
+      // 参考资料已提前发,而二维码要等 result 才到 —— 它得排在参考资料之前)
       const needsReorder = pendingTail.some((part) =>
         [...this.tailSent].some(
           (sent) => ApiChatCoordinator.TAIL_ORDER.indexOf(part.key) < ApiChatCoordinator.TAIL_ORDER.indexOf(sent)
@@ -1216,24 +1117,20 @@ export class ApiChatCoordinator {
 
       if (leadingText || needsReorder) {
         // 需要整块重排一次(内容不是纯追加,渲染层会重置重解析):
-        // - `leadingText`:卡片没能提前上屏 / 开流后才到的 item / 要保留的 answer 概述
-        // - `needsReorder`:尾部顺序必须调(见上)
+        // leadingText = 卡片没提前上屏 / 开流后才到的 item / 要保留的 answer 概述;needsReorder = 尾部顺序要调
         this._eventbus.emit('answerStreamCompose', {
           streamId: this.answerStream.streamId || '',
           leading: leadingText,
           trailing: allTailText,
-          // 卡片集合变了:旧卡片块已经在屏幕上,应用层重排时要先把它抹掉(否则新旧两份并存)
+          // 卡片集合变了:旧卡片块已经在屏幕上,重排前要先把它抹掉(否则新旧两份并存)
           replacedLeading: policyTable && !cardsAlreadyShown ? this.preStreamLeading : '',
-          // 只要**提前上屏过**尾部就一定要抹掉 —— 不能只挂在 `needsReorder` 上。
-          // 踩过的坑(2026-09-21 用户报「参考资料出现 2 次、概述那句出现 3 次」):
-          // `answer` 事件按契约排在 `answer_end` **之后**,所以 `flushTailEarly()` 在 summary
-          // 那一刻还不知道「概述要保留」,会先把尾部发出去;等 answer 到达,done 时发现概述
-          // 要保留 → 走整块重排 —— 此时若没抹掉旧尾部,重排出来的内容里就有**两份尾部**。
+          // 只要提前上屏过尾部就一定要抹掉 —— 不能只挂在 `needsReorder` 上:
+          // 尾部可能在概述还没确定要保留时就发了,重排时不抹掉就会出现两份尾部
           replacedTrailing: this.earlyTailText,
         });
       } else if (pendingTail.length) {
-        // 卡片已在正文之前上屏:尾部**纯追加**即可 —— 应用侧不重置、不闪
-        // (让出两帧与旧路径同:确保先发出的 scope 已被解析,再追加尾部内容)
+        // 卡片已在正文之前上屏:尾部纯追加即可(应用侧不重置、不闪)
+        // 让出两帧与旧路径同:确保先发出的 scope 已被解析,再追加尾部内容
         if (this.scopeEmitted) {
           await this.nextFrame();
           await this.nextFrame();
@@ -1251,10 +1148,9 @@ export class ApiChatCoordinator {
       return; // 没有卡片等要补发
     }
 
-    // 原 StrXMLFilter 在遇到 <!-- POLICY_TABLE 时,会把同一批 buffer 里位于它
-    // 之前的内容直接当纯文本行输出(processPolicyTableBlock 的既有行为)。
-    // 若本轮的 scope 进度标记还没被解析就与卡片标记同批送达,scope 会被当字面
-    // 文本渲染。这里让出两帧,确保先发出的 scope 已被解析完再追加卡片。
+    // 让出两帧:解析层遇到 <!-- POLICY_TABLE 时,会把同一批 buffer 里位于它之前的内容
+    // 当纯文本行输出,scope 进度标记若与卡片标记同批送达就会被当字面文本渲染 ——
+    // 等先发出的 scope 解析完再追加卡片。
     if (this.scopeEmitted) {
       await this.nextFrame();
       await this.nextFrame();
@@ -1264,18 +1160,13 @@ export class ApiChatCoordinator {
   }
 
   /**
-   * F079 卡片先于正文:**开流前**把卡片上屏。
+   * 卡片先于正文:**开流前**把卡片上屏。
    *
-   * 契约(`token-streaming.md`「卡片先于正文(F079)」)保证:有入选政策时,
-   * 完整 `source` / `item` 都发完才开始 `answer_start` / `answer_delta`。
-   * 所以 `answer_start` 这一刻卡片集合就是完整的 —— 在这里发既不会「先发一半再补」,
-   * 又能在正文第一个字之前就把卡片摆好。
+   * 契约(token-streaming.md)保证:有入选政策时,完整 `source` / `item` 都发完才开始
+   * `answer_start`,所以这一刻卡片集合就是完整的;不在这里发就得等 done,只能靠
+   * `answerStreamCompose` 整块重写把卡片插到正文之上(用户会看到正文渲两遍)。
    *
-   * 不这么做的话(旧行为):卡片要等 done 才发,与已经流出来的正文顺序冲突,
-   * 只能靠 `answerStreamCompose` **整块重写**把卡片插到正文之上 ——
-   * 用户看到的就是「正文先渲一遍,然后从头再渲一遍」,第一次纯属浪费(2026-09-21 用户指出)。
-   *
-   * 无卡片(闲聊 / 纯知识问答)时什么都不发;开流后才到的 item 由 `flushContent` 兜底。
+   * 无卡片时什么都不发;开流后才到的 item 由 `flushContent` 兜底。
    */
   private emitCardsBeforeStream(): void {
     const cards = buildPolicyTableContent(this.pendingItems, this.pendingSources);
@@ -1287,10 +1178,8 @@ export class ApiChatCoordinator {
   }
 
   /**
-   * 组装「正文之下」的尾部各块(**按最终展示顺序**)。
-   *
-   * 拆成带 key 的几块,是为了能**按块提前发**:数据先到的先上屏,不必等 done
-   * (见 `flushTailEarly` 的说明)。
+   * 组装「正文之下」的尾部各块(按最终展示顺序)。
+   * 拆成带 key 的几块是为了能按块提前发:数据先到的先上屏,不必等 done(见 flushTailEarly)。
    */
   private buildTrailingParts(streamBody: string | null): { key: string; text: string }[] {
     const parts: { key: string; text: string }[] = [];
@@ -1308,14 +1197,13 @@ export class ApiChatCoordinator {
       });
     }
 
-    // 企业微信二维码:后端标记为 CIPA / 出海类问题时追加。
-    // 位置在**参考资料之前**(用户 2026-09-20 指定)——正文/卡片之后、参考资料之前。
-    // ⚠️ 本轮若是**补问轮**(interrupt)则不展示:那时界面上是补问卡片,还没给出正文回答。
+    // 企业微信二维码:位置在**参考资料之前**。
+    // ⚠️ 补问轮(interrupt)不展示:那时界面上是补问卡片,还没给出正文回答。
     if (this.pendingCipa && !this.pendingInterrupt) {
       parts.push({ key: 'cipa', text: buildCipaQrcodeContent() });
     }
 
-    // 参考资料放在最后:它是引用来源,应该排在正文、卡片与二维码之后
+    // 参考资料放在最后(引用来源,排在正文、卡片与二维码之后)
     const refLinks = buildRefLinksContent(this.pendingSources);
     if (refLinks) {
       parts.push({ key: 'refs', text: refLinks });
@@ -1330,20 +1218,12 @@ export class ApiChatCoordinator {
   /**
    * **正文定稿就把「数据已就绪」的尾部立即上屏**,不等 done。
    *
-   * 为什么要有这一步(2026-09-21 实测,见 `harness/tools/probe-answer-tail-timing.mjs`):
-   * `done` 要等 `result`,而 result 是 **375~390KB** 的完整快照,实测比正文定稿晚 **3~10 秒**;
-   * 而参考资料的来源 `source` 事件在正文**开流前**就发完了 —— 用户看到的「等参考资料」
-   * 等的是事件、不是数据(两次实测里「数据到齐」到「界面显示」差了 3 秒 / 14.5 秒)。
-   *
-   * 只发**数据已就绪**的部分:`notices`(来自 summary)与参考资料(来自 source)。
-   * 二维码来自 `result.cipa`,只能等 done;那时若需要,会把它**插到参考资料之前**
-   * (版式不变,见 `flushContent` 的重排分支)。
+   * `done` 要等 `result`(375~390KB 的完整快照,实测比正文定稿晚 3~10 秒),
+   * 而 `notices` / 参考资料的数据早就到了。只发数据已就绪的块:二维码来自 `result.cipa`,
+   * 只能等 done,那时再插到参考资料之前(见 `flushContent` 的重排分支)。
+   * 触发点选在 `summary`:它到了 `notices` 才齐,否则 notices 会排在参考资料之后。
    *
-   * 触发点在 `summary`(契约保证它在 answer_end 之后必到,且实测 +0.00s)——
-   * 等它到齐再发,是为了让 `notices` 也能跟上,否则 notices 会在参考资料之后才到、顺序就颠倒了。
-   *
-   * 两种「不抢跑」的情况(都会在 done 时走整块重排,提前发反而要抹回来):
-   * ① 卡片没能提前上屏(`preStreamLeading` 为空)② `answer` 概述需要保留在正文之上。
+   * 两种情况不抢跑(提前发反而要在 done 时抹回来):卡片没提前上屏、`answer` 概述要保留。
    */
   private flushTailEarly(): void {
     if (this.contentFlushed || !this.answerStream.completed || !this.pendingSummary) {
@@ -1373,10 +1253,9 @@ export class ApiChatCoordinator {
   /**
    * 撤去本轮尚未定稿的流式正文(后端 answer_abort / 出错 / 断流)。
    *
-   * 必须先于随之而来的 error / close 同步发出:应用侧的 error 监听会立刻
+   * ⚠️ 必须在随之而来的 error / close **之前**同步发出:应用侧的 error 监听会立刻
    * `finishTask` 摘掉任务,abort 晚到就会被任务守卫丢弃。
-   *
-   * ⚠️ **用户主动停止不走这里**(决策:保留已流出的部分文字)。
+   * ⚠️ 用户主动停止不走这里(保留已流出的部分文字)。
    */
   private discardActiveStream(reason: AnswerAbortReason): void {
     const { streamId, completed } = this.answerStream;
@@ -1416,12 +1295,10 @@ export class ApiChatCoordinator {
       case 'accepted':
         break;
 
-      // 处理阶段 / 心跳 → 原“思考中”卡片,标题随阶段动态替换。
-      // 正文输出后不再发送,避免和正文抢位置。
+      // 处理阶段 / 心跳 → 原“思考中”卡片,标题随阶段动态替换;
+      // 正文开始流式输出(或整轮已 flush)后不再发,否则会把卡片插进正文中间
       case 'progress':
       case 'heartbeat': {
-        // 正文已开始流式输出(或整轮已 flush)后不再发「思考中」卡片:
-        // 否则流中途夹带的 heartbeat 会把卡片插进正文中间
         if (this.contentFlushed || this.answerStream.streamId !== null) {
           break;
         }
@@ -1431,7 +1308,7 @@ export class ApiChatCoordinator {
         break;
       }
 
-      // 正文真实增量流(F073):start / delta / end / abort
+      // 正文真实增量流:start / delta / end / abort
       case 'answer_start':
       case 'answer_delta':
       case 'answer_end':
@@ -1441,17 +1318,15 @@ export class ApiChatCoordinator {
         this.answerStream = state;
 
         if (effect.kind === 'start') {
-          // F079 卡片先于正文:正文马上要逐片上屏,卡片必须**抢在它前面**发,
-          // 否则只能等 done 时整块重写把卡片插上去——用户会看到正文先渲一遍、再从头渲一遍
+          // 卡片先于正文:正文马上要逐片上屏,卡片必须抢在它前面发
           this.emitCardsBeforeStream();
-          // 正文即将到达:此后 progress/heartbeat 不再发 scope(见上面那个 case 的守卫)
           this._eventbus.emit('answerStreamStart', {
             streamId: effect.streamId,
             target: effect.target,
           });
         } else if (effect.kind === 'append') {
-          // 逐片段上屏:走既有 message 通道,交给应用侧的 chunkBuffer 按帧批处理
-          // (实测平均 1.7 字/片、一轮最多 746 片,逐个直接渲染会把主线程打满)
+          // 逐片段上屏:走 message 通道,交给应用侧的 chunkBuffer 按帧批处理
+          // (实测平均 1.7 字/片,逐个直接渲染会把主线程打满)
           const increment = this.answerStreamNormalizer.push(effect.text);
           if (increment) {
             this._eventbus.emit('message', increment);
@@ -1466,7 +1341,7 @@ export class ApiChatCoordinator {
             text: finalText,
             status: effect.status,
           });
-          // 正文定稿 → 尾部能发就发(不等 done;summary 未到时会在它的 case 里再试一次)
+          // 正文定稿 → 尾部能发就发(不等 done;summary 未到时它的 case 里会再试一次)
           this.flushTailEarly();
         } else if (effect.kind === 'discard') {
           this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
@@ -1504,17 +1379,15 @@ export class ApiChatCoordinator {
       // 补问不代表本轮结束,后面仍有 result / done
       case 'interrupt':
         this.pendingInterrupt = data as ChatInterruptPayload;
-        // 留存最近一次补问:提交时要按选项 label 反查候选,
-        // 因为 QuestionCard 只回传 label、不回传 description
+        // 留存最近一次补问:提交时要按选项 label 反查候选(QuestionCard 只回传 label)
         this.lastInterruptPayload = data as ChatInterruptPayload;
         break;
 
-      // result.response 与 answer.text 相同,是最终快照,不再输出;
+      // result.response 与 answer.text 相同(最终快照),不再输出;
       // 但 company_info 只有这里有,暂存下来等 done 时随 totalResponse 交给应用侧
       case 'result': {
         const result = data as ChatResultPayload;
         this.pendingCompanyInfo = result.company_info ?? null;
-        // 后端只在 CIPA / 海外服务平台 / 出海服务的问题上给 true,其余为 false
         this.pendingCipa = result.cipa === true;
         break;
       }
@@ -1522,8 +1395,7 @@ export class ApiChatCoordinator {
       case 'done': {
         const turn = this.turnToken;
         this.doneReceived = true;
-        // flushContent 可能需要让出两帧,结束动作必须等它输出完再执行,
-        // 否则消息会先被标记完成、正文随后才到
+        // flushContent 可能让出两帧,结束动作要等它输出完再执行(否则消息先被标记完成、正文随后才到)
         void this.flushContent().then(() => {
           this.finishTurn(turn);
         });
@@ -1532,7 +1404,7 @@ export class ApiChatCoordinator {
 
       case 'error': {
         const code = data.code || 'processing_failed';
-        // F073:出错时撤掉未定稿的流式草稿(先于 error 事件同步发出)
+        // 出错时撤掉未定稿的流式草稿(先于 error 事件同步发出)
         this.discardActiveStream('generation_failed');
         this.generating = false;
         this.loading = false;
@@ -1547,9 +1419,8 @@ export class ApiChatCoordinator {
   }
 
   /**
-   * 正常结束(done)或用户主动停止。
-   * 注意:不单独发 done 事件——应用侧只监听 totalResponse 与 close,
-   * 新协议的事件已全部翻译进 message 通道。
+   * 正常结束(done)或用户主动停止。不单独发 done 事件 —— 应用侧只监听 totalResponse
+   * 与 close,新协议的事件已全部翻译进 message 通道。
    */
   private finishTurn(token: number, options: { silent?: boolean } = {}) {
     if (token !== this.turnToken) {
@@ -1564,7 +1435,7 @@ export class ApiChatCoordinator {
       this._eventbus.emit('totalResponse', {
         question: this.question,
         answer: this.totalResponse,
-        // 只有拿到了才带这个键(用户主动停止走 silent 分支,本来就不同步)
+        // 只有拿到了才带这个键(用户主动停止走 silent 分支)
         ...(this.pendingCompanyInfo != null ? { company_info: this.pendingCompanyInfo } : {}),
       });
     }
@@ -1578,7 +1449,7 @@ export class ApiChatCoordinator {
       return;
     }
 
-    // F073:任何中途失败都撤掉未定稿的流式草稿(幂等;必须先于 error/close 发出)
+    // 任何中途失败都撤掉未定稿的流式草稿(幂等;必须先于 error/close 发出)
     this.discardActiveStream('generation_failed');
 
     this.generating = false;

+ 18 - 35
src/components/business-assistant/chat-generation-task.ts

@@ -1,24 +1,15 @@
 /**
- * 「按会话并行生成」的任务模型与决策函数。
+ * 「按会话并行生成」的任务模型与决策函数:每个会话各自一个生成任务,互不干扰
+ * (后端 409 thread_busy 是 per-thread)。
  *
- * 背景(2026-09-17 重构):此前整个应用只有**一个** `ApiChatCoordinator` 实例和一个
- * 全局 `isGenerating`,于是「同一时间只能有一个会话在生成」。带来两个后果:
- *   ① 发送按钮的语义是全局的 —— 切到别的会话后按钮仍是「停止」,想发送却触发了停止
- *   ② 停止时按全局 `activeGenerationSessionId` 找目标,而它在同步事件里已被清空,
- *      于是 fallback 到「当前会话」,把停止打在了用户正在看的那个会话上
- *
- * 现在改成 ChatGPT 桌面那种模型:**每个会话各自一个生成任务,互不干扰**。
- * 后端支持(409 thread_busy 是 per-thread,见 reference/api-chat.md)。
- *
- * 本文件只放类型与**纯决策函数** —— 不 import vue、不发网络请求,
- * 这样 harness 的 esbuild 脚本能直接打包它们做断言(见 tools/verify-chat-task-utils.mjs)。
+ * 只放类型与纯决策函数 —— 不 import vue、不发网络请求,可被 esbuild 直接打包断言。
  */
 
 import type { ApiChatCoordinator } from "../api-chat-coordinator";
 
 /**
  * 流式内容缓冲。结构与 `shared.ts` 的 `createAiMessageChunkBuffer` 返回值兼容,
- * 这里用结构化接口而不是 import,避免把 shared 的依赖拖进纯逻辑层。
+ * 用结构化接口而非 import,避免把 shared 的依赖拖进纯逻辑层。
  */
 export interface AiMessageChunkBufferLike {
   push: (content: string) => void;
@@ -28,7 +19,7 @@ export interface AiMessageChunkBufferLike {
 
 /** 一个会话的「一轮生成」任务:每轮发送/补问/测试流各建一个 */
 export interface ChatGenerationTask {
-  /** 归属会话 id —— 内容路由与 DMS 写入都靠它(闭包捕获,不再读全局"当前生成会话") */
+  /** 归属会话 id —— 内容路由与 DMS 写入都靠它(闭包捕获,不读全局"当前生成会话") */
   sessionId: string;
   /** 本轮预置 AI 消息的 id:DMS 的 c_record_id,也是反馈定位用的 record.id */
   aiMessageId: string;
@@ -52,8 +43,8 @@ export const isSessionGenerating = (
 export type SendAction = "start" | "busy-same-session";
 
 /**
- * 发送守卫:**同一会话**已有任务在跑 → 拦截(提示"已有回答在生成中");
- * 别的会话在跑、或没有会话 → 放行。这是并行化的核心:冲突只按会话算。
+ * 发送守卫:**同一会话**已有任务在跑 → 拦截;别的会话在跑、或没有会话 → 放行。
+ * 这是并行化的核心:冲突只按会话算。
  */
 export const resolveSendAction = (
   tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
@@ -61,12 +52,10 @@ export const resolveSendAction = (
 ): SendAction => (isSessionGenerating(tasks, sessionId) ? "busy-same-session" : "start");
 
 /**
- * 停止的目标:只停**当前会话自己**的任务。
- * 当前会话没在生成(按钮此时显示的是"发送"而不是"停止")→ null,什么都不做。
+ * 停止的目标:只停**当前会话自己**的任务(没在生成 → null,什么都不做)。
  *
- * ⚠️ 这个函数的存在就是为了根治旧 bug:以前是在 `stopGenerate()` 之后才去找目标,
- * 而那个调用会同步触发 close、把"正在生成的会话"清空,于是必然 fallback 到当前会话、
- * 把停止打在错误的会话上。现在目标在动手**之前**解析,且由任务的闭包决定写哪。
+ * 目标必须在动手**之前**解析:`stopGenerate()` 会同步触发 close、清空"正在生成的会话",
+ * 之后再找就会 fallback 到当前会话、把停止打在错误的会话上。
  */
 export const resolveStopTargetTaskId = (
   tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
@@ -75,27 +64,21 @@ export const resolveStopTargetTaskId = (
   isSessionGenerating(tasks, currentSessionId) ? (currentSessionId as string) : null;
 
 /**
- * 切换会话时,是否对该会话做历史归一化。
- * 生成中的会话不能归一化 —— 归一化会把还在流式的 pending 内容判成"中断"并写死文案。
+ * 切换会话时,是否对该会话做历史归一化:生成中的不能归一化
+ * (会把还在流式的 pending 内容判成"中断"并写死文案)。
  */
 export const shouldNormalizeSessionHistory = (
   tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
   sessionId: string | null | undefined
 ): boolean => !isSessionGenerating(tasks, sessionId);
 
-// ─── 任务表的更新方式(⚠️ 这里踩过一个坑,别改回去)─────────────────────
-//
-// 任务表必须用 **`shallowRef(new Map())` + 整体替换**,不能用 `ref(new Map())`:
-//
-//   ref(new Map()).get(id) 返回的是**响应式代理**,不是存进去的那个对象,
-//   于是 `tasks.value.get(id) !== task` 这种「取出来的还是不是我存的那个」判断
-//   **永远成立** —— 监听器会全部提前返回,表现为:mock 流跑完了按钮还停在
-//   "运行中"、真实对话的内容一帧都不追加。
-//
-//   (实测:`ref(new Map()).get(k) === raw` 为 false;shallowRef 为 true。)
+// ─── 任务表的更新方式:必须是 `shallowRef(new Map())` + 整体替换,别改回去 ───
 //
-// shallowRef 不代理内部值,所以取出来的就是原对象;但**原地 mutate 不会触发响应式**,
-// 因此下面两个函数用「复制 + 替换」的方式产出新 Map,由调用方赋值给 `tasks.value`。
+// `ref(new Map()).get(id)` 返回的是响应式代理,不是存进去的那个对象,于是
+// `tasks.value.get(id) !== task` 这种同一性判断会**永远成立** → 监听器全部提前返回
+// (mock 流跑完不清理、真实对话内容一帧都不追加)。
+// shallowRef 不代理内部值,但原地 mutate 不触发响应式,所以下面两个函数
+// 用「复制 + 替换」产出新 Map,由调用方赋值给 `tasks.value`。
 
 /** 产出一个「加了这个任务」的新 Map(不改原 Map) */
 export const withTaskAdded = (

+ 17 - 66
src/components/business-assistant/shared.ts

@@ -21,18 +21,14 @@ export const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
 export const MAX_FILE_SIZE = 50 * 1024 * 1024;
 
 /**
- * 支持的格式 —— 与产品提示文案**一一对应**(用户 2026-09-23 按提示对齐):
- *   文件:**Excel(xls/xlsx)、DOCX、文字 PDF**
- *   图片:**JPEG、PNG、GIF、WebP**
- *
- * ⚠️ 改动这里要**同时改三处**,否则会出现"提示说支持、实际被拒":
- *   ① 本文件的两个扩展名数组 ② PC / Mobile 里 `<input accept="…">`
- *   ③ 提示文案(用下面的 `*_LABEL` 常量拼,别各写各的)
+ * 支持的上传格式:文件 = Excel(xls/xlsx)/ DOCX / 文字 PDF,图片 = JPEG / PNG / GIF / WebP。
+ * 改这里要**同时改三处**(否则"提示说支持、实际被拒"):本文件的两个扩展名数组、
+ * PC/Mobile 的 `<input accept>`、提示文案(用下面的 `*_LABEL` 常量拼)。
  */
 export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
 export const SUPPORTED_ATTACHMENT_EXTENSIONS = ['xls', 'xlsx', 'docx', 'pdf'];
 
-/** 提示文案里给的格式说明(给人看的写法,与上面的白名单保持同一口径) */
+/** 提示文案里的格式说明(与上面的白名单同一口径) */
 export const SUPPORTED_IMAGE_LABEL = 'JPEG、PNG、GIF、WebP';
 export const SUPPORTED_ATTACHMENT_LABEL = 'Excel、DOCX、文字PDF';
 
@@ -44,9 +40,8 @@ export interface BusinessAssistantMessage {
   state?: number;
   history?: boolean;
   /**
-   * F073:本轮正文走**真实增量流式**(协调器 `answer_stream_start` 已到、`finishTask` 前)。
-   * 渲染层据此关闭打字机改为即时渲染——正文已经是逐片段到达了,再逐字重放等于凭空多等一遍。
-   * 旧后端轮次、mock 测试流从不置位,行为不变。
+   * F073:本轮正文走真实增量流式 —— 渲染层据此关闭打字机改为即时渲染
+   * (正文已是逐片段到达,再逐字重放等于凭空多等一遍)。
    */
   contentStreaming?: boolean;
   questionSource?: any[];
@@ -81,15 +76,13 @@ export interface BusinessAssistantMessageAttachment {
   size: number;
   isImage: boolean;
   /**
-   * **可显示地址**(气泡里 `<img src>` 用的那个)—— 图片才有。
-   * 2026-09-23 换 DMS 后它可能是 `/dms/static/photo/…` 这样的**代理地址**,
-   * 只有拼接前缀才能取到图,`/static/photo/…` 这种相对路径直接当 src 是取不到的
-   * (实测:相对路径经 dev server 返回的是 index.html,`<img>` 解不出来 → 图不显示)。
+   * **可显示地址**(气泡里 `<img src>` 用的那个)—— 图片才有,是拼了代理前缀的地址;
+   * 相对路径直接当 src 取不到图(实测经 dev server 返回的是 index.html)。
    */
   url?: string;
   /**
-   * **相对路径**(`/static/photo/…`)—— 这个才是随请求体 `pictures` / `files`
-   * 发给**后端**的值,别跟 `url` 混(见 `toChatAttachmentUrls`)。
+   * **相对路径**(`/static/photo/…`)—— 随请求体 `pictures` / `files` 发给**后端**的是它,
+   * 别跟 `url` 混(见 `toChatAttachmentUrls`)。
    */
   fileUrl?: string;
 }
@@ -338,8 +331,7 @@ export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
     changed = true;
   }
 
-  // 判空口径与渲染层统一:`<scope>` 进度块不算可见内容
-  // (见 utils/interrupted-message.ts 的 hasVisibleMessageContent 说明)
+  // 判空口径与渲染层统一:`<scope>` 进度块不算可见内容(见 hasVisibleMessageContent)
   if (!hasVisibleMessageContent(lastMsg.content)) {
     lastMsg.content = CANCELLED_SESSION_TEXT;
     changed = true;
@@ -353,27 +345,10 @@ export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
   return changed;
 };
 
-/**
- * 组装**发给后端的 question**:把已上传附件的地址拼在问题后面。
- *
- * 背景(2026-09-18):新接口 `POST /api/chat` 的请求体**只接受 `{thread_id, question}`
- * 两个字段**,多一个就 422,所以旧协议的 `transmission.files / file_pos` 发不出去。
- * 经用户拍板走「把 OSS 地址拼进 question 文本」这条**不需要后端配合**的路子。
- *
- * ⚠️ **只用于发给后端**,不要拿它当本地消息内容 —— 否则用户气泡里会显示一长串 URL。
- * 本地展示仍用用户输入的原文。
- *
- * 格式:原文 + 换行 + 每条 URL 一行(**不加「附件:」之类的字眼**,
- * 免得凭空发明一套后端可能不认识的措辞;URL 本身就说明了一切)。
- * 若后端要求特定标记,改这一个函数即可。
- */
 /**
  * 多模态附件的两种 URL 列表(`/api/chat` 请求体的 `pictures` / `files` 字段)。
- *
- * 实测(2026-09-23):
- * - 两者都是 **URL 字符串数组**(传对象数组会被 422 `invalid_request` 拒掉)
- * - `pictures` 里的图后端**自己去取**(所以必须是可公网访问的地址),
- *   实测带上它之后模型确实"看到"了图并能描述内容
+ * 两者都是 **URL 字符串数组**(传对象数组会被 422 `invalid_request` 拒掉);
+ * `pictures` 里的图后端自己去取,所以必须是可公网访问的地址。
  */
 export interface ChatAttachmentUrls {
   /** 图片(后端按视觉输入处理) */
@@ -383,17 +358,11 @@ export interface ChatAttachmentUrls {
 }
 
 /**
- * 把上传模块交出来的 transmission 拆成 `pictures` / `files`。
+ * 把上传模块交出来的 transmission 拆成 `pictures` / `files`(按 `attachments[].isImage` 分,
+ * 两个列表都只保留非空 URL)。拿不到 `attachments` 时全部当普通文件(至少地址不丢)。
  *
- * 按 `attachments[].isImage` 分(上传时已经判好),**两个列表都只保留非空 URL**。
- * 拿不到 `attachments`(只有 `files`)时,只能全部当普通文件 —— 分不出图片,
- * 但至少地址不丢。
- *
- * ⚠️ **取的是 `fileUrl`(相对路径),不是 `url`(可显示地址)**:
- * 换 DMS 之后两个字段分工不同 —— 后端要的是 `/static/photo/…`,
- * 而 `url` 是拼了 `/dms` 代理前缀给 `<img>` 用的。
- * 2026-09-23 的 bug 正是这里取错:气泡里的图不显示(相对路径解不出来),
- * 详见 progress.md 续 28 与 verify-attachment-question.mjs 的【7】。
+ * ⚠️ **取的是 `fileUrl`(相对路径),不是 `url`(可显示地址)**:后端要 `/static/photo/…`,
+ * 而 `url` 是拼了 `/dms` 代理前缀给 `<img>` 用的 —— 取错会让气泡里的图不显示。
  */
 export const toChatAttachmentUrls = (
   transmission?: BusinessAssistantInputFileTransmission | null,
@@ -416,21 +385,3 @@ export const toChatAttachmentUrls = (
     .filter(Boolean);
   return { pictures: [], files: fallback };
 };
-
-// [已停用] 附件地址拼进 question 文本(方案②)—— 2026-09-23 后端支持了
-// `pictures` / `files` 两个多模态字段(方案①),地址不再拼进正文:
-// 拼进去会让模型把 URL 当正文内容读,而且本地消息、会话标题、DMS 记录都跟着变脏。
-// export const buildQuestionWithAttachments = (
-//   question: string,
-//   fileUrls?: Array<string | null | undefined> | null,
-// ): string => {
-//   const urls = (Array.isArray(fileUrls) ? fileUrls : [])
-//     .map((url) => String(url ?? '').trim())
-//     .filter(Boolean);
-//
-//   if (!urls.length) {
-//     return question;
-//   }
-//
-//   return `${question}\n${urls.join('\n')}`;
-// };

+ 58 - 114
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -42,7 +42,7 @@ import {
   composeAroundStreamBody,
   resolveStreamedBodyAfterEnd,
 } from '../answer-stream';
-// 1887 助手会话:写企业相关的值(名称/归属/是否登录/图片),见 upsertDmsSession 的注释
+// 1887 助手会话写企业相关的值(名称/归属/是否登录/图片),见 upsertDmsSession 的注释
 
 interface UseBusinessAssistantChatOptions {
   apiBaseUrl?: string;
@@ -64,12 +64,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   /**
    * 按会话并行的生成任务表(key = sessionId)。
    *
-   * ⚠️ 必须是 **`shallowRef` + 整体替换**(用 withTaskAdded/withTaskRemoved),
-   * 不能用 `ref(new Map())` 也不能原地 `.set()/.delete()`:
-   *   · `ref(new Map())` 取出来的值是**响应式代理**,`tasks.value.get(id) !== task`
-   *     这种同一性判断会永远成立 → 监听器全部提前返回(曾因此让 mock 流跑完不清理、
-   *     真实对话内容一帧不追加)
-   *   · shallowRef 下原地 mutate 不触发响应式 → 所以每次换一个新 Map
+   * ⚠️ 必须是 **`shallowRef` + 整体替换**(withTaskAdded/withTaskRemoved),
+   * 不能用 `ref(new Map())`(取出来的是响应式代理,同一性判断永远成立 → 监听器全部提前返回),
+   * 也不能原地 `.set()/.delete()`(shallowRef 下不触发响应式)。
    * 详见 chat-generation-task.ts 末尾的说明。
    */
   const tasks = shallowRef(new Map<string, ChatGenerationTask>());
@@ -81,9 +78,6 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const currentSessionGenerating = computed(() =>
     isSessionGenerating(tasks.value, currentSession.value?.id ?? null)
   );
-  // [已停用] `toastMessage` 状态:提示改由 Ant Design 的 message 直接弹(见 showToast),
-  // 不再需要壳组件挂 <Toast> 消费。保留注释以便回退。
-  // const toastMessage = ref('');
   const isZhaoshangMode = isZhaoshangLLM();
   const storedThinking = isZhaoshangMode ? null : localStorage.getItem('enable_thinking');
   const isDeepThink = ref(isZhaoshangMode ? false : storedThinking !== null ? storedThinking === 'true' : true);
@@ -103,12 +97,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const getCreditCode = () => enterpriseInfo.value?.credit_code || '';
 
   /**
-   * 把一轮问答写进 DMS(一问一答一条,幂等键 = 该轮 AI 消息 id)。
+   * 把一轮问答写进 DMS(一问一答一条,幂等键 = 该轮 AI 消息 id):
    * 提交时只带 question(+ 附件路径),回答完成/中断时再带 answer 补一次。
-   *
-   * `images` / `files` 存的是附件**相对路径**(用户 2026-09-23 要求落到
-   * `c_image` / `c_file` 两列),值与请求体 `pictures` / `files` 完全一致 ——
-   * 这样库里记的和后端收到的是同一份,出问题好对账。
+   * `images` / `files` 存附件**相对路径**(落 `c_image` / `c_file` 两列),值与请求体一致。
    */
   const saveTurnToDms = (params: {
     sessionId: string;
@@ -121,9 +112,6 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     void upsertDmsRecord(params);
   };
 
-  // 会话标题不再同步到服务端(2026-09-18 用户指示,2026-09-24 连注释一起清掉):
-  // 改名只改本地;1887 的写入只保留「回答完成时 upsert 一次」,没有标题同步这条链路。
-
   const syncSessionDeleteToServer = (sessionId: string) => {
     void deleteSessionInfo(sessionId, getCreditCode());
   };
@@ -217,7 +205,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         saveHistory();
       }
     } catch (error) {
-      console.error('Failed to parse chat history', error);
+      /* console.error('Failed to parse chat history', error); */
     }
   };
 
@@ -244,13 +232,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     });
   };
 
-  /**
-   * 全局提示。
-   *
-   * 2026-09-23 起改用 **Ant Design Vue 的 `message`**(顶部弹出、过几秒自动消失)——
-   * 原来的 `<Toast>` 是「全屏黑遮罩 + 居中白框」,用户说"太丑了"。
-   * 调用方签名不变,仍然只传文案。
-   */
+  /** 全局提示:走 Ant Design Vue 的 message(顶部弹出、过几秒自动消失),调用方只传文案 */
   const showToast = (message: string) => {
     showAppMessage(message);
   };
@@ -258,9 +240,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   /**
    * 收尾一个生成任务:落残余内容 → 释放缓冲 → 从任务表移除 → 收尾消息状态 → 存档。
-   *
-   * 会被 totalResponse / close / error / stopTask 多条路径调用,靠 `task.finished`
-   * 与「表里还是不是这个 task」双重防重(mitt 是**同步** emit,事件会连着来)。
+   * 会被 totalResponse / close / error / stopTask 多条路径调用,靠 `task.finished` 与
+   * 「表里还是不是这个 task」双重防重(mitt 是同步 emit,事件会连着来)。
    */
   const finishTask = (task: ChatGenerationTask) => {
     if (task.finished || tasks.value.get(task.sessionId) !== task) {
@@ -272,15 +253,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     task.chunkBuffer.cancel();
     tasks.value = withTaskRemoved(tasks.value, task.sessionId);
 
-    // 补问提交发生在**下一轮**(协调器实例已销毁),所以把最近一次补问数据留在会话上
+    // 补问提交发生在**下一轮**(协调器实例已销毁),把最近一次补问数据留在会话上
     if (task.coordinator?.lastInterruptPayload) {
       sessionInterruptPayloads.set(task.sessionId, task.coordinator.lastInterruptPayload);
     }
 
     const session = getSessionById(task.sessionId);
     if (session) {
-      // F073:流式标记只在本轮真正收尾时清除(end/abort 都不清)——
-      // 这样 answer_end 之后追加的卡片/参考资料仍走即时渲染,观感一致
+      // 流式标记只在本轮真正收尾时清除(end/abort 都不清):
+      // answer_end 之后追加的卡片/参考资料仍走即时渲染,观感一致
       const lastAiMessage = getLastAiMessage(session.messages);
       if (lastAiMessage?.contentStreaming) lastAiMessage.contentStreaming = false;
       finishAiMessage(session.messages); // 已是 Stop 态则内部跳过
@@ -292,8 +273,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
    * 建一个生成任务,并把该会话的事件监听挂到它自己的协调器上。
    *
    * ⚠️ 所有监听器**闭包捕获 sessionId**,一律不读「当前会话」「当前生成会话」——
-   * 这正是并行化的关键:用户在 A 生成时切到 B、在 B 提问,两条流各写各的会话,
-   * 不会因为"谁是当前会话"而串台。
+   * 这是并行化的关键:A 生成时切到 B 提问,两条流各写各的会话,不会串台。
    */
   const createGenerationTask = (
     sessionId: string,
@@ -310,22 +290,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     };
 
     /**
-     * F073 增量流式的本任务状态(闭包持有 → 并行会话天然隔离)。
-     * `baseIndex/baseText` 是「流开始那一刻消息已有内容」的快照:正文替换/撤回都以它为界,
+     * 增量流式的本任务状态(闭包持有 → 并行会话天然隔离)。
+     * `baseIndex/baseText` 是「流开始那一刻消息已有内容」的快照,正文替换/撤回以它为界,
      * 这样 `<scope>` 进度标记等此前已上屏的内容不会被误伤。
      */
     const stream = { active: false, streamId: '', baseIndex: -1, baseText: '', body: '', lastHistorySaveAt: 0 };
 
-    /**
-     * 流式相关的诊断日志(仅非生产环境)。
-     *
-     * 为什么留着:内容被整块替换时用户会看到「先出现的内容突然消失」,
-     * 而这类问题只看最终结果看不出来 —— 得知道**是哪条路径改写了内容**、改了多少。
-     * 出问题时报这几行日志即可定位(见 F073 的记录)。
-     */
+    /** 流式相关诊断日志(仅非生产环境):内容被整块改写的问题只能靠它定位是哪条路径改的 */
     const debugStream = (tag: string, detail: Record<string, unknown>) => {
       if (import.meta.env.MODE === 'production') return;
-      console.warn(`[answer-stream] ${tag}`, detail);
+      /* console.warn(`[answer-stream] ${tag}`, detail); */
     };
 
     /** 取本轮的 AI 消息(双守卫:任务还在表里 + 就是这条消息) */
@@ -343,15 +317,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       },
       onFlush: () => {
         void options.onMessageUpdated?.();
-        // 流式期每帧都会 flush:整库 JSON.stringify 太贵,按 500ms 节流;
-        // 收尾(finishTask)会无条件再存一次,不会丢
+        // 流式期每帧都会 flush:整库 JSON.stringify 太贵,按 500ms 节流
+        // (收尾 finishTask 会无条件再存一次,不会丢)
         if (stream.active && Date.now() - stream.lastHistorySaveAt < 500) return;
         stream.lastHistorySaveAt = Date.now();
         saveHistory();
       },
     });
 
-    // ---- F073:正文真实增量流式(start / end / abort;delta 走上面的 message 通道)----
+    // ---- 正文真实增量流式:start / end / abort;delta 走上面的 message 通道 ----
 
     coordinator?.addEventListener('answerStreamStart', (payload: any) => {
       if (tasks.value.get(sessionId) !== task) return;
@@ -361,12 +335,12 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       stream.baseIndex = ai.content.length;
       stream.baseText = ai.content;
       // 自己累计「流出来的正文」:后面改写内容都以它为准做**后缀匹配**,
-      // 不再用「按基线长度切片」——基线可能已经被别的路径改过(那样切片会错位)
+      // 不按「基线长度切片」——基线可能已被别的路径改过(那样切片会错位)
       stream.body = '';
       stream.streamId = String(payload?.streamId || '');
       stream.active = true;
       debugStream('start', { base: stream.baseText.length });
-      // 关掉打字机:正文已经是「真实增量」,不该再被逐字重放(见 BusinessRecord 的守卫)
+      // 关掉打字机:正文已是真实增量,不再逐字重放(见 BusinessRecord 的守卫)
       ai.contentStreaming = true;
     });
 
@@ -377,13 +351,13 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       if (!stream.active || String(payload?.streamId || '') !== stream.streamId) return; // 过期流
       task.chunkBuffer.flush(); // 残余增量先落,再按最终全文对齐
 
-      // 用**自己累计的流式正文**做前缀比较/切片(不要按基线长度切:基线可能已被别的路径改过)
+      // 用自己累计的流式正文做前缀比较/切片(不按基线长度切:基线可能已被别的路径改过)
       const before = ai.content;
       const finalText = String(payload?.text ?? '');
       const next = applyAnswerStreamEnd(before, stream.body, finalText);
       if (next !== before) ai.content = next;
-      // 上屏正文更新为最终全文(可能比增量拼接长)—— 不同步的话,下面的
-      // answerStreamCompose 就不再是「上屏内容以流式正文结尾」,会退到兜底分支(正文上屏两份)
+      // 上屏正文更新为最终全文(可能比增量拼接长):不同步的话,后面的重排就不再是
+      // 「上屏内容以流式正文结尾」,会退到兜底分支(正文上屏两份)
       stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
       debugStream('end', {
         流式正文: stream.body.length,
@@ -394,7 +368,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       stream.active = false; // contentStreaming 不清:之后的卡片等仍即时渲染,由 finishTask 统一收尾
     });
 
-    // 流式轮收尾时把内容重排一次:**卡片放正文之上**(用户要求),参考资料等仍在正文之下
+    // 流式轮收尾把内容重排一次:**卡片放正文之上**,参考资料等仍在正文之下
     coordinator?.addEventListener('answerStreamCompose', (payload: any) => {
       if (tasks.value.get(sessionId) !== task) return;
       const ai = streamTargetMessage();
@@ -402,7 +376,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       task.chunkBuffer.flush(); // 残余增量先落,正文才是完整的
       const before = ai.content;
       const body = stream.body;
-      // 基线由「上屏内容减去流式正文」**推导**,不用开流时的快照(基线可能已被别的路径改过)
+      // 基线由「上屏内容减去流式正文」推导,不用开流时的快照(基线可能已被别的路径改过)
       const { next, matched } = composeAroundStreamBody(
         before,
         body,
@@ -412,7 +386,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         String(payload?.replacedTrailing || '')
       );
       if (!matched) {
-        console.warn('[answer-stream] 上屏内容末尾不是流式正文,重排走兜底(不重复、不丢字)');
+        /* console.warn('[answer-stream] 上屏内容末尾不是流式正文,重排走兜底(不重复、不丢字)'); */
       }
       if (next !== before) ai.content = next;
       debugStream('compose', { 正文: body.length, 卡片: String(payload?.leading || '').length, 尾部: String(payload?.trailing || '').length, matched });
@@ -426,7 +400,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       task.chunkBuffer.flush(); // 残余先落(随后被截掉,顺序无污染)
       const before = ai.content;
       const body = stream.body;
-      // 撤稿 = 从上屏内容里去掉这段流式正文(同样用后缀匹配,不按快照长度切)
+      // 撤稿 = 从上屏内容里去掉这段流式正文(后缀匹配,不按快照长度切)
       ai.content = applyAnswerStreamAbort(before, body);
       debugStream('abort', { reason: payload?.reason, 撤掉: before.length - ai.content.length });
       stream.active = false;
@@ -435,8 +409,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     coordinator?.addEventListener('message', (msg: string) => {
       if (tasks.value.get(sessionId) !== task) return;
       if (!getSessionById(sessionId)) return;
-      // 正在流式时,这条通道上跑的就是正文增量 —— 自己留一份,
-      // 收尾对齐/卡片重排/撤稿都以它为准(见 stream.body 的说明)
+      // 流式时这条通道上跑的就是正文增量:自己留一份,收尾对齐/重排/撤稿都以它为准
       if (stream.active) stream.body += msg;
       task.chunkBuffer.push(msg);
     });
@@ -457,18 +430,12 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         });
       }
 
-      // [已停用] 首轮完成时同步标题到 DMS 会话行(1887 不再写入)
-      // if (session && session.messages.length <= 2) {
-      //   syncSessionTitleToServer(session.id, session.title || '');
-      // }
-
-      // 本轮若带回了企业资料 → 调分类接口并把结果落 DMS 企业栏目。
-      // fire-and-forget:内部全程 try/catch + 只 warn,不阻塞也不影响聊天
+      // 本轮带回了企业资料 → 调分类接口并把结果落 DMS 企业栏目
+      // (fire-and-forget:内部全程 try/catch + 只 warn,不阻塞也不影响聊天)
       if (payload?.company_info) syncCompanyInfoFromChat(payload.company_info);
 
-      // 顺带把企业相关的值写进 1887「助手会话」(用户 2026-09-22 要求):
-      // 未登录时只在对话确实带回了 company_info 时写;登录后用登录态的值覆盖(优先级最高)。
-      // 写哪些、谁覆盖谁见 upsertDmsSession 的注释。fire-and-forget,失败只 warn。
+      // 顺带把企业相关的值写进 1887「助手会话」:未登录时只在本轮确实带回了 company_info
+      // 时写,登录后用登录态的值覆盖(写哪些、谁覆盖谁见 upsertDmsSession 的注释)
       void upsertDmsSession({
         sessionId,
         sessionTitle: session?.title || '',
@@ -486,7 +453,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       // 停止/断流走这里(没有 totalResponse):把已生成的内容补写进 DMS,
       // 否则被中断的问答在库里会只有 question 没有 answer
       //
-      // ⚠️ 判据用 hasVisibleMessageContent 而不是 trim():F073 撤草稿后 content 可能只剩
+      // ⚠️ 判据用 hasVisibleMessageContent 而不是 trim():撤草稿后 content 可能只剩
       // `<scope>` 进度标记,trim() 非空会把这段标记当回答写进库
       if (
         session &&
@@ -503,8 +470,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       finishTask(task);
     });
 
-    // 提示类事件不校验任务归属:出错时 emitError 会**先**发 error(本任务已被收尾、
-    // 从表里移除)再发 info,加了校验就会把 409 之类的提示吞掉。
+    // 提示类事件不校验任务归属:emitError 会**先**发 error(本任务已被收尾、从表里移除)
+    // 再发 info,加了校验就会把 409 之类的提示吞掉。
     coordinator?.addEventListener('info', (text: string) => {
       if (text) showToast(text);
     });
@@ -571,8 +538,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       return;
     }
 
-    // 守卫按**会话**算:同一会话已有回答在生成中 → 拦截并提示;
-    // 别的会话在生成不受影响(这是按会话并行的前提)
+    // 守卫按会话算:同一会话已有回答在生成中 → 拦截;别的会话在生成不受影响
     if (resolveSendAction(tasks.value, currentSession.value?.id ?? null) === 'busy-same-session') {
       showToast('该会话已有回答在生成中');
       return;
@@ -586,16 +552,14 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
     const userMessagePos = session.messages.filter((message) => message.role === 'user').length;
     const fileTransmission = options.getInputFileTransmission?.(userMessagePos);
-    // 附件走**请求体的多模态字段** `pictures` / `files`(2026-09-23 起后端支持,实测契约):
-    //   · pictures —— 图片 URL 数组(后端自己去取图,必须公网可达)
-    //   · files    —— 非图片文件 URL 数组
-    // 不再把地址拼进 question:那样模型会把 URL 当正文读,而且会话标题、DMS 记录都跟着变脏。
-    // question 发给后端的就是用户原文。
+    // 附件走**请求体的多模态字段** `pictures` / `files`(图片 URL 数组 / 非图片文件 URL 数组,
+    // pictures 里的图后端自己去取,必须公网可达)。**不把地址拼进 question**:
+    // 那样模型会把 URL 当正文读,会话标题与 DMS 记录也会跟着变脏 —— 发给后端的就是用户原文。
     const attachmentsForBackend = toChatAttachmentUrls(fileTransmission);
 
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
     // 预置一条空的 AI 消息:它的 id 既是本轮的 DMS 记录标识(c_record_id),
-    // 也是反馈组件用的 record.id —— 三条链路同一个值,反馈才定位得到
+    // 也是反馈组件用的 record.id —— 同一个值,反馈才定位得到
     const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
 
@@ -609,16 +573,14 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     saveHistory();
     await options.afterSend?.();
 
-    // 点击提交即写 DMS:**只写本轮问答记录(1889)**,不再写会话行(1887)
-    // 1887 助手会话的写入**不在这里**:统一放在回答完成的 totalResponse 里
-    // (那时才知道本轮有没有 company_info,见 upsertDmsSession 的注释)。
-    // 别在这里补一行,否则同一次会话会写两次。
-    // fire-and-forget,失败只 warn,不影响聊天
+    // 点击提交即写 DMS:**只写本轮问答记录(1889)**,不写会话行(1887)——
+    // 1887 的写入统一放在回答完成的 totalResponse 里(那时才知道本轮有没有 company_info)。
+    // 别在这里补一行,否则同一次会话会写两次。fire-and-forget,失败只 warn。
     saveTurnToDms({
       sessionId: session.id,
       recordId: pendingAiMessage.id,
       question: text,
-      // 附件路径一并落库(用户 2026-09-23 要求):直接复用给后端的那两份,
+      // 附件路径一并落库:直接复用给后端的那两份,
       // 保证 **DMS 里存的就是后端收到的**(相对路径),不会各存一份口径不同的地址
       images: attachmentsForBackend.pictures,
       files: attachmentsForBackend.files,
@@ -684,7 +646,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         return;
       }
 
-      console.log('Appending chunk:', chunk);
+      /* console.log('Appending chunk:', chunk); */
       appendAiMessageChunk(typewriterTestSession.messages, chunk);
       saveHistory();
       void options.onMessageUpdated?.();
@@ -1622,12 +1584,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   /**
    * 停止生成 —— 只停**当前会话**自己的那一轮。
    *
-   * ⚠️ 这里曾有一个隐蔽的 bug:旧实现先调 `stopGenerate()`(同步 emit close →
-   * 监听器把"正在生成的会话"清空),再去找目标,于是 `getActiveGenerationSession()
-   * || currentSession` 必然 fallback 到**用户当前看的会话** —— 停止打错了对象,
-   * 还会凭空给那个会话造出一条空的"请求已取消"消息。
-   * 现在目标由 `resolveStopTargetTaskId` 在动手**之前**解析,且任务的监听器只操作
-   * 自己闭包里的会话。
+   * ⚠️ 目标必须由 `resolveStopTargetTaskId` 在动手**之前**解析:`stopGenerate()` 会同步
+   * emit close、把"正在生成的会话"清空,之后再找就必然 fallback 到用户当前看的会话。
    */
   const handleStopGenerate = () => {
     clearTypewriterTestTimer();
@@ -1637,7 +1595,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
   };
 
-  /** 新建会话:**不再**打断正在生成的会话 —— 后台任务继续跑(与主流产品一致) */
+  /** 新建会话:**不再**打断正在生成的会话,后台任务继续跑 */
   const startNewChat = () => {
     currentSessionIndex.value = -1;
     currentSessionVisitedBefore.value = false;
@@ -1681,8 +1639,6 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
     chatHistory.value[index].title = nextTitle;
     saveHistory();
-    // [已停用] 改名不再同步到 DMS 会话行(1887 不再写入)——只改本地
-    // syncSessionTitleToServer(chatHistory.value[index].id, nextTitle);
     return true;
   };
 
@@ -1776,8 +1732,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
           history: true,
         },
         {
-          // 用 DMS 回填的 record_id 当消息 id:这样从 DMS 读回的历史消息,
-          // 点赞/点踩时仍能命中 DMS 里的原始行(否则反馈会另起一行、对不上账)
+          // 用 DMS 回填的 record_id 当消息 id:点赞/点踩时才能命中 DMS 里的原始行
           id: msg.record_id || createBusinessAssistantId(),
           role: 'ai' as const,
           content: msg.answer,
@@ -1794,7 +1749,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const submitQuestionAnswers = async (answers: any[]) => {
     const session = currentSession.value;
     if (!session) {
-      console.warn('[submitQuestionAnswers] No session available');
+      /* console.warn('[submitQuestionAnswers] No session available'); */
       return;
     }
     if (resolveSendAction(tasks.value, session.id) === 'busy-same-session') {
@@ -1802,7 +1757,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       return;
     }
 
-    // 原 <question-cards> 提交的是 {answers} 对象,这里翻译成协议要求的 question 字符串。
+    // 把 {answers} 翻译成协议要求的 question 字符串。
     // 传入最近一次补问数据(按会话存):公司候选要按 label 反查、拼出选项的全部内容。
     const answerText = toChatQuestionAnswer(answers, sessionInterruptPayloads.get(session.id));
     if (!answerText) {
@@ -1810,8 +1765,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
 
     // 把答案**写回那一轮 AI 消息**里的问卷行 → 卡片立刻变只读(显示已选项/已填文本),
-    // 而不是一直停在可点可填的状态(用户 2026-09-24:「下方已经新对话了,上方的问卷应该是只读状态」)。
-    // 取最后一条带 <question-cards> 的 AI 消息:正要作答的就是它。
+    // 不再停在可点可填的状态。取最后一条带 <question-cards> 的 AI 消息:正要作答的就是它。
     const questionCardsMessage = [...session.messages]
       .reverse()
       .find((item) => item.role === 'ai' && typeof item.content === 'string' && item.content.includes('<question-cards>'));
@@ -1834,11 +1788,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     saveHistory();
     await options.afterSend?.();
 
-    // 补问也是一轮问答:记进 DMS 的问答记录(1889)。问题存**真实提交文本**(answerText),
-    // 不存界面上那句「已提交企业信息补充」——否则从 DMS 读回的历史会失真
-    // 1887 助手会话的写入**不在这里**:统一放在回答完成的 totalResponse 里
-    // (那时才知道本轮有没有 company_info,见 upsertDmsSession 的注释)。
-    // 别在这里补一行,否则同一次会话会写两次。
+    // 补问也是一轮问答:记进 DMS(1889)。问题存**真实提交文本**(answerText),不存界面上那句
+    // 「已提交企业信息补充」——否则从 DMS 读回的历史会失真。1887 会话行的写入同 sendMessage。
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: answerText });
 
     void coordinator.generateAnswer(answerText);
@@ -1846,10 +1797,6 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   provide('submitQuestionAnswers', submitQuestionAnswers);
 
-  // [已移除] 会话级卡片聚合 provide('collectSessionPolicyCards', …)
-  // 「更多」侧边栏已改为**只列本条回答里的政策**(用户 2026-09-18 调整),
-  // PolicyMatch 直接用本组件自己的卡片,不再需要往上取会话级聚合。
-
   return {
     suggestions,
     chatHistory,
@@ -1863,10 +1810,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     isGenerating,
     /** **当前会话**是否在生成 —— 发送/停止按钮的语义由它决定 */
     currentSessionGenerating,
-    /**
-     * 弹一条提示。**壳组件(含上传校验)统一用它**,不要直接改 toastMessage ——
-     * 这里处理了"同一句文案要能再次弹出"(先清空再 nextTick 置回)。
-     */
+    /** 弹一条提示:壳组件(含上传校验)统一用它 */
     showToast,
     isDeepThink,
     isChatting,

+ 12 - 29
src/components/business-assistant/useBusinessAssistantUpload.ts

@@ -17,10 +17,7 @@ const DRAFT_HOME_KEY = '__business_assistant_home_draft__';
 export function useBusinessAssistantUpload(options?: {
   imageInputRef?: Ref<HTMLInputElement | null>;
   fileInputRef?: Ref<HTMLInputElement | null>;
-  /**
-   * 校验失败时的提示(**用界面的 toast,不要 window.alert** —— 用户 2026-09-23 要求)。
-   * 不传时退回 console.warn(验证脚本等无界面场景)。
-   */
+  /** 校验失败时的提示(用界面的 toast,不要 window.alert);不传时退回 console.warn */
   notify?: (message: string) => void;
 }) {
   const notify = (message: string) => {
@@ -28,7 +25,7 @@ export function useBusinessAssistantUpload(options?: {
       options.notify(message);
       return;
     }
-    console.warn('[upload]', message);
+    /* console.warn('[upload]', message); */
   };
 
   const uploadedFiles = ref<BusinessAssistantUploadedFile[]>([]);
@@ -45,13 +42,8 @@ export function useBusinessAssistantUpload(options?: {
   const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || '';
 
   /**
-   * 图片是否受支持 —— **按扩展名**判(2026-09-23 修)。
-   *
-   * ⚠️ 原来写的是 `file.type.startsWith('image/') || 扩展名在白名单里`,等于
-   * **任何 `image/*` 都放行**:SVG / BMP / TIFF / HEIC 全被当图片收下,
-   * 与白名单和给用户的提示(JPEG、PNG、GIF、WebP)自相矛盾。
-   * 现在**只有白名单里的扩展名算数**;文件名没有扩展名时(例如某些粘贴来的图)
-   * 才退回看 MIME,仍需落在白名单里(`image/jpeg` → `jpeg`)。
+   * 图片是否受支持 —— **按扩展名**判:只有白名单里的扩展名算数(`image/*` 不能全放行);
+   * 文件名没有扩展名时(例如粘贴来的图)才退回看 MIME,仍需落在白名单里。
    */
   const isSupportedImageFile = (file: File) => {
     const extension = getFileExtension(file.name);
@@ -128,24 +120,19 @@ export function useBusinessAssistantUpload(options?: {
     updateUploadedFile(uploadItem.id, { abort: abortSignature });
 
     try {
-      // 2026-09-23 起改用 **DMS 的文件服务**(用户指定):返回的是**相对路径**,
-      // 它随聊天请求体的 `pictures` / `files` 发给后端(见 file-upload.ts 的实测契约)。
-      // (更早那版是直传厂商 OSS,实现已在 2026-09-24 按用户要求删除,见 git 历史。)
+      // 返回的是**相对路径**,它随聊天请求体的 `pictures` / `files` 发给后端(见 file-upload.ts)
       const relativePath = await uploadDmsAttachment(file, {
         isImage: Boolean(uploadItem.isImage),
         signal: abortController.signal,
       });
       const currentFile = uploadedFiles.value.find((item) => item.id === uploadItem.id);
       const localPreviewUrl = currentFile?.url;
-      // 展示地址:相对路径直接打不开,要拼上代理前缀(实测 `/dms` + 相对路径 = 200)。
-      // 图片才需要;非图片在气泡里显示成文件条,用不上 url。
+      // 展示地址:相对路径打不开,要拼上代理前缀;非图片在气泡里显示成文件条,用不上 url
       const displayUrl = currentFile?.isImage ? buildDmsFileDisplayUrl(relativePath) : undefined;
       updateUploadedFile(uploadItem.id, {
         status: 'success',
         progress: 100,
-        // ⚠️ 两个字段分工不同,别混:
-        //   fileUrl —— **相对路径**,随请求体 pictures / files 发给后端
-        //   url     —— **可显示地址**(拼了代理前缀),只给预览用
+        // ⚠️ 两个字段分工不同,别混:fileUrl 是给后端的相对路径,url 是给预览用的可显示地址
         fileUrl: relativePath,
         url: displayUrl,
         abort: undefined,
@@ -182,7 +169,7 @@ export function useBusinessAssistantUpload(options?: {
       const isSupported = isImage ? isSupportedImageFile(file) : isSupportedAttachmentFile(file);
 
       if (!isSupported) {
-        // 不支持的格式:**提示 + 不加入输入框**(这里 return 就是不加)
+        // 不支持的格式:提示并跳过(不加入列表)
         notify(
           isImage
             ? `「${file.name}」格式不支持,图片请用 ${SUPPORTED_IMAGE_LABEL}`
@@ -242,14 +229,10 @@ export function useBusinessAssistantUpload(options?: {
     const successfulFiles = uploadedFiles.value.filter((file) => file.status === 'success' && file.fileUrl);
     const successfulIds = new Set(successfulFiles.map((file) => file.id));
     const files = successfulFiles.map((file) => file.fileUrl as string);
-    // ⚠️ 两个字段分工不同(2026-09-23 换 DMS 后尤其明显),**别混**:
-    //   fileUrl —— 相对路径,随请求体 `pictures` / `files` 发给后端(toChatAttachmentUrls 取它)
-    //   url     —— 可显示地址,气泡里的 <img src> 取它
-    // 这里曾经只填 `url: file.fileUrl`(那时 fileUrl 还是 OSS 绝对地址,能直接当 src)。
-    // 换成 DMS 相对路径后那一行就成了 bug:气泡里的图**不显示**(相对路径经 dev server
-    // 返回的是 index.html,<img> 解不出来)。用户 2026-09-23 报「提交之后看不到图片了」。
-    // 显示地址在这里**按相对路径重新拼**(不沿用上传时的 file.url):那条路径可能还是
-    // 本地 blob(已被 revoke)或压根没来得及写,重拼一次才是确定的。
+    // ⚠️ 两个字段分工不同,别混:fileUrl 是随请求体 `pictures` / `files` 发给后端的相对路径,
+    // url 是气泡里 <img src> 用的可显示地址。
+    // 显示地址在这里**按相对路径重新拼**(不沿用上传时的 file.url):那条可能还是本地 blob
+    // (已被 revoke)或压根没来得及写,重拼一次才是确定的。
     const attachments = successfulFiles.map((file) => ({
       id: file.id,
       name: file.name,

+ 4 - 7
src/components/useEnterpriseAuth.ts

@@ -66,9 +66,7 @@ export function useEnterpriseAuth() {
     globalThis.source = globalThis.policyMode || "zhaoshang";
 
     // 1. access_token 存在: 换取 credit_code + authToken, 再拉企业信息
-    // [已移除] 开发环境的硬编码账号自动登录:它会让「没登录也进入登录态」,
-    //          与真实用户路径不符(访客记录就验证不了)。要企业态请显式带
-    //          ?access_token=<token> 或 ?credit_code=<代码>。
+    // (要企业态就显式带 ?access_token=<token> 或 ?credit_code=<代码>)
     if (accessToken && !creditCodeParam) {
       authLoading.value = true;
       authError.value = false;
@@ -132,10 +130,9 @@ export function useEnterpriseAuth() {
   };
 
   const redirectToLogin = () => {
-    const isProduction = import.meta.env.MODE === 'production';
-    const currentUrl = isProduction
-      ? `https://aixq.shqp.gov.cn/zhaoshang/redirect.html#zhaoshang?mode=${globalThis.policyMode}`
-      : `https://aixq.shqp.gov.cn/zhaoshang/#/?mode=zhaoshang`;
+    // 回跳到本页;一网通办 redirect_uri 白名单后续自行对接
+    const mode = globalThis.policyMode || "zhaoshang";
+    const currentUrl = `${window.location.origin}${window.location.pathname}#/?mode=${mode}`;
     const sourceLink = `https://zwdtuser.sh.gov.cn/uc/login/login.jsp?sendPost=false&type=2&redirect_uri=${encodeURIComponent(currentUrl)}`;
     window.location.href = sourceLink;
   };

+ 2 - 2
src/components/utils.ts

@@ -92,7 +92,7 @@ export const resolveAllMediaResource = (
       try {
         return JSON.parse(formattedData);
       } catch (error) {
-        console.error("JSON 解析失败:", error);
+        /* console.error("JSON 解析失败:", error); */
         return {};
       }
     });
@@ -107,7 +107,7 @@ export const resolveAllMediaResource = (
       try {
         return JSON.parse(formattedData);
       } catch (error) {
-        console.error("JSON 解析失败:", error);
+        /* console.error("JSON 解析失败:", error); */
         return {};
       }
     });

+ 4 - 0
src/main.ts

@@ -5,12 +5,16 @@ import "ant-design-vue/dist/reset.css";
 
 import App from "./App.vue";
 import { ensureClientIp } from "@/utils/client-ip";
+import { ensurePolicyLibrary } from "@/utils/policy-library-loader";
 
 // 预热本机 IP(DMS 访客归属要用:`访客_<IP>`)。
 // 这里只是**发起**、不等待:写 DMS 的地方自己会 await(内部有缓存 + 并发去重),
 // 先起一次能让第一笔 DMS 写入不用现等;失败也只是退回随机 id,不影响任何功能。
 void ensureClientIp();
 
+// 惠企政策库:只读 —— config.policyRemotePath → 取不到再本地兜底(上传用 upload-policy-library.mjs)。
+void ensurePolicyLibrary();
+
 const IS_IOS =
   typeof navigator !== "undefined" &&
   (/iPad|iPhone iPod/.test(navigator.userAgent || "") ||

+ 4 - 7
src/network/api/chat-sessions.ts

@@ -3,9 +3,6 @@
  *
  * ⚠️ **内部实现已切到 DMS**(栏目 1887 助手会话 / 1889 助手问答记录),
  * 对外函数签名与返回类型保持不变,调用方(PC / Mobile / useBusinessAssistantChat)无需改动。
- * DMS 侧的实现与踩坑见 `./dms/chat-sessions-dms.ts` 与 `./dms/client.ts` 的注释。
- *
- * 原来的实现打的是 `{VITE_API}/chat/sessions` 等旧接口,已整段替换(可查 git 历史)。
  */
 
 import {
@@ -30,7 +27,7 @@ export interface RemoteSessionRecord {
   /**
    * 该条问答的记录 id(= 写入 DMS 时的 `c_record_id`)。
    * 前端用它作为 AI 消息的 id,这样从 DMS 读回的历史消息在点赞/点踩时
-   * 仍能命中 DMS 里的原始行(反馈闭环)。
+   * 仍能命中 DMS 里的原始行。
    */
   record_id?: string;
 }
@@ -52,7 +49,7 @@ export async function fetchRemoteSessions(
   try {
     return await fetchDmsSessions(creditCode);
   } catch (error) {
-    console.warn("fetch remote sessions error:", error);
+    /* console.warn("fetch remote sessions error:", error); */
     return [];
   }
 }
@@ -68,7 +65,7 @@ export async function fetchRemoteSessionRecords(
   try {
     return await fetchDmsSessionRecords(sessionId);
   } catch (error) {
-    console.warn("fetch remote session records error:", error);
+    /* console.warn("fetch remote session records error:", error); */
     return [];
   }
 }
@@ -81,7 +78,7 @@ export async function deleteSessionInfo(
   try {
     return await deleteDmsSession(sessionId);
   } catch (error) {
-    console.warn("delete session info error:", error);
+    /* console.warn("delete session info error:", error); */
     return false;
   }
 }

+ 12 - 15
src/network/api/company-classification.ts

@@ -1,15 +1,12 @@
 /**
- * 公司多标签分类接口(F062):`POST /api/company/classify`。
+ * 公司多标签分类接口:`POST /api/company/classify`。
  *
- * 契约见 `harness/docs/reference/company-classification.md`:
- * - 请求体**就是** `/api/chat` 的 `result` 事件里的 `data.company_info` 整个对象,
- *   不包外层、不传 thread_id
+ * - 请求体**就是** `/api/chat` 的 `result` 事件里的 `data.company_info` 整个对象,不包外层、不传 thread_id
  * - 返回普通 JSON(不是 SSE);六个角度见 `CLASSIFY_ANGLE_KEYS`
- * - 它是**聊天后端**的接口(与 `/api/chat` 同一个服务),所以 base URL 用
- *   `getChatApiBaseUrl()`(dev 经 `/chat-api` 代理;生产与聊天同一条路)
+ * - 它是**聊天后端**的接口,所以 base URL 用 `getChatApiBaseUrl()`
  *
- * 与 DMS 客户端同一约定:**不抛异常**,失败 `console.warn` 后返回 null,
- * 调用方(对话完成后的同步)是 fire-and-forget,分类失败不能影响聊天。
+ * 与 DMS 客户端同一约定:**不抛异常**,失败 `console.warn` 后返回 null
+ * (调用方是 fire-and-forget,分类失败不能影响聊天)。
  */
 
 import { getChatApiBaseUrl } from "@/utils/runtime-config";
@@ -20,7 +17,7 @@ export const CLASSIFY_ENDPOINT_PATH = "/api/company/classify";
 const CHAT_ENDPOINT_SUFFIX = "/api/chat";
 
 /**
- * 拼分类接口地址。`VITE_CHAT_API` 允许两种写法(见 `resolveChatEndpoint`):
+ * 拼分类接口地址。`VITE_CHAT_API` 允许两种写法:
  * - 服务前缀:`http://192.168.2.23:8000` 或 `/chat-api` → 追加 `/api/company/classify`
  * - 完整聊天接口:`.../api/chat` → 先剥掉再追加
  */
@@ -46,7 +43,7 @@ export const classifyCompany = async (
 ): Promise<CompanyClassification | null> => {
   const endpoint = resolveClassifyEndpoint(getChatApiBaseUrl());
   if (!endpoint) {
-    console.warn("[classify] 未配置 VITE_CHAT_API,跳过分类请求");
+    /* console.warn("[classify] 未配置 VITE_CHAT_API,跳过分类请求"); */
     return null;
   }
 
@@ -58,22 +55,22 @@ export const classifyCompany = async (
     });
 
     if (!response.ok) {
-      // 后端在错误时可能回 {"error":"..."},带上便于排查(422 的语义见接口文档)
+      // 后端在错误时可能回 {"error":"..."},带上便于排查
       const detail = await response.text().catch(() => "");
-      console.warn(
+      /* console.warn(
         `[classify] 分类接口返回 ${response.status}${detail ? `:${detail.slice(0, 200)}` : ""}`
-      );
+      ); */
       return null;
     }
 
     const data = (await response.json()) as CompanyClassification;
     if (!data || typeof data !== "object") {
-      console.warn("[classify] 分类接口返回的不是对象,已忽略");
+      /* console.warn("[classify] 分类接口返回的不是对象,已忽略"); */
       return null;
     }
     return data;
   } catch (err) {
-    console.warn("[classify] 分类请求失败", err);
+    /* console.warn("[classify] 分类请求失败", err); */
     return null;
   }
 };

+ 56 - 141
src/network/api/dms/chat-sessions-dms.ts

@@ -1,19 +1,11 @@
 /**
  * 会话与问答记录在 DMS 里的读写(栏目 1887 助手会话 / 1889 助手问答记录)。
  *
- * 设计要点:
- *
- * - **会话标识用前端的 `session.id`**(UUID)。它在 `sendMessage` 里恒等于聊天协议的
- *   `thread_id`、且跨刷新稳定(存 localStorage),所以拿它当 `c_session_id` 能与协议对账。
- * - **问答记录标识用 AI 消息 id**(`BusinessAssistantMessage.id`)。它同时也是
- *   反馈组件 `BusinessRecord` 拿到的 `record.id`,所以三条链路(写入/读回/反馈)用同一个值,
- *   反馈能命中原来那一行。
- * - **upsert**:先按幂等键精确搜(1887→`c_session_id`,1889→`c_record_id`),
- *   命中就 `updateContent`,没命中就 `addContent`。DMS 没有唯一约束,只能这么来。
- * - **顺序链**:同一幂等键的写操作串行排队。「提交时写 question」与「回答完成补 answer」
- *   是两次独立调用,不排队的话第二次的反查可能先于第一次的写入,导致同一问答出现两行。
- * - **访客也能写**:未登录时 `c_credit_code` 用 `访客_<埋点访客id>`,
- *   埋点访客 id 存在 localStorage,同一浏览器跨刷新稳定。
+ * - 会话标识用前端的 `session.id`(恒等于聊天协议的 `thread_id`);问答记录标识用 AI 消息 id
+ *   (同时也是反馈组件拿到的 `record.id`,写入/读回/反馈三条链路共用同一个值)。
+ * - upsert:先按幂等键精确搜(1887→`c_session_id`,1889→`c_record_id`),命中就更新、否则新增
+ *   —— DMS 没有唯一约束。
+ * - 同一幂等键的写操作串行排队:「写 question」与「补 answer」是两次独立调用,不排队会写成两行。
  *
  * ⚠️ 全部 fire-and-forget:失败只 warn,不抛异常、不阻塞聊天。
  */
@@ -37,11 +29,7 @@ import type { RemoteSession, RemoteSessionRecord } from "@/network/api/chat-sess
 
 /**
  * 访客标识(未登录时 DMS 记录的归属:`访客_<这个 id>`)。
- *
- * ⚠️ **localStorage 的 key 字符串不要改**:它沿用统计埋点时代的名字
- * (`assistant_statistics_visitor_id`)。改了会让已存在的访客全部换新 id,
- * 同一浏览器在 DMS 里就变成两个访客了。统计埋点本身已删除(2026-09-18),
- * 但**这个 key 保留**、由 DMS 接手使用。
+ * ⚠️ localStorage 的 key 字符串不要改:改了已存在的访客会全部换新 id。
  */
 export const GUEST_VISITOR_ID_KEY = "assistant_statistics_visitor_id";
 
@@ -70,30 +58,15 @@ export const getOrCreateAssistantVisitorId = (idFactory = defaultVisitorIdFactor
 /** 本轮的来源标识,与其它接口保持一致 */
 const resolveSource = (): string => ((globalThis as any).source as string) || "zhaoshang";
 
-/**
- * 记录归属的「统一社会信用代码」。
- * 已登录 → 企业的 credit_code;未登录(访客)→ 访客标识(**`访客_<本机 IP>`**,见下)。
- */
+/** 记录归属的「统一社会信用代码」:已登录 → 企业代码;未登录 → 访客标识(`访客_<本机 IP>`) */
 export const resolveDmsCreditCode = (): string =>
   enterpriseInfo.value?.credit_code || resolveVisitorLabel();
 
 /**
- * 1887 助手会话的**必填运营字段** —— 只能传空值/默认值。
+ * 1887 助手会话的**必填运营字段** `summary` / `qpyszx` / `qyzt` / `rzqpyx`(`must=true`,四个全带才写得进)。
  *
- * 背景(2026-09-18 实测二分):
- * DMS 的 1887 模型把 `summary` / `qpyszx` / `qyzt` / `rzqpyx` 设为 **`must=true`**,
- * **四个全带才写得进去**,少任何一个都会被 `214 数据错误` 拒绝:
- *
- * | 载荷 | 结果 |
- * |---|---|
- * | 全不带 / +summary / +summary+qpyszx / +…+qyzt | 都 214 |
- * | **四个都带** | **200** |
- *
- * ⚠️ 这里**故意传空值**(用户拍板「先传空值/默认值」):它们是运营分析口径,
- * 取值由业务方决定。**不要顺手填业务判断**(比如按登录态推 `qyzt`、拿首问当 `summary`)——
- * 那会把「没数据」变成「有数据但可能是错的」,比空值更糟。等口径定了再改这里。
- *
- * ⚠️ **只在新增(addContent)时带**:更新时不带 —— 否则会把后端将来回填的值冲成空。
+ * ⚠️ 故意传空值:它们是运营分析口径,取值由业务方决定,不要顺手填业务判断。
+ * ⚠️ **只在新增(addContent)时带**:更新时不带,否则会把后端回填的值冲成空。
  */
 const buildRequiredSessionFields = (): DmsRow => ({
   summary: "",
@@ -102,7 +75,7 @@ const buildRequiredSessionFields = (): DmsRow => ({
   rzqpyx: false,
 });
 
-/** 写 DMS 的时间格式(Step-0 实测:`YYYY-MM-DD HH:mm:ss` 被接受) */
+/** 写 DMS 的时间格式:`YYYY-MM-DD HH:mm:ss` */
 export const nowDmsTimestamp = (): string => {
   const d = new Date();
   const p = (n: number) => String(n).padStart(2, "0");
@@ -111,16 +84,13 @@ export const nowDmsTimestamp = (): string => {
   )}:${p(d.getSeconds())}`;
 };
 
-/**
- * DMS 的时间戳解析成 epoch 毫秒。
- * Step-0 实测:读回来是**毫秒数字**;这里额外兼容秒数与字符串,避免格式变了就崩。
- */
+/** DMS 时间戳 → epoch 毫秒(读回来是毫秒数;这里额外兼容秒数与字符串) */
 export const parseDmsTimestamp = (value: unknown): number => {
   if (typeof value === "number") return value < 1e12 ? value * 1000 : value;
   if (typeof value === "string" && value) {
     if (/^\d{13}$/.test(value)) return Number(value);
     if (/^\d{10}$/.test(value)) return Number(value) * 1000;
-    // "2026-09-17 21:30:00" 按东八区解析(DMS 服务器在国内)
+    // 字符串形态按东八区解析(DMS 服务器在国内)
     const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
       ? value.replace(" ", "T") + "+08:00"
       : value;
@@ -130,24 +100,13 @@ export const parseDmsTimestamp = (value: unknown): number => {
   return Date.now();
 };
 
-// `enqueueWrite` / `findRowBy` 两个工具原先只写在本文件里,
-// 2026-09-18 企业信息同步也要用(且「不要加 orderBy」那条坑不想再踩一遍),
-// 已上移到 ./client 共用,名字加 Dms 前缀:`enqueueDmsWrite` / `findDmsRowBy`。
-
 /**
- * 访客标识 —— **`访客_<本机 IP>`**(用户 2026-09-22 要求:游客用「访客」拼 IP 来判定)。
+ * 访客标识 —— `访客_<本机 IP>`(IP 由 `ensureClientIp()` 取得)。
  *
- * 为什么用 IP:`访客_<浏览器随机 id>` 换个浏览器/清掉 localStorage 就换人了;
- * 用 IP 至少同一网络出口下能认成同一个访客。IP 由 `ensureClientIp()` 取得
- * (见 `src/utils/client-ip.ts`,走同源代理)。
+ * ⚠️ 取不到 IP 时退回随机 id:绝不能让"拿不到 IP"变成"写不进 DMS"。
+ * ⚠️ 同一出口 IP(公司/学校 NAT)下的不同人会被认成同一个访客。
  *
- * ⚠️ **取不到 IP 时退回随机 id**(`访客_<uuid>`):绝不能让"拿不到 IP"变成"写不进 DMS"。
- * ⚠️ 用 IP 的代价:同一个出口 IP(公司/学校 NAT)下的**不同人会被认成同一个访客** ——
- *    对 DMS 的归属统计来说这是可接受的取舍,但排查时要知道这一点。
- *
- * 它出现在两处:
- * - **1889 的 `c_credit_code`**(那个栏目没有 `c_ip` 列,归属只能放这里)
- * - 1887 的 `c_ip` 放的是**真实 IP**(不是这个标签),见 `upsertDmsSession`
+ * 用在 1889 的 `c_credit_code`(该栏目没有 `c_ip` 列);1887 的 `c_ip` 放的是真实 IP。
  */
 export const resolveVisitorLabel = (): string => {
   const ip = getCachedClientIp();
@@ -163,9 +122,7 @@ const pickText = (value: unknown): string => (typeof value === "string" ? value.
 
 /**
  * 从对话带回来的 `company_info` 里取出 1887 用得上的三项。
- *
- * 取值路径与 1888 的同步一致:优先 `profile.data.*`(企查查),回退 `company.*`。
- * 取不到就是空串(调用方据此决定不写该字段)。
+ * 优先 `profile.data.*`(企查查),回退 `company.*`;取不到就是空串。
  */
 export const readSessionCompanyFields = (
   companyInfo: unknown
@@ -190,24 +147,15 @@ const isSameRowValue = (a: unknown, b: unknown): boolean =>
 /**
  * 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
  *
- * 建行策略(用户 2026-09-23 定):**只要对话就在 1887 建行** —— 未登录、本轮也没带回
- * 企业资料时同样建,行里就放 IP / 登录状态 / 时间戳 / 标题与四个必填运营位。
- * (2026-09-18 那版是「访客且无企业信息就不建行」,2026-09-23 由用户推翻。)
- *
- * 写哪些、谁能覆盖谁(用户 2026-09-22 定):
- * - **`c_credit_code` 只放真实信用代码**:登录 → 企业代码;未登录 → 对话带出的公司代码;
- *   两者都没有就**留空**(用户 2026-09-23 确认:「留空」而不是写访客标签)。
- *   访客身份(`访客_<IP>`)落在 1889 的 `c_credit_code` 上,不进这里。
- * - **`c_ip` 放本机 IP**(用户 2026-09-22:IP 存在 c_ip)——只填空,拿不到 IP 就不写。
- * - **登录态的值优先级最高**:`c_ent_name` / `c_credit_code` / `c_is_login="1"` 无条件写(覆盖)。
- * - **对话带出的 `company_info`**(`c_ent_name` / `c_credit_code` / `c_ent_image_url`):
- *   与库里**值不同就更新**;但**已被登录态认领过的行**(`c_is_login === "1"`)**不许被它改**
- *   —— 否则退登后再问到别的公司,会把这行改到别家公司名下、而 `c_is_login` 还是 `"1"`。
- * - **`c_is_login` 只在为空时写**:空值形态说明这一行还没被登录态认领过。
- * - **`c_title` 不写**(用户要求:标题由运营侧生成);DMS 的系统字段 `title` 只在**创建那一刻**
- *   带上会话标题 —— 不写会落成字符串 `"null"`(1889 踩过)。
- * - 必填运营位(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)**只在创建时带空值**:
- *   它们是运营分析系统回填的(实测库里那几行是分析产出的 JSON),更新时绝不能碰。
+ * **只要对话就建行**(未登录、没带回企业资料时也建)。字段取舍:
+ * - `c_credit_code` 只放真实信用代码(登录态或对话带出的),都没有就**留空**
+ * - `c_ip` 放本机 IP,只填空、拿不到就不写;访客身份(`访客_<IP>`)落在 1889
+ * - 登录态的值优先级最高,无条件覆盖
+ * - 对话带出的 `company_info` 与库里值不同就更新,但**已被登录态认领的行**
+ *   (`c_is_login === "1"`)不许被它改(否则退登后问到别的公司会串到别家名下)
+ * - `c_is_login` 只在为空时写;`c_title` 不写
+ * - 系统字段 `title` 只在创建那一刻带(不带会落成字符串 `"null"`)
+ * - 必填运营位只在创建时带空值(见 `buildRequiredSessionFields`)
  */
 export const upsertDmsSession = (params: {
   sessionId: string;
@@ -219,8 +167,7 @@ export const upsertDmsSession = (params: {
   companyInfo?: unknown;
 }): Promise<boolean> =>
   enqueueDmsWrite(`session:${params.sessionId}`, async () => {
-    // 先确保拿到本机 IP:下面的 c_ip 与访客归属都要用它。
-    // 内部有缓存 + 并发去重,拿不到也不抛 —— 拿不到时访客归属会退回随机 id。
+    // 先确保拿到本机 IP:下面的 c_ip 与访客归属都要用它
     await ensureClientIp();
     const logged = enterpriseInfo.value;
     const fromChat = readSessionCompanyFields(params.companyInfo);
@@ -242,9 +189,7 @@ export const upsertDmsSession = (params: {
     /** 只在字段为空时才写的值 */
     const clientIp = getCachedClientIp();
     const fillIfEmpty: DmsRow = {};
-    // c_ip 放**本机 IP**(用户 2026-09-22:IP 存在 c_ip)。
-    // 只填空:已有值可能是后端回填的、或这台机器上次的出口 IP。
-    // 拿不到 IP 就**不写**(宁可空着,也不要拿访客标签冒充 IP)。
+    // c_ip 放本机 IP:只填空(已有值可能是后端回填的);拿不到就不写
     if (clientIp) fillIfEmpty.c_ip = clientIp;
     if (!logged) {
       fillIfEmpty.c_is_login = "0";
@@ -263,12 +208,9 @@ export const upsertDmsSession = (params: {
     const existing = await findDmsRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
 
     /**
-     * 这一行的**公司身份由谁决定**?
-     * - 已被登录态认领过的行(库里 `c_is_login === "1"`)→ 属于那家企业,
-     *   **对话值不许改它的公司字段**。否则:用户登录过、之后退登又问到别的公司,
-     *   这行的 `c_credit_code` 会被改成那家公司,而 `c_is_login` 还是 `"1"`
-     *   —— 既串到了别的公司名下,又会被读侧当成"登录行"返回出去。
-     * - 否则(纯访客行)→ 公司字段跟着对话走(值不同就更新,用户 2026-09-22 要求)。
+     * 已被登录态认领过的行(`c_is_login === "1"`)**不许被对话值改公司字段** ——
+     * 否则退登后再问到别的公司,会把这行改到别家名下、且读侧仍当它是"登录行"。
+     * 纯访客行则跟着对话走(值不同就更新)。
      */
     const claimedByLogin = String(existing?.c_is_login ?? "").trim() === "1";
     const chatOwnsCompany = !logged && !claimedByLogin;
@@ -277,34 +219,26 @@ export const upsertDmsSession = (params: {
       chatOwnsCompany ||
       (!!logged && !!fromChat.creditCode && fromChat.creditCode === logged.credit_code);
     if (!existing) {
-      // **只要对话就建行**(用户 2026-09-23 决定,推翻 2026-09-18 那条
-      // 「只传具体的会话记录(1889),另一个栏目不传了」)。
-      //
-      // 未登录且本轮没带回企业资料时,这一行仍然建,写进去的就是「可填的那些」:
-      // `c_ip`(真实 IP,拿不到则不写)、`c_is_login="0"`、时间戳、系统 `title`,
-      // 以及四个必填运营位(`summary` 空串 + 三个布尔 false,见 buildRequiredSessionFields)。
-      // **`c_credit_code` 留空**(用户 2026-09-23 明确)—— 这一列只放真实信用代码,
-      // 访客身份靠 `c_ip` 认,不往这列混非代码值。
-      // 企业名称/图片同理留空(没有就不带这两列)。
+      // 未登录且本轮没带回企业资料时也建行,写进去的是「可填的那些」:fillIfEmpty +
+      // 时间戳 + 系统 title + 四个必填运营位;c_credit_code 与企业名称/图片留空。
       const content: DmsRow = {
         ...base,
         ...fillIfEmpty,
         ...fromChatFields,
         ...authoritative,
         c_created_at: nowDmsTimestamp(),
-        // 系统字段 title:**创建时必须带**(不带 DMS 会落成字符串 "null",1889 踩过)。
-        // 兜底成「未命名会话」,与旧的 upsert 行为一致。
+        // 系统字段 title:**创建时必须带**(不带 DMS 会落成字符串 "null")
         title: params.sessionTitle || "未命名会话",
         ...buildRequiredSessionFields(),
       };
       const id = await addDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, content);
-      if (!id) console.warn("[dms] 会话写入失败", params.sessionId);
+      if (!id) { /* console.warn("[dms] 会话写入失败", params.sessionId); */ }
       return !!id;
     }
 
     const id = existing.id as string | undefined;
     if (!id) {
-      console.warn("[dms] 会话行缺少 uuid,无法更新", existing);
+      /* console.warn("[dms] 会话行缺少 uuid,无法更新", existing); */
       return false;
     }
 
@@ -313,7 +247,7 @@ export const upsertDmsSession = (params: {
       const writable = field === "c_ent_image_url" ? chatOwnsImage : chatOwnsCompany;
       if (!writable) continue;
       if (!isSameRowValue(existing[field], value)) {
-        patch[field] = value; // 值不同就更新(用户 2026-09-22 修订)
+        patch[field] = value; // 值不同就更新
       }
     }
     for (const [field, value] of Object.entries(fillIfEmpty)) {
@@ -325,12 +259,9 @@ export const upsertDmsSession = (params: {
   });
 
 /**
- * 附件路径列表 → DMS 文本列的值(`c_image` / `c_file`)。
- *
- * 这两列是 2026-09-23 用户新建的(模型 2038,`frontType: text`),存**相对路径**
- * (与请求体 `pictures` / `files` 里发给后端的是同一个值,便于对账)。
- * 多个用**逗号**分隔 —— 路径里不含逗号(DMS 生成的文件名是 uuid),所以是安全的。
- * 空值/空串/纯空白一律过滤,全空时返回空串(调用方据此决定写不写这一列)。
+ * 附件路径列表 → DMS 文本列的值(`c_image` / `c_file`):存**相对路径**
+ * (与请求体 `pictures` / `files` 里发给后端的是同一个值),多个用逗号分隔,
+ * 空值一律过滤,全空时返回空串(调用方据此决定写不写这一列)。
  */
 const joinDmsPathList = (list?: string[]): string =>
   (Array.isArray(list) ? list : [])
@@ -339,16 +270,10 @@ const joinDmsPathList = (list?: string[]): string =>
     .join(",");
 
 /**
- * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)—— **一问一答一条**。
- *
- * 提交时先写 question(回答还没出来),回答完成/中断时再补 answer
- * (见 useBusinessAssistantChat 的三个挂钩点)。
+ * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)—— **一问一答一条**:
+ * 提交时先写 question,回答完成/中断时再补 answer。
  *
  * ⚠️ 1889 **没有 `c_updated_at` 字段**(只有 `c_created_at`),别写它。
- *
- * 历史:曾一度改成「一个会话一行、整段对话 JSON 塞进 c_answer」,
- * 2026-09-17 按用户要求**改回本版(一问一答一条)**。改回的理由是那样更贴近
- * 库表原本的语义(chat_record 就是一行一轮问答),也便于按轮次检索/统计。
  */
 export const upsertDmsRecord = (params: {
   recordId: string;
@@ -356,12 +281,9 @@ export const upsertDmsRecord = (params: {
   creditCode?: string;
   question?: string;
   answer?: string;
-  /**
-   * 本轮附件路径(**相对路径**,与请求体 `pictures` 里是同一个值)。
-   * 多张用逗号分隔后写进 `c_image`(见 `joinDmsPathList`)。
-   */
+  /** 本轮图片附件路径(**相对路径**,与请求体 `pictures` 里是同一个值),逗号分隔后写进 `c_image` */
   images?: string[];
-  /** 本轮非图片附件路径,写进 `c_file`。口径同上。 */
+  /** 本轮非图片附件路径,写进 `c_file`。口径同上 */
   files?: string[];
 }): Promise<boolean> =>
   enqueueDmsWrite(`record:${params.recordId}`, async () => {
@@ -379,27 +301,24 @@ export const upsertDmsRecord = (params: {
       const content: DmsRow = { ...base };
       if (params.question !== undefined) {
         content.c_question = params.question;
-        // 系统字段 title 用问题文本:不写的话库里是字符串 "null",在 DMS 列表里
-        // 完全看不出这一条是哪一轮(用户要求)
+        // 系统字段 title 用问题文本:不写的话库里是字符串 "null",列表里看不出是哪一轮
         content.title = params.question;
       }
       if (params.answer !== undefined) content.c_answer = params.answer;
-      // 附件路径(用户 2026-09-23 要求:图片进 c_image、文件进 c_file)。
-      // 没有附件就**不写这两列** —— 与「没附件时请求体不多发字段」同一口径,
-      // 也让「后端/库表若回滚」只影响带附件的那几轮。
+      // 附件路径:图片进 c_image、文件进 c_file;没有附件就不写这两列
       const images = joinDmsPathList(params.images);
       const files = joinDmsPathList(params.files);
       if (images) content.c_image = images;
       if (files) content.c_file = files;
       content.c_created_at = nowDmsTimestamp();
       const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
-      if (!id) console.warn("[dms] 问答记录写入失败", params.recordId);
+      if (!id) { /* console.warn("[dms] 问答记录写入失败", params.recordId); */ }
       return !!id;
     }
 
     const id = existing.id as string | undefined;
     if (!id) {
-      console.warn("[dms] 问答记录行缺少 uuid,无法更新", existing);
+      /* console.warn("[dms] 问答记录行缺少 uuid,无法更新", existing); */
       return false;
     }
     const patch: DmsRow = {};
@@ -407,10 +326,9 @@ export const upsertDmsRecord = (params: {
     if (params.question !== undefined && !existing.c_question) patch.c_question = params.question;
     if (params.answer !== undefined) patch.c_answer = params.answer;
     // 附件路径同理:只在**有值且这一列还空着**时才进 patch。
-    // ⚠️ 这里必须克制 —— `updateContent` 遇到「模型里不存在的列」会**静默丢弃整个 patch**
-    // (返回仍是 code 200「成功」,见 docs/reference/DMS_API.md 的写入陷阱)。
-    // 把 c_image / c_file 只在真有附件时才捎上,最坏情况也只影响带附件的轮次,
-    // 不会连累补 answer 这条主链路。
+    // ⚠️ 必须克制 —— `updateContent` 遇到「模型里不存在的列」会**静默丢弃整个 patch**
+    // (返回仍是 code 200),所以 c_image / c_file 只在真有附件时才捎上,
+    // 最坏也只影响带附件的轮次,不连累补 answer 这条主链路。
     const images = joinDmsPathList(params.images);
     const files = joinDmsPathList(params.files);
     if (images && !existing.c_image) patch.c_image = images;
@@ -429,12 +347,9 @@ export const fetchDmsSessions = async (creditCode: string): Promise<RemoteSessio
   });
 
   return rows
-    // 🚨 **排除「明确是访客」的行**(`c_is_login === "0"`)——
-    // 2026-09-22 起访客行也带**真实公司代码**(对话里提到的那家公司),
-    // 不加这道过滤,那家公司登录后就会在自己的会话列表里看到陌生访客的对话
-    // (用户 2026-09-22 明确要求挡住)。改造前访客行的代码是 `访客_<id>`、匹配不上,
-    // 所以这里只是**保持与改造前一致的可见范围**。
-    // ⚠️ 只排除「显式为访客」的行:`c_is_login` 缺失的历史行照旧返回,不缩小既有范围。
+    // 排除「明确是访客」的行(`c_is_login === "0"`):访客行也可能带真实公司代码,
+    // 不过滤的话那家公司登录后会看到陌生访客的对话。
+    // ⚠️ 只排除显式访客:`c_is_login` 缺失的历史行照旧返回。
     .filter((row) => String(row.c_is_login ?? "").trim() !== "0")
     .map(
       (row): RemoteSession => ({
@@ -504,7 +419,7 @@ export const writeDmsFeedback = (params: {
     const existing = await findDmsRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
     if (!existing?.id) {
       // 例如从本地历史里点的赞,那一轮还没写进 DMS
-      console.warn("[dms] 反馈未落库:DMS 里没有这条问答记录", params.recordId);
+      /* console.warn("[dms] 反馈未落库:DMS 里没有这条问答记录", params.recordId); */
       return false;
     }
 

+ 30 - 61
src/network/api/dms/classification-sync-utils.ts

@@ -1,18 +1,15 @@
 /**
  * 企业分类同步的**纯逻辑层**。
  *
- * 背景:对话 `result` 事件带回 `company_info` 时,前端调后端分类接口
- * (`POST /api/company/classify`),把结果落到 DMS 两个企业栏目:
+ * 输入是对话 `result` 事件带回的 `company_info`,结果落到 DMS 两个企业栏目:
  *   - **1888 企业基础信息**:一行一家企业(幂等键 `c_credit_code`);六个分类角度
  *     各存一个字段(`c_tag_*`),值是 JSON 数组字符串(空数组存 `"[]"`)
  *   - **1886 企业荣誉信息**:**一条真实荣誉一行**,数据源是 `company_info.honors.records`
- *     (**不是**分类标签——标签太粗,实测一家公司只给 2 个,而真实荣誉有 31 条);
- *     与库里该企业的行按「荣誉名+级别+来源」**全量对齐**(补新增、更新差异、删多余,
- *     删只在 `honors.complete === true` 时做)
+ *     (**不是**分类标签);与库里该企业的行按「荣誉名+级别+来源」**全量对齐**
+ *     (补新增、更新差异、删多余,删只在 `honors.complete === true` 时做)
  *
  * 本文件只放类型与纯函数:**不 import vue、不发网络请求**(只 `import type`),
  * 这样 harness 的 esbuild 脚本能直接打包它做断言。
- * 契约见 `harness/docs/reference/company-classification.md` 与 `DMS_COLUMNS.md`。
  */
 
 import type { DmsRow } from "./client";
@@ -31,8 +28,7 @@ export type ClassifyAngle = (typeof CLASSIFY_ANGLE_KEYS)[number];
 
 /**
  * 角度 → 1888 的字段名。
- * ⚠️ 行业信息用 `c_tag_industry`:1888 里**已经有一个 `c_industry`**(源库的行业分类
- * JSON),不能占用那个名字。
+ * ⚠️ 行业信息用 `c_tag_industry`:1888 里**已经有一个 `c_industry`**,不能占用那个名字。
  */
 export const ANGLE_TO_FIELD: Record<ClassifyAngle, string> = {
   基本信息: "c_tag_basic",
@@ -43,17 +39,12 @@ export const ANGLE_TO_FIELD: Record<ClassifyAngle, string> = {
   许可认证: "c_tag_license",
 };
 
-/**
- * 1886 里存**荣誉名称**的列(DMS 侧 2026-09-18 18:17 新增,别名「荣誉名称」)。
- * 系统字段 `title` 同时写(DMS 列表靠它显示,且灌入行本来就有)。
- */
+/** 1886 里存**荣誉名称**的列;系统字段 `title` 同时写(DMS 列表靠它显示) */
 export const HONOR_NAME_FIELD = "c_honor";
 
 /**
- * 1886 的「荣誉标签」列(同日先建的 `c_tag_name`)。
- *
- * ⚠️ 2026-09-18 晚些时候用户新建了 `c_honor` 专存荣誉名称,**这一列不再当名字用**;
- * 保留只为读取兼容:早先同步写进去的值仍在,读名字时作为中间回退项。
+ * 1886 的「荣誉标签」列。
+ * ⚠️ 已不当名字用,保留只为读取兼容(读名字时作为中间回退项)。
  */
 export const HONOR_TAG_FIELD = "c_tag_name";
 
@@ -102,11 +93,8 @@ const asRecord = (value: unknown): Record<string, unknown> | null =>
  * ------------------------------------------------------------------ */
 
 /**
- * 从 `company_info` 抽身份三字段。
- *
- * 取值顺序与后端一致(见 classify 文档):`profile.data.Name/CreditCode/OperName` 优先,
- * 逐项回退 `company` 的同名字段;都没有则返回 null(调用方应跳过整条同步——
- * 没有统一社会信用代码就既不能查也不能写 1888)。
+ * 从 `company_info` 抽身份三字段:`profile.data.Name/CreditCode/OperName` 优先,
+ * 逐项回退 `company` 的同名字段;都没有则返回 null(没有信用代码就既不能查也不能写 1888)。
  */
 export const extractIdentityFromCompanyInfo = (info: unknown): CompanyIdentity | null => {
   const root = asRecord(info);
@@ -181,9 +169,8 @@ export const sameTagSet = (a: readonly string[], b: readonly string[]): boolean
 /**
  * 哪些角度失败了(partial 时)。
  *
- * 文档只给了 `errors` 的**键名**含义,没给完整结构,所以这里按键名与六个角度求交集;
- * 出现对不上角度的键(或 partial 却一个角度都对不上)时置 `unknown`,
- * 由调用方降级为保守策略(只写非空角度、跳过 1886)。
+ * 文档只给了 `errors` 的**键名**含义,所以按键名与六个角度求交集;对不上角度的键
+ * (或 partial 却一个都对不上)时置 `unknown`,由调用方降级为保守策略(只写非空角度、跳过 1886)。
  */
 export const extractFailedAngles = (
   classification: CompanyClassification | null
@@ -206,12 +193,9 @@ export const extractFailedAngles = (
 };
 
 /**
- * 最终采用的身份三字段。
- *
- * 以**分类响应**为准(后端按 `profile.data` → `company` 的顺序算好了),响应里为空时
- * 回退到前端自己从 `company_info` 抽的 `identityHint` —— 同一份数据的两个出口、
- * 不是猜。两个都拿不到就是 null(`creditCode` 为 null 时调用方应跳过整条同步:
- * 它既是 1888 的幂等键、又是 1886 的必填字段)。
+ * 最终采用的身份三字段:以**分类响应**为准,响应里为空时回退前端自己从
+ * `company_info` 抽的 `identityHint`。`creditCode` 为 null 时调用方应跳过整条同步
+ * (它既是 1888 的幂等键、又是 1886 的必填字段)。
  */
 export const resolveSyncedIdentity = (
   classification: CompanyClassification | null,
@@ -259,9 +243,8 @@ export const buildEnterpriseRow = (
 /**
  * `profile.data` → 1888 列。
  *
- * ⚠️ 这些列**本来就是照着企查查这套字段建的**(DMS 侧从源表 `qcc_enterprise` 灌数据时用的同一套),
- * 所以是直接对应,没有派生计算。2026-09-20 用户要求把这些字段也同步进来
- * (此前只写身份三字段 + 分类标签)。
+ * ⚠️ 这些列本来就是照着企查查这套字段建的(DMS 侧灌数据用的是同一套),
+ * 所以是直接对应、没有派生计算。
  */
 export const PROFILE_FIELD_MAP: Record<string, string> = {
   Name: "c_name",
@@ -303,7 +286,7 @@ export const PROFILE_FIELD_MAP: Record<string, string> = {
 };
 
 /**
- * 值是「数组/对象」的列:存 JSON 字符串(与 DMS 侧灌入的形态一致——那边是 jsonb 序列化过来的)。
+ * 值是「数组/对象」的列:存 JSON 字符串(与 DMS 侧灌入的形态一致,那边是 jsonb 序列化过来的)。
  * ⚠️ 比较时**不能按字符串比**:Python 的 `json.dumps` 带空格、jsonb 还会按字母序排键,
  * 与 JS 的 `JSON.stringify` 输出不同,直接比会每轮都判成「有差异」而反复写。
  */
@@ -366,14 +349,10 @@ export const readProfileDataFromCompanyInfo = (info: unknown): unknown =>
   asRecord(asRecord(asRecord(info)?.profile)?.data) ?? null;
 
 /**
- * 补上 DMS 的**系统字段 `title`**(列表里显示的那一列)。
- *
- * 为什么要显式写(实测教训,与 1886 同因):
- * - `title` 只在**创建那一刻**初始化,之后改 `c_*` 字段**不会带动它**;
- * - 创建时不传,DMS 落的是**字符串 `"null"`**(1889 踩过,列表里看不出是哪一条)。
+ * 补上 DMS 的**系统字段 `title`**(列表里显示的那一列):它只在**创建那一刻**初始化,
+ * 之后改 `c_*` 字段不会带动它;创建时不传,DMS 落的是**字符串 `"null"`**。
  *
  * 取值就用 `c_name`(企业名称);`c_name` 为空时不动 `title`(**不清存量**)。
- * 库里灌入的行本来就有 title,值相同不会产生写入。
  */
 export const withSystemTitle = (row: DmsRow): DmsRow => {
   const name = pickString(row?.c_name);
@@ -405,9 +384,8 @@ const parseIfJson = (raw: unknown): unknown => {
  *
  * 三类字段的比较方式不同:
  * - **标签字段**(`c_tag_*`):decode 后按**集合**比较,防 `"[]"` 与 `[]`、顺序不同这类假差异
- * - **JSON 列**(`c_area` / `c_original_name` 等):先解析再**按键排序**比较——
- *   DMS 里灌入的值是 Python/jsonb 序列化的(带空格、键按字母序),
- *   与 JS 的 `JSON.stringify` 输出不同,直接比字符串会每轮都误判成「有差异」
+ * - **JSON 列**(`c_area` / `c_original_name` 等):先解析再**按键排序**比较
+ *   (DMS 里的值是 Python/jsonb 序列化的,直接比字符串会每轮都误判成「有差异」)
  * - **普通文本列**:期望值非空且与现有值(去空白后)不同才写;期望为空一律不写(**不清存量**)
  */
 export const diffEnterpriseRow = (
@@ -443,10 +421,8 @@ export const diffEnterpriseRow = (
 /**
  * 一条荣誉(字段名沿用企查查原始结构里 `records[].data` 的写法)。
  *
- * ⚠️ **数据源是 `company_info.honors`,不是分类接口的「资质荣誉」角度**——
- * 后者是 165 个标签里挑出来的粗标签(实测宇树科技只给 2 个),
- * 而 `honors.records` 才是逐条的真实荣誉(同一家 31 条,含级别/发布机构/日期/证书编号),
- * 且与 DMS 里既有的行字段一一对应。2026-09-18 按用户要求改为此源。
+ * ⚠️ **数据源是 `company_info.honors`,不是分类接口的「资质荣誉」角度** ——
+ * 后者只是粗标签,前者才是逐条真实荣誉,且与 DMS 里既有的行字段一一对应。
  */
 export interface CompanyHonorRecord {
   name: string;
@@ -475,8 +451,7 @@ export const HONOR_FIELD_MAP: Record<keyof Omit<CompanyHonorRecord, "name">, str
 };
 
 /**
- * 读出荣誉行里的荣誉名,回退链:`c_honor`(现在的正主)→ `c_tag_name`(早期同步写进去的)
- * → 系统字段 `title`(DMS 侧灌入的行写在 title)。
+ * 读出荣誉行里的荣誉名,回退链:`c_honor` → `c_tag_name` → 系统字段 `title`(DMS 侧灌入的行写在 title)。
  */
 const readHonorName = (row: DmsRow | null | undefined): string | null => {
   const honor = pickString(row?.[HONOR_NAME_FIELD]);
@@ -484,7 +459,7 @@ const readHonorName = (row: DmsRow | null | undefined): string | null => {
   const tag = pickString(row?.[HONOR_TAG_FIELD]);
   if (tag) return tag;
   const title = pickString(row?.title);
-  // 历史遗留:一度没写 title,DMS 落的是字符串 "null",别把它当荣誉名
+  // DMS 没写 title 时落的是字符串 "null",别把它当荣誉名
   return title && title !== "null" ? title : null;
 };
 
@@ -493,7 +468,7 @@ const readHonorName = (row: DmsRow | null | undefined): string | null => {
  *
  * - `honors` 缺失/null → `available=false`,调用方**整块跳过** 1886 同步
  * - `complete !== true` → 调用方**只增改、不删除**(记录集不完整时删 = 丢数据)
- * - 没有 `Name` 的记录直接丢弃(没名字的荣誉行没有意义)
+ * - 没有 `Name` 的记录直接丢弃
  */
 export const parseHonorRecords = (
   honors: unknown
@@ -533,14 +508,13 @@ const diffHonorFields = (row: DmsRow, record: CompanyHonorRecord): DmsRow | null
     if (!next) continue; // 载荷没有的字段不写(不清存量)
     if (pickString(row?.[field]) !== next) patch[field] = next;
   }
-  // 荣誉名两处都要有:自有列 `c_honor` + 系统字段 `title`(DMS 列表靠后者显示)。
-  // 灌入的行只有 title;早期同步的行写的是 c_tag_name —— 首次对齐时都要补上 `c_honor`。
+  // 荣誉名两处都要有:自有列 `c_honor` + 系统字段 `title`(DMS 列表靠后者显示)
   if (pickString(row?.[HONOR_NAME_FIELD]) !== record.name) patch[HONOR_NAME_FIELD] = record.name;
   if (pickString(row?.title) !== record.name) patch.title = record.name;
   return Object.keys(patch).length ? patch : null;
 };
 
-/** 新荣誉行的内容(c_credit_code 必填;c_id 见计划文件里的说明) */
+/** 新荣誉行的内容(`c_credit_code` 必填) */
 export const buildHonorRowContent = (
   creditCode: string,
   companyName: string | null,
@@ -548,9 +522,8 @@ export const buildHonorRowContent = (
   createdAt: string
 ): DmsRow => {
   const row: DmsRow = {
-    // ⚠️ 1886 的 c_id 是源库主键且 must=true,前端新写行拿不到源库 id。
-    // 按 1889 的实测经验(must 只校验字段在不在、c_id 自动填 0)先传 0;
-    // 若被 214 拒绝,需要 DMS 侧放宽该字段或给一个前端可用的幂等键(见计划文件)。
+    // ⚠️ 1886 的 c_id 是源库主键且 must=true,前端拿不到源库 id,先传 0
+    // (must 只校验字段在不在,c_id 会自动填 0)。
     c_id: 0,
     c_credit_code: creditCode,
     c_created_at: createdAt,
@@ -571,10 +544,6 @@ export const buildHonorRowContent = (
  * - 库里没有的 → `toAdd`
  * - 同键但字段有差异 → `toUpdate`(只 patch 有差异的列;`title` 也跟荣誉名走)
  * - 库里多出来的 → `toDelete`(**仅当 `allowDelete`**:载荷 `complete !== true` 时不许删)
- *
- * ⚠️ 与上一版的区别:上一版只认「自己写的行」(`c_source=company_info_sync`),
- * 库里灌入的行一律不碰;那个做法在**库里已有同一份数据**时会让两边的行并存
- * (宇树:28 条灌入 + 2 条标签)。现在按用户 2026-09-18 的要求改为全量对齐。
  */
 export const diffHonorRows = (
   existing: readonly DmsRow[],

+ 113 - 48
src/network/api/dms/client.ts

@@ -1,34 +1,97 @@
 /**
- * DMS(数据管理服务)HTTP 客户端。
+ * DMS(数据管理服务)HTTP 客户端。三处与常规 REST 不同:
  *
- * 契约来源:`harness/docs/reference/DMS_API.md` + **本项目的 Step-0 实测**
- * (`harness/tools/dms-step0-verify.mjs`、`dms-delete-probe.mjs`)。
- * 三处与常规 REST 不同,改代码前先记住:
- *
- * 1. **鉴权头叫 `token`**(不是 `Authorization`)。**2026-09-29 起由前端自己带**:
- *    未登录访客用服务账号换一个(`service-token.ts`),随请求发 `token` 头;
- *    代理(dev 的 vite / 生产的 nginx)**只在请求没带时才注入**,命令行走代理的方式不受影响。
+ * 1. **鉴权头叫 `token`**(不是 `Authorization`):由前端用服务账号换一个(见 `service-token.ts`)
+ *    随请求发;代理(dev 的 vite / 生产的 nginx)**只在请求没带时才注入**。
  * 2. **参数走 form body**(`application/x-www-form-urlencoded`),不是 JSON。
  * 3. **成功判定是 `code === 200`**;`202` 表示「数据不存在」(栏目为空),不是错误。
  *
- * ⚠️ **所有函数都不抛异常**:失败一律 `console.warn` 并返回空值。
+ * 栏目 id / 双 c_ 前缀见 `public/config.js`(`window.__ZHAOSHANG_CONFIG__.dms`)。
+ *
+ * ⚠️ **所有函数都不抛异常**:失败一律 `console.warn` 并返回空值 ——
  *    聊天主链路是 fire-and-forget 调用它们,DMS 挂了不能让聊天挂。
  */
 
 import { getDmsApiBaseUrl } from "@/utils/runtime-config";
 import { getServiceDmsToken, clearServiceDmsToken } from "./service-token";
 
-/** 栏目与栏目模型 id(实测自 DMS,见 DMS_COLUMNS.md) */
-export const DMS_COLUMN_SESSION = 1887;
-export const DMS_MODEL_SESSION = 2034;
-export const DMS_COLUMN_RECORD = 1889;
-export const DMS_MODEL_RECORD = 2038;
-/** 企业基础信息(一行一家企业,幂等键 `c_credit_code`;modelId 实测自 getModelById) */
-export const DMS_COLUMN_ENTERPRISE = 1888;
-export const DMS_MODEL_ENTERPRISE = 2036;
-/** 企业荣誉信息(本同步里「一个标签一行」,幂等靠我们自己的 c_source 标记) */
-export const DMS_COLUMN_HONOR = 1886;
-export const DMS_MODEL_HONOR = 2032;
+type DmsColumnsConfig = {
+  honor?: number;
+  session?: number;
+  enterprise?: number;
+  record?: number;
+};
+
+type DmsModelsConfig = {
+  honor?: number;
+  session?: number;
+  enterprise?: number;
+  record?: number;
+};
+
+type DmsRuntimeConfig = {
+  columns?: DmsColumnsConfig;
+  models?: DmsModelsConfig;
+  /** 内网:c_* → 线上 c_c_* */
+  fieldDoubleCPrefix?: boolean;
+};
+
+const readDmsRuntimeConfig = (): DmsRuntimeConfig => {
+  const root = (globalThis as { __ZHAOSHANG_CONFIG__?: { dms?: DmsRuntimeConfig } }).__ZHAOSHANG_CONFIG__;
+  return root?.dms && typeof root.dms === "object" ? root.dms : {};
+};
+
+const pickId = (value: unknown, fallback: number): number => {
+  const n = Number(value);
+  return Number.isFinite(n) && n > 0 ? n : fallback;
+};
+
+const dmsCfg = readDmsRuntimeConfig();
+
+export type DmsRow = Record<string, any>;
+
+/** 栏目与栏目模型 id(优先读 public/config.js) */
+export const DMS_COLUMN_SESSION = pickId(dmsCfg.columns?.session, 1887);
+export const DMS_MODEL_SESSION = pickId(dmsCfg.models?.session, 2034);
+export const DMS_COLUMN_RECORD = pickId(dmsCfg.columns?.record, 1889);
+export const DMS_MODEL_RECORD = pickId(dmsCfg.models?.record, 2038);
+/** 企业基础信息(一行一家企业,幂等键 `c_credit_code`) */
+export const DMS_COLUMN_ENTERPRISE = pickId(dmsCfg.columns?.enterprise, 1888);
+export const DMS_MODEL_ENTERPRISE = pickId(dmsCfg.models?.enterprise, 2036);
+/** 企业荣誉信息(一条真实荣誉一行) */
+export const DMS_COLUMN_HONOR = pickId(dmsCfg.columns?.honor, 1886);
+export const DMS_MODEL_HONOR = pickId(dmsCfg.models?.honor, 2032);
+
+/** 内网建栏多一层 c_ 时为 true;业务代码仍写 `c_question`,出网前编成 `c_c_question` */
+export const isDmsFieldDoubleCPrefix = (): boolean =>
+  Boolean(readDmsRuntimeConfig().fieldDoubleCPrefix);
+
+/**
+ * 业务字段名 → 线上字段名。只处理以 `c_` 开头的自定义列;`id` / `title` 等不动。
+ */
+export const encodeDmsFieldName = (field: string): string => {
+  if (!isDmsFieldDoubleCPrefix()) return field;
+  if (!field.startsWith("c_") || field.startsWith("c_c_")) return field;
+  return `c_${field}`;
+};
+
+/** 线上字段名 → 业务字段名(`c_c_question` → `c_question`) */
+export const decodeDmsFieldName = (field: string): string => {
+  if (!isDmsFieldDoubleCPrefix()) return field;
+  if (!field.startsWith("c_c_")) return field;
+  return field.slice(2);
+};
+
+const mapRowKeys = (row: DmsRow, mapKey: (key: string) => string): DmsRow => {
+  const out: DmsRow = {};
+  for (const [key, value] of Object.entries(row || {})) {
+    out[mapKey(key)] = value;
+  }
+  return out;
+};
+
+export const encodeDmsRow = (row: DmsRow): DmsRow => mapRowKeys(row, encodeDmsFieldName);
+export const decodeDmsRow = (row: DmsRow): DmsRow => mapRowKeys(row, decodeDmsFieldName);
 
 /** `state` 取值:0 草稿 / 1 待审 / 2 审完 / 3 发布 / 4 销毁 / 5 退回 */
 export const DMS_STATE_DESTROYED = 4;
@@ -52,8 +115,6 @@ export interface DmsResponse<T = unknown> {
   content: T;
 }
 
-export type DmsRow = Record<string, any>;
-
 type DmsMethod = "GET" | "POST" | "DELETE";
 
 /** 取一个可用的 token;拿不到就返回空串(并降级——dev 下代理还会兜底注入) */
@@ -61,10 +122,10 @@ const ensureToken = async (): Promise<string> => {
   try {
     return await getServiceDmsToken();
   } catch (error) {
-    console.warn(
+    /* console.warn(
       "[dms] 换服务账号 token 失败,本次不带 token(dev 由代理兜底注入,生产会失败)",
       error instanceof Error ? error.message : error
-    );
+    ); */
     return "";
   }
 };
@@ -77,7 +138,7 @@ async function dmsRequest(
 ): Promise<DmsResponse | null> {
   const base = getDmsApiBaseUrl();
   if (!base) {
-    console.warn("[dms] 未配置 VITE_DMS_API,跳过请求", path);
+    /* console.warn("[dms] 未配置 VITE_DMS_API,跳过请求", path); */
     return null;
   }
 
@@ -97,7 +158,7 @@ async function dmsRequest(
     try {
       data = JSON.parse(text) as DmsResponse;
     } catch {
-      console.warn("[dms] 响应不是 JSON", path, text.slice(0, 120));
+      /* console.warn("[dms] 响应不是 JSON", path, text.slice(0, 120)); */
     }
     return { data, text };
   };
@@ -106,7 +167,7 @@ async function dmsRequest(
     let token = await ensureToken();
     let { data, text } = await sendOnce(token);
 
-    // token 失效(201/212):丢掉缓存、重换一次再试一遍 —— 只重试一次
+    // token 失效(201/212):丢掉缓存、重换一次再试 —— 只重试一次
     if (data && (data.code === 201 || data.code === 212) && token) {
       clearServiceDmsToken();
       token = await ensureToken();
@@ -118,27 +179,26 @@ async function dmsRequest(
     // 202 = 数据不存在(栏目为空),属正常空结果,不算失败
     if (data.code === 202) return null;
     if (typeof data.code !== "number") {
-      // 不是 DMS 的标准响应(例如 Spring 的 400/405 错误体没有 code 字段)——
-      // 把原始片段打出来,否则只看到 undefined 无从排查
-      console.warn("[dms] 非标准响应", path, text.slice(0, 200));
+      // 不是 DMS 的标准响应(例如 Spring 的 400/405 错误体没有 code 字段)——把原始片段打出来
+      /* console.warn("[dms] 非标准响应", path, text.slice(0, 200)); */
     } else if (data.code === 201 || data.code === 212) {
-      // 前端换 token 的路径见 service-token.ts;换成命令行排查时也可以 `npm run dms:token`
-      console.warn(
+      // 前端换 token 的路径见 service-token.ts
+      /* console.warn(
         "[dms] token 无效或无权限(服务账号可能被停用/改密,见 src/network/api/dms/service-token.ts)",
         data.code,
         data.message
-      );
+      ); */
     } else {
-      console.warn("[dms] 请求失败", path, data.code, data.message);
+      /* console.warn("[dms] 请求失败", path, data.code, data.message); */
     }
     return null;
   } catch (error) {
-    console.warn("[dms] 请求异常", path, error);
+    /* console.warn("[dms] 请求异常", path, error); */
     return null;
   }
 }
 
-/** 分页查询内容;无数据返回 [] */
+/** 分页查询内容;无数据返回 [](行内 c_c_* 键会解码成业务侧 c_*) */
 export const searchDmsContents = async (
   columnId: number,
   options: {
@@ -154,18 +214,25 @@ export const searchDmsContents = async (
     page: String(options.page ?? 0), // DMS 的 page 从 0 起
     pageSize: String(options.pageSize ?? 100),
   };
-  if (options.search?.length) form.search = JSON.stringify(options.search);
-  if (options.orderBy?.length) form.orderBy = JSON.stringify(options.orderBy);
+  if (options.search?.length) {
+    form.search = JSON.stringify(
+      options.search.map((item) => ({ ...item, field: encodeDmsFieldName(item.field) }))
+    );
+  }
+  if (options.orderBy?.length) {
+    form.orderBy = JSON.stringify(
+      options.orderBy.map((item) => ({ ...item, field: encodeDmsFieldName(item.field) }))
+    );
+  }
   if (options.states?.length) form.states = options.states.join(",");
 
   const res = await dmsRequest("/content/selectContentList", "POST", form);
   const content = res?.content as { data?: DmsRow[] } | undefined;
-  return content?.data || [];
+  return (content?.data || []).map(decodeDmsRow);
 };
 
 /**
- * 新增内容。成功时返回**记录的 uuid**(DMS 把它作为纯字符串放在 `content` 里,
- * 不是对象——这是 Step-0 实测出来的),失败返回空串。
+ * 新增内容。成功时返回**记录的 uuid**(DMS 把它作为纯字符串放在 `content` 里,不是对象),失败返回空串。
  */
 export const addDmsContent = async (
   columnId: number,
@@ -175,7 +242,7 @@ export const addDmsContent = async (
   const res = await dmsRequest("/content/addContent", "POST", {
     columnId: String(columnId),
     modelId: String(modelId),
-    content: JSON.stringify(content),
+    content: JSON.stringify(encodeDmsRow(content)),
   });
   const value = res?.content;
   return typeof value === "string" ? value : "";
@@ -191,15 +258,13 @@ export const updateDmsContent = async (
   const res = await dmsRequest("/content/updateContent", "POST", {
     columnId: String(columnId),
     modelId: String(modelId),
-    content: JSON.stringify({ ...content, id }),
+    content: JSON.stringify({ ...encodeDmsRow(content), id }),
   });
   return !!res;
 };
 
 /**
- * 删除内容。
- *
- * ⚠️ **不是 `delContentById`**——那个端点各种传法都返回 `code=-1`/405(Step-0 实测)。
+ * 删除内容。**不是 `delContentById`**(那个端点各种传法都返回 `code=-1`/405)——
  * DMS 的删除是**改状态**:`POST /content/updateAudit`,`state=4`(销毁)。
  */
 export const deleteDmsContent = async (columnId: number, id: string): Promise<boolean> => {
@@ -212,7 +277,7 @@ export const deleteDmsContent = async (columnId: number, id: string): Promise<bo
 };
 
 /* ------------------------------------------------------------------ *
- * 通用工具(各业务模块共用,原先只写在 chat-sessions-dms.ts 里)
+ * 通用工具(各业务模块共用)
  * ------------------------------------------------------------------ */
 
 /**
@@ -220,7 +285,7 @@ export const deleteDmsContent = async (columnId: number, id: string): Promise<bo
  *
  * ⚠️ **不要加 orderBy**:各栏目的字段并不相同(1889 没有 `c_updated_at`),
  * 按不存在的字段排序会让 DMS 直接报错、查询恒空 —— 后果是 upsert 每次都走「新增」,
- * 同一份数据被写成多行(踩过,见 `verify-dms-chat-storage.mjs` 的回归断言)。
+ * 同一份数据被写成多行。
  */
 export const findDmsRowBy = async (
   columnId: number,

+ 27 - 37
src/network/api/dms/company-info-sync.ts

@@ -1,20 +1,14 @@
 /**
  * 对话带回的企业资料 → 分类接口 + 荣誉列表 → 落到 DMS 两个企业栏目。
  *
- * 触发:`/api/chat` 的 `result` 事件里 `data.company_info` 非空时,
  * 由 `useBusinessAssistantChat` 在回答完成后调 `syncCompanyInfoFromChat`(fire-and-forget)。
- *
  * 两个栏目**数据源不同、互不依赖**(一条失败不影响另一条):
  *
- * - **1888 企业基础信息** ← 分类接口(`POST /api/company/classify`)的六个角度标签。
- *   一行一家企业,幂等键 `c_credit_code`;没有就新增,有就按 `diffEnterpriseRow` 的差异更新。
- * - **1886 企业荣誉信息** ← **`company_info.honors.records`**(逐条真实荣誉)。
- *   一条荣誉一行,与库里该企业的行按「荣誉名+级别+来源」**全量对齐**:
- *   补新增、更新有差异的列、删库里多出来的(**仅在 `honors.complete === true` 时删**)。
- *
- * ⚠️ 2026-09-18 用户确认的调整:1886 原先写「分类接口的资质荣誉标签」,
- * 但那只是 165 个标签里挑出的粗标签(实测宇树科技只有 2 个),而同一家的
- * `honors.records` 有 31 条完整荣誉 —— 改为以后者为准;分类标签仍写进 1888 的 `c_tag_honor`。
+ * - **1888 企业基础信息** ← 分类接口(`POST /api/company/classify`)的六个角度标签;
+ *   一行一家企业,幂等键 `c_credit_code`,按 `diffEnterpriseRow` 的差异更新。
+ * - **1886 企业荣誉信息** ← `company_info.honors.records`(逐条真实荣誉);
+ *   一条荣誉一行,与库里该企业的行按「荣誉名+级别+来源」**全量对齐**,
+ *   删库里多出来的仅在 `honors.complete === true` 时做。
  *
  * ⚠️ 全程 fire-and-forget:任何失败只 `console.warn`,不抛异常、不阻塞聊天。
  * ⚠️ 同一企业(同一信用代码)的多轮同步串行排队,避免并发反查各写一行。
@@ -57,8 +51,7 @@ import {
 const HONOR_PAGE_SIZE = 200;
 
 /**
- * 入口:对话拿到 `company_info` 后调用。
- * 没有可用资料时**静默返回**(多数轮都没有,属常态,不刷日志)。
+ * 入口:对话拿到 `company_info` 后调用;没有可用资料时**静默返回**(多数轮都没有,属常态)。
  */
 export const syncCompanyInfoFromChat = (companyInfo: unknown): void => {
   if (!isCompanyInfoUsable(companyInfo)) return;
@@ -74,18 +67,18 @@ async function runCompanyInfoSync(
   companyInfo: unknown,
   identity: CompanyIdentity | null
 ): Promise<void> {
-  // ① 工商信息(数据源 company_info.profile.data,不依赖分类接口,先跑——不用等 LLM)
+  // ① 工商信息(数据源 company_info.profile.data,不依赖分类接口,先跑)
   try {
     await applyCompanyProfile(readProfileDataFromCompanyInfo(companyInfo), identity);
   } catch (err) {
-    console.warn("[company-sync] 工商信息同步异常", err);
+    /* console.warn("[company-sync] 工商信息同步异常", err); */
   }
 
   // ② 荣誉(数据源就是 company_info.honors,同样不依赖分类接口)
   try {
     await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
   } catch (err) {
-    console.warn("[company-sync] 荣誉同步异常", err);
+    /* console.warn("[company-sync] 荣誉同步异常", err); */
   }
 
   // ③ 分类标签(需要分类接口结果,最慢的一步,放最后)
@@ -93,20 +86,18 @@ async function runCompanyInfoSync(
     const classification = await classifyCompany(companyInfo);
     if (!classification) return; // classifyCompany 里已经 warn 过
     if (classification.status === "failed") {
-      console.warn("[company-sync] 分类失败(status=failed),跳过 1888 同步", classification.errors);
+      /* console.warn("[company-sync] 分类失败(status=failed),跳过 1888 同步", classification.errors); */
       return;
     }
     await applyCompanyClassification(classification, identity);
   } catch (err) {
-    console.warn("[company-sync] 基础信息同步异常", err);
+    /* console.warn("[company-sync] 基础信息同步异常", err); */
   }
 }
 
 /**
  * 1888:把 `company_info.profile.data` 的工商信息同步进去(**不依赖分类接口**)。
- *
  * 一行一家企业,幂等键 `c_credit_code`;字段级 diff,无差异零写入。
- * 与下面「分类标签」那个入口共用同一个串行链键,因此对同一行的两次写入不会并发。
  *
  * ⚠️ 空值一律不写(`null`/空串/空数组/空对象都跳过)——**不清存量**:
  * 库里灌进来的值不会被一个「本次没带」的字段抹掉。
@@ -122,15 +113,14 @@ export const applyCompanyProfile = async (
     null;
   if (!creditCode) {
     // 1888 的幂等键就是它,没有就什么都写不了
-    console.warn("[company-sync] 工商信息里没有统一社会信用代码,跳过 1888 同步");
+    /* console.warn("[company-sync] 工商信息里没有统一社会信用代码,跳过 1888 同步"); */
     return;
   }
   await upsertEnterpriseRow(creditCode, fields);
 };
 
 /**
- * 1888:拿到分类结果后写分类标签。**单独导出**是为了让验证脚本能注入假的分类响应
- * 直接打真实 DMS(可反复跑,不消耗分类接口的 LLM 调用)。
+ * 1888:拿到分类结果后写分类标签。**单独导出**是为了让验证脚本能注入假的分类响应直接打真实 DMS。
  */
 export const applyCompanyClassification = async (
   classification: CompanyClassification,
@@ -140,13 +130,13 @@ export const applyCompanyClassification = async (
   const creditCode = identity.creditCode;
   if (!creditCode) {
     // 1888 的幂等键就是它,没有就什么都写不了
-    console.warn("[company-sync] 分类结果缺少统一社会信用代码,跳过 1888 同步");
+    /* console.warn("[company-sync] 分类结果缺少统一社会信用代码,跳过 1888 同步"); */
     return;
   }
 
   const { failed, unknown } = extractFailedAngles(classification);
   if (unknown) {
-    console.warn("[company-sync] 分类错误无法对应到角度,降级为保守同步(只写非空角度)");
+    /* console.warn("[company-sync] 分类错误无法对应到角度,降级为保守同步(只写非空角度)"); */
   }
 
   await upsertEnterpriseRow(
@@ -157,7 +147,7 @@ export const applyCompanyClassification = async (
 
 /**
  * 1886:拿 `company_info.honors` 与库里该企业的荣誉行全量对齐。
- * **单独导出**供验证脚本注入假的 honors 直接打真实 DMS。
+ * **单独导出**供验证脚本注入假的 honors。
  */
 export const applyCompanyHonors = async (
   honors: unknown,
@@ -166,17 +156,17 @@ export const applyCompanyHonors = async (
   const { records, complete, available, malformed } = parseHonorRecords(honors);
   if (!available) return; // honors 缺失/null:常态,静默跳过
   if (malformed) {
-    console.warn("[company-sync] 有荣誉记录缺 data/Name,已跳过这些条", malformed);
+    /* console.warn("[company-sync] 有荣誉记录缺 data/Name,已跳过这些条", malformed); */
   }
 
   const creditCode = identityHint?.creditCode || null;
   if (!creditCode) {
     // 1886 的 c_credit_code 是必填,没有就写不了
-    console.warn("[company-sync] 缺少统一社会信用代码,跳过 1886 同步");
+    /* console.warn("[company-sync] 缺少统一社会信用代码,跳过 1886 同步"); */
     return;
   }
   if (!complete) {
-    console.warn("[company-sync] honors.complete=false(记录集不完整):只增改、不删除");
+    /* console.warn("[company-sync] honors.complete=false(记录集不完整):只增改、不删除"); */
   }
 
   const rows = await searchDmsContents(DMS_COLUMN_HONOR, {
@@ -189,7 +179,7 @@ export const applyCompanyHonors = async (
     allowDelete: complete,
   });
   if (badRows) {
-    console.warn("[company-sync] 有我方荣誉行缺 id/荣誉名,已跳过(不匹配、不更新、不删)", badRows);
+    /* console.warn("[company-sync] 有我方荣誉行缺 id/荣誉名,已跳过(不匹配、不更新、不删)", badRows); */
   }
   if (!toAdd.length && !toUpdate.length && !toDelete.length) return; // 完全一致:零写入
 
@@ -202,23 +192,23 @@ export const applyCompanyHonors = async (
       DMS_MODEL_HONOR,
       buildHonorRowContent(creditCode, companyName, record, createdAt)
     );
-    if (!id) console.warn("[company-sync] 荣誉行新增失败", creditCode, record.name);
+    if (!id) { /* console.warn("[company-sync] 荣誉行新增失败", creditCode, record.name); */ }
   }
 
   for (const item of toUpdate) {
     const ok = await updateDmsContent(DMS_COLUMN_HONOR, DMS_MODEL_HONOR, item.id, item.patch);
-    if (!ok) console.warn("[company-sync] 荣誉行更新失败", creditCode, item.record.name, item.patch);
+    if (!ok) { /* console.warn("[company-sync] 荣誉行更新失败", creditCode, item.record.name, item.patch); */ }
   }
 
   for (const item of toDelete) {
     const ok = await deleteDmsContent(DMS_COLUMN_HONOR, item.id);
-    if (!ok) console.warn("[company-sync] 荣誉行删除失败", creditCode, item.key);
+    if (!ok) { /* console.warn("[company-sync] 荣誉行删除失败", creditCode, item.key); */ }
   }
 };
 
 /** 1888:没有就新增,有就按差异更新(无差异零写入) */
 async function upsertEnterpriseRow(creditCode: string, desired: DmsRow): Promise<void> {
-  // 系统字段 title = 企业名称(两条入口共用这一处,见 withSystemTitle 的说明)
+  // 系统字段 title = 企业名称(见 withSystemTitle 的说明)
   const target = withSystemTitle(desired);
   const existing = await findDmsRowBy(DMS_COLUMN_ENTERPRISE, "c_credit_code", creditCode);
 
@@ -230,7 +220,7 @@ async function upsertEnterpriseRow(creditCode: string, desired: DmsRow): Promise
       c_created_at: nowDmsTimestamp(),
     };
     const id = await addDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, content);
-    if (!id) console.warn("[company-sync] 企业基础信息新增失败", creditCode);
+    if (!id) { /* console.warn("[company-sync] 企业基础信息新增失败", creditCode); */ }
     return;
   }
 
@@ -239,9 +229,9 @@ async function upsertEnterpriseRow(creditCode: string, desired: DmsRow): Promise
 
   const id = typeof existing.id === "string" ? existing.id : "";
   if (!id) {
-    console.warn("[company-sync] 企业基础信息行缺少 uuid,无法更新", existing);
+    /* console.warn("[company-sync] 企业基础信息行缺少 uuid,无法更新", existing); */
     return;
   }
   const ok = await updateDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, id, patch);
-  if (!ok) console.warn("[company-sync] 企业基础信息更新失败", creditCode, patch);
+  if (!ok) { /* console.warn("[company-sync] 企业基础信息更新失败", creditCode, patch); */ }
 }

+ 70 - 37
src/network/api/dms/file-upload.ts

@@ -1,48 +1,40 @@
 /**
  * 附件上传到 DMS 的文件服务(`POST {DMS}/file/uploadFile`)。
  *
- * **实测契约(2026-09-23,打真实服务)**:
- * - `multipart/form-data`,**五个字段都必填**:
- *   `file` / `type` / `columnId` / `contentId` / `paraName` ——
- *   少任何一个都返回 `{"code":-1,"message":"缺少必传字段"}`
+ * - `multipart/form-data`,**五个字段都必填**:`file` / `type` / `columnId` / `contentId` / `paraName`
+ *   —— 少任何一个都返回 `{"code":-1,"message":"缺少必传字段"}`
  * - `type` 决定**存储目录**:`0` 图 → `/static/photo/…`;`1` 音 / `2` 视 / `3` 文件 → `/static/file/…`
- * - 成功返回 `{"code":200,"message":"上传成功!","content":"/static/photo/1889-c_question-<uuid>.png"}`
+ * - 成功返回 `{"code":200,"message":"上传成功!","content":"/static/photo/…"}`
  *   —— **`content` 就是那个相对路径**
- * - ⚠️ 返回的相对路径**当前直接取不到**(10081 / 10011 都 404)——
- *   所以聊天里的缩略图**不能拿它当 `<img>` 的 src**,预览要用本地 blob(见上传模块)
+ * - ⚠️ 返回的相对路径**直接取不到**,所以聊天里的缩略图不能拿它当 `<img>` 的 src,预览要用本地 blob
  * - ⚠️ 走 `/dms` 代理:页面是 HTTPS、接口是 HTTP,直连会被混合内容拦掉
- *   (代理已注入 DMS token,实测这个端点不校验 token 也无妨)
  *
- * 这个相对路径要放进聊天请求体的 `pictures` / `files` 里(用户 2026-09-23 指定)。
+ * 这个相对路径要放进聊天请求体的 `pictures` / `files` 里。
  */
 
 import { getDmsApiBaseUrl } from "@/utils/runtime-config";
+import { DMS_COLUMN_RECORD, encodeDmsFieldName } from "./client";
+import { getServiceDmsToken, clearServiceDmsToken } from "./service-token";
 
 /**
- * 上传时随文件一起提交的三个 DMS 定位字段(**会进文件名**)。
- *
- * 取值由用户 2026-09-23 指定:附件按「属于 1889 助手问答记录的问题内容」落库 ——
- * 会话/问答记录都在 1889,附件跟着问题走。
+ * 上传时随文件一起提交的三个 DMS 定位字段(**会进文件名**):
+ * 附件按「属于助手问答记录的问题内容」落库(栏目取 config 里的 record)。
  */
-export const DMS_ATTACHMENT_COLUMN_ID = 1889;
+export const DMS_ATTACHMENT_COLUMN_ID = DMS_COLUMN_RECORD;
 export const DMS_ATTACHMENT_CONTENT_ID = 0;
+/** 业务侧字段名;出网前经 encodeDmsFieldName(内网 → c_c_question) */
 export const DMS_ATTACHMENT_PARA_NAME = "c_question";
 
-/** `type`:0 图 · 1 音 · 2 视 · 3 文件(实测 0 与 3 的差别就是落在 photo/ 还是 file/ 目录) */
+/** `type`:0 图 · 1 音 · 2 视 · 3 文件(0 与 3 的差别就是落在 photo/ 还是 file/ 目录) */
 export const DMS_ATTACHMENT_TYPE_IMAGE = "0";
 export const DMS_ATTACHMENT_TYPE_FILE = "3";
 
 /**
- * 把上传返回的**相对路径**变成一个**可显示**的地址(用于聊天里的缩略图预览)。
- *
- * 实测(2026-09-23):相对路径本身取不到(10081 / 10011 的根路径都 404),
- * 但**在 DMS 主机前加上 `/dms` 就能取到** ——
- * `http://121.43.55.7:10081/dms/static/photo/….png` → **200**。
- * 而代理前缀 `/dms` 与 DMS 的真实路径前缀**同名且直通**(2026-09-29 起,不再需要补前缀),
- * 所以这里直接拼 `/dms` 即可。
+ * 把上传返回的**相对路径**变成一个**可显示**的地址(用于聊天里的缩略图预览):
+ * 在 DMS 主机前加上 `/dms` 即可取到(代理前缀 `/dms` 与 DMS 的真实路径前缀**同名且直通**)。
  *
- * ⚠️ 存进消息里的就是这个地址:它依赖代理前缀保持不变;
- * 发给**后端**的仍是相对路径(见 `toChatAttachmentUrls` 用的 `fileUrl` 字段),两者不要混。
+ * ⚠️ 存进消息里的就是这个地址,依赖代理前缀保持不变;
+ * 发给**后端**的仍是相对路径,两者不要混。
  */
 export const buildDmsFileDisplayUrl = (relativePath: string): string => {
   const path = String(relativePath || "").trim();
@@ -54,7 +46,7 @@ export const buildDmsFileDisplayUrl = (relativePath: string): string => {
 
 /**
  * 上传一个附件,返回**相对路径**(如 `/static/photo/1889-c_question-<uuid>.png`)。
- * 任何失败都抛 `Error`(消息可直接给用户看),由调用方更新上传项状态。
+ * 任何失败都抛 `Error`(消息可直接给用户看)。
  */
 export const uploadDmsAttachment = async (
   file: File,
@@ -65,27 +57,68 @@ export const uploadDmsAttachment = async (
     throw new Error("未配置 DMS 接口地址,无法上传附件");
   }
 
-  const form = new FormData();
-  form.append("file", file);
-  form.append("type", options.isImage ? DMS_ATTACHMENT_TYPE_IMAGE : DMS_ATTACHMENT_TYPE_FILE);
-  form.append("columnId", String(DMS_ATTACHMENT_COLUMN_ID));
-  form.append("contentId", String(DMS_ATTACHMENT_CONTENT_ID));
-  form.append("paraName", DMS_ATTACHMENT_PARA_NAME);
+  const buildForm = () => {
+    const form = new FormData();
+    form.append("file", file);
+    form.append("type", options.isImage ? DMS_ATTACHMENT_TYPE_IMAGE : DMS_ATTACHMENT_TYPE_FILE);
+    form.append("columnId", String(DMS_ATTACHMENT_COLUMN_ID));
+    form.append("contentId", String(DMS_ATTACHMENT_CONTENT_ID));
+    form.append("paraName", encodeDmsFieldName(DMS_ATTACHMENT_PARA_NAME));
+    return form;
+  };
 
-  const response = await fetch(`${base}/file/uploadFile`, {
-    method: "POST",
-    body: form,
-    signal: options.signal,
-  });
+  const sendOnce = async (token: string) => {
+    const headers: Record<string, string> = {};
+    if (token) headers.token = token;
+    return fetch(`${base}/file/uploadFile`, {
+      method: "POST",
+      headers,
+      body: buildForm(),
+      signal: options.signal,
+    });
+  };
+
+  let token = "";
+  try {
+    token = await getServiceDmsToken();
+  } catch {
+    /* 没 token 时仍发一次,交给代理/nginx 兜底注入 */
+  }
+
+  let response = await sendOnce(token);
+  let payload: { code?: number; message?: string; content?: string } | null = null;
+  try {
+    payload = await response.json();
+  } catch {
+    payload = null;
+  }
+
+  // token 失效:清缓存换一次再试
+  if (payload && (payload.code === 201 || payload.code === 212) && token) {
+    clearServiceDmsToken();
+    try {
+      token = await getServiceDmsToken();
+    } catch {
+      token = "";
+    }
+    if (token) {
+      response = await sendOnce(token);
+      try {
+        payload = await response.json();
+      } catch {
+        payload = null;
+      }
+    }
+  }
 
   if (!response.ok) {
     throw new Error(`上传失败(HTTP ${response.status})`);
   }
 
-  const payload = await response.json().catch(() => null);
   const path = String(payload?.content ?? "").trim();
   if (payload?.code !== 200 || !path) {
     throw new Error(String(payload?.message || "上传失败"));
   }
   return path;
 };
+

+ 16 - 24
src/network/api/dms/service-token.ts

@@ -1,31 +1,23 @@
 /**
- * 「未登录访客」用的 DMS token —— **由前端自己换**(用户 2026-09-29 指定)。
+ * 「未登录访客」用的 DMS token —— **由前端自己换**。
  *
- * 换取方式:`POST {VITE_OAUTH_LOGIN_URL}`(默认 `/oauth/api/user/login`,**同源路径**:
- * dev 走 vite 代理 → `http://121.43.55.7:10086`,生产由 nginx 做同样转发),
- * 用**服务账号**换一个 JWT —— 响应体里 `message` 就是 token(不是 `content`),
- * 另有 `content.failureTime`(epoch 毫秒)可当过期时间。
+ * 换取方式:`POST {VITE_OAUTH_LOGIN_URL}/api/user/login`(前缀默认 `/oauth`,**同源**:
+ * dev 走 vite 代理,生产由 nginx 转发),用**服务账号**换一个 JWT ——
+ * **token 在响应的 `message` 字段里**(不是 `content`),另有 `content.failureTime`(epoch 毫秒)可当过期时间。
  *
- * ⚠️⚠️ **这个方案有两个已知代价,用户已知悉并要求这样做**:
- *   1. **服务账号的密码就在下面的 `SERVICE_ACCOUNT` 里** —— 前端产物是公开的,
- *      任何人从 bundle 里抠出来都能登录 DMS。
- *      📌 **该账号是用户专门准备的「默认账号」(用户 2026-09-29 确认:已处理过,就是给这个用途的)**
- *      ⇒ 不要再拿它去申请别的权限;**要收紧/改密码时**:改密码 → 同步改这里与
- *      `harness/tools/dms-login.mjs` 的 `DEFAULT_ACCOUNT` → 重新构建。
- *   2. **token 会落到浏览器**(本模块缓存在内存里)—— 任何能打开站点的人都能拿到一个
- *      DMS 令牌(技术债 #8 记的就是这件事)。
- *   正路是后端按调用者身份做**行级授权**(技术债 #8 的推荐方案),届时本模块可以删掉。
+ * ⚠️ **服务账号的密码就在下面的 `SERVICE_ACCOUNT` 里,会被打进公开的前端产物**
+ * (任何人从 bundle 里抠出来都能登录 DMS;token 也会落在浏览器内存里,见技术债 #8)。
  *
- * ⚠️ 与**代理注入**的关系(dev / 生产都适用):请求带上 `token` 头时,
- * vite 代理**不再覆盖**它(见 vite.config.ts 的 `/dms/` 注释);没带时才由代理注入
- * (命令行走代理的排查方式因此不受影响)。
+ * 缓存策略:内存缓存到**过期前 5 分钟**;并发调用共用同一个在途请求。
  */
 
-import { getOauthLoginUrl } from "@/utils/runtime-config";
+import { getOauthApiBaseUrl } from "@/utils/runtime-config";
+
+const OAUTH_LOGIN_PATH = "/api/user/login";
 
 /**
- * 服务账号(**用户 2026-09-29 给的默认账号**)。
- * ⚠️ 它会被打进公开的前端产物 —— 见文件顶部的代价说明,别拿它当有权限的个人账号用。
+ * 服务账号。
+ * ⚠️ 它会被打进公开的前端产物,别拿它当有权限的个人账号用(见文件顶部说明)。
  */
 const SERVICE_ACCOUNT = {
   userName: "user_yszs_web",
@@ -48,7 +40,7 @@ interface CachedToken {
 
 /** 兜底有效期:拿不到 `content.failureTime` 时按 12 小时估(实测签发的 token 就是 12 小时) */
 const FALLBACK_LIFETIME_MS = 12 * 60 * 60 * 1000;
-/** 提前多久视为过期(留出重试余量) */
+/** 提前多久视为过期(见文件头部的缓存策略) */
 const REFRESH_AHEAD_MS = 5 * 60 * 1000;
 
 let cached: CachedToken | null = null;
@@ -58,14 +50,14 @@ const isValid = () => !!cached && cached.expireAt - Date.now() > REFRESH_AHEAD_M
 
 /**
  * 取一个服务账号 token(带缓存;并发调用共用同一个在途请求)。
- * 失败时抛错 —— 调用方(`client.ts`)自己决定是降级还是重试。
+ * 失败时抛错 —— 调用方(`client.ts`)自己决定降级还是重试。
  */
 export const getServiceDmsToken = async (): Promise<string> => {
   if (isValid()) return (cached as CachedToken).token;
   if (inflight) return inflight;
 
   inflight = (async () => {
-    const url = getOauthLoginUrl();
+    const url = `${getOauthApiBaseUrl()}${OAUTH_LOGIN_PATH}`;
     const response = await fetch(url, {
       method: "POST",
       headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -76,7 +68,7 @@ export const getServiceDmsToken = async (): Promise<string> => {
     try {
       body = (await response.json()) as OauthLoginResponse;
     } catch {
-      /* 非 JSON:下面按状态码/空 body 报错 */
+      /* 非 JSON:按状态码/空 body 报错 */
     }
 
     const token = String(body?.message || "").trim();

+ 6 - 6
src/network/api/enterprise.ts

@@ -58,13 +58,13 @@ export async function fetchEnterpriseLogin(accessToken: string): Promise<{ credi
     });
 
     if (!response.ok) {
-      console.warn('enterprise login failed:', response.status);
+      /* console.warn('enterprise login failed:', response.status); */
       return null;
     }
 
     const data: EnterpriseLoginResponse = await response.json();
     if (data.err_code !== 0 || !data.ret?.credit_code) {
-      console.warn('enterprise login error:', data.err_msg);
+      /* console.warn('enterprise login error:', data.err_msg); */
       return null;
     }
 
@@ -73,7 +73,7 @@ export async function fetchEnterpriseLogin(accessToken: string): Promise<{ credi
       authToken: data.ret.access_token || '',
     };
   } catch (error) {
-    console.warn('enterprise login error:', error);
+    /* console.warn('enterprise login error:', error); */
     return null;
   }
 }
@@ -91,19 +91,19 @@ export async function fetchEnterpriseInfo(creditCode: string): Promise<Enterpris
     });
 
     if (!response.ok) {
-      console.warn('fetch enterprise info failed:', response.status);
+      /* console.warn('fetch enterprise info failed:', response.status); */
       return null;
     }
 
     const data: EnterpriseInfoResponse = await response.json();
     if (data.err_code !== 0) {
-      console.warn('fetch enterprise info error:', data.err_msg);
+      /* console.warn('fetch enterprise info error:', data.err_msg); */
       return null;
     }
 
     return data.ret;
   } catch (error) {
-    console.warn('fetch enterprise info error:', error);
+    /* console.warn('fetch enterprise info error:', error); */
     return null;
   }
 }

+ 6 - 8
src/network/api/nls-token.ts

@@ -1,16 +1,14 @@
 /**
  * 取阿里云 NLS 的 token(实时语音识别用)——**走后端网关**。
  *
- * 端点是 `{VITE_NLS_TOKEN_API}`(默认 `/aly-api/proxy/verify/getALYToken`,同源路径:
- * dev 由 vite 代理到 `http://121.43.55.7:10011`,生产由 nginx 转发)。
+ * 端点是 `{VITE_GATEWAY_API}/proxy/verify/getALYToken`(默认 `/api/…`,同源路径:
+ * 与取本机 IP 共用一条代理;dev 由 vite 代理,生产由 nginx 转发)。
  * 返回形态:`{ code: 200, message: "成功", content: "<32 位 token>" }`。
  *
- * **前端不签 token**:签名要 AccessKeySecret,而前端 bundle 是公开的;
- * 网关侧(或后端)做这件事,前端只拿结果。
- * 与阿里云 `CreateToken` 的差别:网关**不返回过期时间**,按官方文档的 24 小时估。
+ * **前端不持有任何阿里云凭据**:签名要 AccessKeySecret,而前端 bundle 是公开的,
+ * 签名在网关侧做,前端只拿结果。网关**不返回过期时间**,按官方文档的 24 小时估。
  *
- * 缓存策略:缓存到过期前 5 分钟;并发调用共用同一个在途请求
- * (说话时前端可能连着触发几次,不该打多次网关)。
+ * 缓存策略:缓存到**过期前 5 分钟**;并发调用共用同一个在途请求。
  */
 
 import { getNlsAppkey, getNlsTokenApiUrl } from "@/utils/runtime-config";
@@ -73,7 +71,7 @@ export const fetchNlsToken = async (): Promise<NlsTokenResult> => {
 /** NLS 的 appkey(识别请求要带)—— 见 `getNlsAppkey` 的说明 */
 export const getNlsTokenAppkey = (): string => getNlsAppkey();
 
-/** 丢掉缓存(排查用:怀疑 token 失效时强制重取) */
+/** 丢掉缓存(怀疑 token 失效时强制重取) */
 export const clearNlsTokenCache = (): void => {
   cached = null;
 };

+ 5 - 33
src/types/record.ts

@@ -1,10 +1,4 @@
-/**
- * 消息状态(渲染层与记录组件共用)。
- *
- * 2026-09-24 从 `components/stream-message-coordinator.ts` 搬过来 —— 那个协调器
- * 整个类已是死代码(无人实例化)被删除,但这个枚举**是活的**
- * (`BusinessRecord.vue` 与 `types/record.ts` 在用,取值不能动)。
- */
+/** 消息状态(渲染层与记录组件共用;取值不能动) */
 export enum MessageState {
   Update,
   Sending,
@@ -15,24 +9,14 @@ export enum MessageState {
   End,
 }
 
-/***
- * Message Owner
- * 1. AI: Message from AI
- * 2. User: Message from User
- */
+/** 消息来源:AI / User / Tips */
 export enum From {
   AI,
   User,
   Tips,
 }
 
-/***
- * Feedback Type
- * 1. None: No feedback
- * 2. Like: Like
- * 3. Dislike: Dislike
- * 4. Unselected: Unselected
- */
+/** 反馈类型:None 未反馈 / Like 赞 / Dislike 踩 / Unselected 未选 */
 export enum FeedbackType {
   None,
   Like,
@@ -40,26 +24,14 @@ export enum FeedbackType {
   Unselected,
 }
 
-/***
- * Media Type
- * 1. Video: Video
- * 2. Image: Image
- * 3. NotFound: Not Found
- */
+/** 媒体类型 */
 export enum MediaType {
   Video,
   Image,
   NotFound,
 }
 
-/***
- * Media
- * 1. id: Media id
- * 2. type: Media type
- * 3. url: Media url
- * 4. width: Media width
- * 5. height: Media height
- */
+/** 媒体:id / 类型 / 地址 / 宽高 */
 export type Media = {
   id: String;
   type: MediaType;

+ 7 - 12
src/utils/app-message.ts

@@ -1,15 +1,11 @@
 /**
  * 全局轻提示(顶部居中、过一会儿自动消失)。
  *
- * 用 **Ant Design Vue 的 `message`**(本项目已依赖 ant-design-vue 4.x,CSS-in-JS 自带样式)——
- * 就是设计稿那种「粉底 + 红色感叹号的小条」,不用自己实现。
+ * 用 **Ant Design Vue 的 `message`**(本项目已依赖 ant-design-vue 4.x,CSS-in-JS 自带样式)
+ * —— 就是设计稿那种「粉底 + 红色感叹号的小条」。
  *
- * 为什么换掉原来的 `<Toast>` 组件(用户 2026-09-23:「现在的 toast 太丑了」):
- * 那个组件是**全屏黑遮罩 + 居中白框**(`background: rgba(0,0,0,.4)`),本质是模态框的观感,
- * 而且会挡住整页、必须等它自己消失或点一下;用户要的是**从顶部弹出、自动消失**的轻提示。
- *
- * 为什么再包一层:① 统一时长与默认类型 ② 调用方不必到处 import antd
- * ③ 将来换实现(或换成项目自绘)只改这一个文件。
+ * 再包一层是为了统一时长与默认类型,调用方不必到处 import antd,
+ * 将来换实现也只改这一个文件。
  */
 
 import { message } from "ant-design-vue";
@@ -21,8 +17,7 @@ export const APP_MESSAGE_DURATION = 2;
 
 /**
  * 弹一条轻提示。`text` 为空时什么都不做(避免弹出一个空气泡)。
- *
- * 默认 `error`(红):现有调用点都是「这事没成」——上传格式不支持、会话正在生成中等。
+ * 默认 `error`(红):现有调用点都是「这事没成」。
  */
 export const showAppMessage = (text: string, type: AppMessageType = "error"): void => {
   const content = String(text ?? "").trim();
@@ -36,10 +31,10 @@ export const showAppMessage = (text: string, type: AppMessageType = "error"): vo
     // ⚠️ antd 的 message 返回 Promise(挂载/更新是异步的)——失败要吞掉,
     // 否则会变成 unhandled rejection(提示失败不该反过来影响业务)
     void Promise.resolve(api[type](content, APP_MESSAGE_DURATION)).catch((err) => {
-      console.warn("[app-message] 弹出提示失败:", (err as Error)?.message || err);
+      /* console.warn("[app-message] 弹出提示失败:", (err as Error)?.message || err); */
     });
   } catch (err) {
-    console.warn("[app-message] 弹出提示失败:", (err as Error)?.message || err);
+    /* console.warn("[app-message] 弹出提示失败:", (err as Error)?.message || err); */
   }
 };
 

+ 9 - 13
src/utils/client-ip.ts

@@ -1,20 +1,16 @@
 /**
- * 取本机对外 IP —— 用于 DMS 的**访客归属**(用户 2026-09-22 要求)。
+ * 取本机对外 IP —— 用于 DMS 的**访客归属**。
  *
- * 接口:`GET {VITE_IP_API}/proxy/verify/getIPByBrowser`
- * 返回**纯文本 IP**(响应头 Content-Type 标的是 `application/json`,但正文就是裸 IP,
- * 实测 `116.232.248.128`)—— 所以**按 text 读、不要 JSON.parse**。
+ * 接口:`GET {VITE_GATEWAY_API}/proxy/verify/getIPByBrowser`,返回**纯文本 IP**
+ * (响应头标的是 `application/json`,但正文就是裸 IP)—— 所以**按 text 读、不要 JSON.parse**。
  *
- * ⚠️ **必须走同源代理**:接口是 `http://121.43.55.7:10011`,而页面是 HTTPS。
- * 接口本身带 `Access-Control-Allow-Origin: *`(跨域不成问题),但**混合内容**会被浏览器
- * 直接拦掉 —— 与聊天后端同一类问题。所以 dev 走 vite 代理(`vite.config.ts` 的 `/ip-api/`),
- * 生产需要 nginx 等价转发。
+ * ⚠️ **必须走同源代理**:接口是 HTTP、页面是 HTTPS,直连会被混合内容拦掉。
  *
  * 拿不到(没配代理 / 超时 / 返回不是 IP)一律返回空串、**不抛异常**:
- * 调用方会退回旧的「访客_<浏览器随机 id>」——**绝不能因为取不到 IP 就写不进 DMS**。
+ * 调用方会退回「访客_<浏览器随机 id>」,**绝不能因为取不到 IP 就写不进 DMS**。
  */
 
-import { getClientIpApiBaseUrl } from "@/utils/runtime-config";
+import { getGatewayApiBaseUrl } from "@/utils/runtime-config";
 
 /** 进程内缓存:一次会话里 IP 不会变,没必要每轮都问 */
 let cachedIp = "";
@@ -33,7 +29,7 @@ export const ensureClientIp = (): Promise<string> => {
   if (cachedIp) return Promise.resolve(cachedIp);
   if (inflight) return inflight;
 
-  const base = getClientIpApiBaseUrl();
+  const base = getGatewayApiBaseUrl();
   if (!base) {
     return Promise.resolve(""); // 没配代理:静默退回随机 id,不打扰用户
   }
@@ -47,10 +43,10 @@ export const ensureClientIp = (): Promise<string> => {
       if (isIpLike(text)) {
         cachedIp = text;
       } else {
-        console.warn("[client-ip] 返回的内容不像 IP,已忽略:", JSON.stringify(text.slice(0, 40)));
+        /* console.warn("[client-ip] 返回的内容不像 IP,已忽略:", JSON.stringify(text.slice(0, 40))); */
       }
     } catch (err) {
-      console.warn("[client-ip] 取 IP 失败(DMS 访客归属退回浏览器随机 id):", (err as Error)?.message || err);
+      /* console.warn("[client-ip] 取 IP 失败(DMS 访客归属退回浏览器随机 id):", (err as Error)?.message || err); */
     } finally {
       clearTimeout(timer);
       inflight = null;

+ 5 - 10
src/utils/interrupted-message.ts

@@ -10,7 +10,7 @@ const stripEmptyScopeBlocks = (content: string) => {
 };
 
 const stripScopeBlocks = (content: string) => {
-  // 先移除完整的 <scope>...</scope> 块,再移除未闭合的 <scope...> 开始标签及其后续内容
+  // 先移除完整的 <scope>…</scope> 块,再移除未闭合的 <scope…> 开始标签及其后续内容
   return content
     .replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, "")
     .replace(/<scope\b[^>]*>[\s\S]*/gi, "");
@@ -21,16 +21,11 @@ const stripNonVisibleBlocks = (content: unknown) =>
   stripScopeBlocks(stripSilenceTags(String(content || "")));
 
 /**
- * 内容在"可见"意义下是否为空。
+ * 内容在"可见"意义下是否为空 —— ⚠️ 这是**唯一**的判空口径:
+ * `normalizeSessionHistory`(shared.ts)与本文件的 `isInterruptedEmptyScopeMessage` 都必须用它。
  *
- * ⚠️ 这是**唯一**的判空口径。`normalizeSessionHistory`(shared.ts)与本文件的
- * `isInterruptedEmptyScopeMessage` 都必须用它。
- *
- * 曾经两处口径不一致——一处用 `content.trim()`(`<scope>` 进度块算作有内容),
- * 一处用 `stripScopeBlocks()`(进度块不算内容)。而新协议的正文要等到 `done`
- * 才写入,处理期间内容**只有进度块**,于是切会话时:
- * 归一化认为"有内容"→ 不替换文案、把状态翻成 Finish;
- * 渲染层认为"没内容"→ 显示「请求已取消」。请求其实还在跑,属于误报。
+ * 两处口径不一致会误报:新协议的正文要等到 `done` 才写入,处理期间内容**只有 `<scope>` 进度块**,
+ * 一处认为"有内容"、另一处认为"没内容",就会出现「请求已取消」的假提示。
  */
 export const hasVisibleMessageContent = (content: unknown): boolean =>
   stripNonVisibleBlocks(content).trim().length > 0;

+ 80 - 0
src/utils/policy-library-loader.ts

@@ -0,0 +1,80 @@
+/**
+ * 惠企政策库加载(只读):
+ * 远端路径来自 `public/config.js` 的 `dms.policyRemotePath`
+ * (DMS uploadFile 会改名,必须填实际上传返回的相对路径)。
+ * 未配置或取不到 → 随包本地副本。
+ */
+
+import { getDmsApiBaseUrl } from "@/utils/runtime-config";
+
+export const POLICY_LIBRARY_FILE_NAME = "policies_public.v2.json";
+export const POLICY_LIBRARY_LOCAL_URL = `/merchant-agent/${POLICY_LIBRARY_FILE_NAME}`;
+
+type FetchResult =
+  | { ok: true; data: unknown }
+  | { ok: false; status: number; error: unknown };
+
+const applyPolicies = (data: unknown, source: string): void => {
+  const list = Array.isArray(data) ? data : (data as { data?: unknown })?.data;
+  globalThis.policiesPublic = Array.isArray(list) ? list : [];
+  /* console.log(
+    `%c[zhaoshang] 政策列表加载完成(${source}):${globalThis.policiesPublic.length} 条`,
+    "color:green"
+  ); */
+};
+
+const fetchPolicyJson = async (url: string): Promise<FetchResult> => {
+  try {
+    const response = await fetch(url);
+    if (!response.ok) {
+      return { ok: false, status: response.status, error: new Error(`HTTP ${response.status}`) };
+    }
+    const data = await response.json();
+    return { ok: true, data };
+  } catch (error) {
+    return { ok: false, status: 0, error };
+  }
+};
+
+/** 读 config.js 里配置的 DMS 相对路径(空 = 不走远端) */
+export const getPolicyLibraryRemotePath = (): string => {
+  const root = (globalThis as { __ZHAOSHANG_CONFIG__?: { dms?: { policyRemotePath?: string } } })
+    .__ZHAOSHANG_CONFIG__;
+  return String(root?.dms?.policyRemotePath || "").trim();
+};
+
+/** 拼出可 fetch 的完整 URL;未配置远端时返回空 */
+export const getPolicyLibraryRemoteUrl = (): string => {
+  const rel = getPolicyLibraryRemotePath();
+  if (!rel) return "";
+  if (/^https?:\/\//i.test(rel)) return rel;
+  const base = getDmsApiBaseUrl();
+  if (!base) return "";
+  return `${base}${rel.startsWith("/") ? "" : "/"}${rel}`;
+};
+
+const loadLocalFallback = async (): Promise<void> => {
+  const local = await fetchPolicyJson(POLICY_LIBRARY_LOCAL_URL);
+  if (local.ok) {
+    applyPolicies(local.data, "local");
+    return;
+  }
+  /* console.warn("[zhaoshang] 政策列表本地兜底也失败:", local.error); */
+  globalThis.policiesPublic = [];
+};
+
+/** 启动时调用:config 远端 → 本地兜底(不上传)。 */
+export const ensurePolicyLibrary = async (): Promise<void> => {
+  const remoteUrl = getPolicyLibraryRemoteUrl();
+
+  if (remoteUrl) {
+    const remote = await fetchPolicyJson(remoteUrl);
+    if (remote.ok) {
+      applyPolicies(remote.data, "dms");
+      return;
+    }
+    /* console.warn("[zhaoshang] 政策列表远端不可用,改用本地:", remoteUrl, remote.error); */
+  }
+
+  await loadLocalFallback();
+};

+ 4 - 13
src/utils/question-cards-answers.ts

@@ -1,16 +1,9 @@
 /**
  * 问卷(`<question-cards>` 行)**作答后**的内容改写 —— 纯函数,便于 harness 直接测。
  *
- * 背景(2026-09-24 用户反馈):「如果下方已经新对话了,上方的问卷应该是只读状态」。
- * `QuestionCard.vue` 本来就有只读渲染分支(显示已选项 / 已填文本),但它的判定是
- * `isReadonly = 解析 content 得到 answers` —— 而**此前没有任何代码把答案写回 content**,
- * 于是只读分支永远走不到:提交过的问卷在新一轮对话出现后仍然可点可填。
- *
- * 这里负责「把提交的答案写回那一行」:把内容里**最后一个**
- * `<question-cards>…</question-cards>` 块的内部替换成 `{answers:[…]}`(只读分支认的形状)。
- * 写回后:
- *   · 卡片立刻渲染成只读(内容变了 → 渲染层整体重解析,见 BusinessRecord 的 content watcher)
- *   · `saveHistory()` 落盘后,刷新页面仍是已作答态
+ * 负责「把提交的答案写回那一行」:把内容里**最后一个** `<question-cards>…</question-cards>`
+ * 块的内部替换成 `{answers:[…]}`(`QuestionCard.vue` 的只读分支认这个形状)。
+ * 写回后卡片立刻渲染成只读,且 `saveHistory()` 落盘后刷新页面仍是已作答态。
  */
 
 import type { QuestionCardAnswer } from "./question-cards-content";
@@ -38,9 +31,7 @@ export const buildAnsweredQuestionCardsContent = (answers: QuestionCardAnswer[])
  * 把内容里**最后一个** `<question-cards>…</question-cards>` 改成"已作答"的内容。
  *
  * ⚠️ **保留原有字段(尤其 `questions`),只补/换 `answers`**:
- * 只读态要求"选完还是问卷本来的样子",而那份样式(`(单选)` 标签、选项 A/B、提交按钮…)
- * 全靠 `questions` 渲染出来 —— 上一版把整块替换成只有 `answers` 的 JSON,
- * 结果只读渲染只能退化成一个简陋的小框(用户 2026-09-24:「问卷长什么样选完后还是长什么样」)。
+ * 只读态要求"选完还是问卷本来的样子",而那份样式全靠 `questions` 渲染出来。
  *
  * 找不到标记(或标记不成对)时**原样返回** —— 宁可不动,也不要破坏内容里的其它行。
  */

+ 3 - 6
src/utils/question-cards-content.ts

@@ -1,12 +1,9 @@
 /**
  * 问卷内容(`<question-cards>…</question-cards>` 行)的**解析**——纯函数,可被 harness 直接测。
  *
- * 为什么单独抽出来(2026-09-24):渲染层交给 `QuestionCard` 的 `content` 是
- * **带标记的整段**(切行器 `StreamXMLFilter` 给 block 行产出的就是 `openTag + inner + closeTag`,
- * 见 `handleBehavior`)。而组件的两个解析函数一个剥标记、一个不剥 ——
- * 于是"作答后写回 `{answers:…}`"时只读分支解析失败、交互分支也没有 `questions`,
- * **整张卡片渲染成空**(用户报「提交完问卷直接看不见了」)。
- * 现在两处都走这里的同一个解包逻辑,并且 harness 会用**真实切行器**跑一遍往返。
+ * 渲染层交给 `QuestionCard` 的 `content` 是**带标记的整段**(切行器 `StreamXMLFilter` 给 block 行
+ * 产出的就是 `openTag + inner + closeTag`),所以两处解析必须走这里同一个解包逻辑 ——
+ * 否则"作答后写回 `{answers:…}`"时只读分支解析失败、交互分支也没有 `questions`,整张卡片渲染成空。
  */
 
 export interface QuestionOption {

+ 25 - 40
src/utils/runtime-config.ts

@@ -1,7 +1,6 @@
 /**
  * 新版聊天协议后端基础地址(POST /api/chat,SSE)。
- * 优先取 index.html 注入的 globalThis.VITE_CHAT_API(部署时可改),
- * 回退到构建期环境变量 VITE_CHAT_API。末尾斜杠会去掉。
+ * 取值链:`globalThis` 注入(index.html,部署时可改)→ 构建期环境变量 → 空。末尾斜杠会去掉。
  */
 export function getChatApiBaseUrl() {
   const bootstrap = ((globalThis as any).VITE_CHAT_API || "") as string;
@@ -11,11 +10,10 @@ export function getChatApiBaseUrl() {
 }
 
 /**
- * DMS(数据管理服务)基础地址。
+ * DMS(数据管理服务)基础地址(取值链同 getChatApiBaseUrl)。
  *
- * 开发环境是 `/dms`(vite 同源代理,见 vite.config.ts)——代理负责
- * 把 `/dms/...` 直通到 DMS 主机,并把 token 注入到请求头,**前端不持有 token**。
- * 写法与 getChatApiBaseUrl 一致(globalThis 注入优先,构建期环境变量兜底)。
+ * 开发环境是 `/dms`(vite 同源代理)——代理把 `/dms/...` 直通到 DMS 主机,
+ * 并在请求没带 token 时注入一个。
  */
 export function getDmsApiBaseUrl() {
   const bootstrap = ((globalThis as any).VITE_DMS_API || "") as string;
@@ -24,30 +22,11 @@ export function getDmsApiBaseUrl() {
   return value.trim().replace(/\/+$/, "");
 }
 
-/**
- * NLS(阿里云智能语音交互)**token 端点**——后端网关提供的 `getALYToken`。
- *
- * 前端**不持有任何云凭据**:token 由后端网关签发(它自己做阿里云签名),
- * 前端只拿 token。**必须走同源路径**:网关只有 HTTP,而页面是 HTTPS,
- * 直连会被混合内容拦掉 —— dev 由 vite 代理 `/aly-api/`(见 vite.config.ts),
- * 生产由 nginx 做同样的事。
- *
- * 返回整条路径(不是前缀),可用 env / index.html 注入覆盖。
- */
-export function getNlsTokenApiUrl() {
-  const bootstrap = ((globalThis as any).VITE_NLS_TOKEN_API || "") as string;
-  const fromEnv = (import.meta.env.VITE_NLS_TOKEN_API || "") as string;
-  const value = bootstrap && bootstrap !== "%VITE_NLS_TOKEN_API%" ? bootstrap : fromEnv;
-  return (value || "/aly-api/proxy/verify/getALYToken").trim();
-}
-
 /**
  * NLS 的 appkey(识别请求里要带)。
  *
- * **值直接内置在这里**(用户 2026-09-28 要求:「将 accesskey 直接写入使用的地方,
- * 不再放在环境变量里」)—— 它**不是密钥**:换 token 不需要它(签名在后端网关做),
+ * **值直接内置在这里** —— 它**不是密钥**:换 token 不需要它(签名在后端网关做),
  * 而且每次识别请求本就会把它明文发出去,所以它本来就在公开产物里。
- * 内置后**不再需要部署侧注入**,也不要求 `.env.*.local` 里有它。
  *
  * 仍保留覆盖链(`globalThis` 注入 → 构建期 env → 内置值),方便临时换 appkey 而不改代码。
  */
@@ -61,28 +40,34 @@ export function getNlsAppkey() {
 }
 
 /**
- * DMS 登录(OAuth)的**完整路径** —— 前端给「未登录访客」换 DMS token 用
+ * OAuth(DMS 登录)同源前缀 —— 前端给「未登录访客」换 DMS token 用
  * (见 `src/network/api/dms/service-token.ts`)。
  *
- * 默认 `/oauth/api/user/login`:**同源路径**,dev 走 vite 代理 → `http://121.43.55.7:10086`,
- * 生产由 nginx 转发同样路径(网关只有 HTTP,而页面是 HTTPS ⇒ 直连会被混合内容拦掉)。
+ * env 只配前缀(默认 `/oauth`);登录路径 `/api/user/login` 由调用方拼接,
+ * 与 nginx/vite 对 `/oauth` 的反向代理对齐。
  */
-export function getOauthLoginUrl() {
+export function getOauthApiBaseUrl() {
   const bootstrap = ((globalThis as any).VITE_OAUTH_LOGIN_URL || "") as string;
   const fromEnv = (import.meta.env.VITE_OAUTH_LOGIN_URL || "") as string;
   const value = bootstrap && bootstrap !== "%VITE_OAUTH_LOGIN_URL%" ? bootstrap : fromEnv;
-  return (value || "/oauth/api/user/login").trim();
+  return (value || "/oauth").trim().replace(/\/+$/, "");
 }
 
 /**
- * 取本机 IP 的接口前缀(见 `src/utils/client-ip.ts`)。
- *
- * 与其它后端同一个套路:`index.html` 注入 `globalThis.VITE_IP_API`,拿不到就退回构建期变量。
- * **必须是同源路径**:接口只有 HTTP,页面是 HTTPS,直连会被混合内容拦掉。
+ * 后端网关同源前缀(取本机 IP、NLS token 等 `/proxy/verify/…`)。
+ * 与企业业务网关 `VITE_API` 不是一回事。默认 `/api`;nginx/vite 剥前缀后转到网关主机。
  */
-export function getClientIpApiBaseUrl() {
-  const bootstrap = ((globalThis as any).VITE_IP_API || "") as string;
-  const fromEnv = (import.meta.env.VITE_IP_API || "") as string;
-  const value = bootstrap && bootstrap !== "%VITE_IP_API%" ? bootstrap : fromEnv;
-  return value.trim().replace(/\/+$/, "");
+export function getGatewayApiBaseUrl() {
+  const bootstrap = ((globalThis as any).VITE_GATEWAY_API || "") as string;
+  const fromEnv = (import.meta.env.VITE_GATEWAY_API || "") as string;
+  const value = bootstrap && bootstrap !== "%VITE_GATEWAY_API%" ? bootstrap : fromEnv;
+  return (value || "/api").trim().replace(/\/+$/, "");
+}
+
+/**
+ * NLS token 完整 URL——后端网关 `getALYToken`。
+ * 前缀用 `VITE_GATEWAY_API`;路径 `/proxy/verify/getALYToken` 在此拼接(勿写进 env)。
+ */
+export function getNlsTokenApiUrl() {
+  return `${getGatewayApiBaseUrl()}/proxy/verify/getALYToken`;
 }

+ 33 - 149
src/utils/stream-xml-filter.ts

@@ -2,30 +2,20 @@ import { jsonrepair } from "jsonrepair";
 
 export enum RecordRowTypes {
   Text = "text",
-  Form = "form",
   Scope = "scope",
-  LinkURL = "link-url",
   ImageContent = "image-content",
-  OfficeURL = "file-docx",
-  UIForm = "ui-form",
   PolicyTable = "policy-table",
-  Silence = "silence",
-  FollowUp = "followup",
   RefLinks = "ref-links",
   QuestionCards = "question-cards",
-  EnterpriseNotification = "qingpu_enterprise_notificaion",
 }
 
 /**
  * `<!-- POLICY_TABLE … -->` 块的起止标记 —— **全仓库唯一一份**。
  *
- * ⚠️ **为什么放在这个 `.ts` 里,而不是各组件里各写一份**:Vite 的依赖预扫描把
- * **`.vue` 也当 HTML**(`htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/`),扫描前会先做一次
- * `raw.replace(/<!--.*?-->/gs, "<!---->")` —— 于是 `.vue` 的 `<script>` 里只要出现一个**裸 `<!--`**,
- * 它就会与**后面任意一个 `-->`**(哪怕隔着很多行)配成"HTML 注释"被整段吃掉。
- * 2026-09-28 实际踩过:BusinessRecord.vue 里一句注释的 `<!--` 与 9 行后正则里的 `-->` 配了对,
- * 冷扫描直接报 `Failed to scan for dependencies … Unexpected "."`。
- * **`.ts` 不在那份名单里** ⇒ 字面量收在这里是安全的;组件请 import 本文件的常量。
+ * ⚠️ **必须放在 `.ts` 里**:Vite 的依赖预扫描把 **`.vue` 也当 HTML**,扫描前会做一次
+ * `raw.replace(/<!--.*?-->/gs, "<!---->")` —— `.vue` 的 `<script>` 里只要出现一个**裸 `<!--`**,
+ * 就会被与后面任意一个 `-->`(哪怕隔着很多行)配成"HTML 注释"整段吃掉,冷扫描直接报错。
+ * `.ts` 不在那份名单里,所以字面量收在这里是安全的;组件请 import 本文件的常量。
  */
 export const POLICY_TABLE_START = "<!-- POLICY_TABLE";
 export const POLICY_TABLE_END = "POLICY_TABLE -->";
@@ -33,8 +23,8 @@ export const POLICY_TABLE_END = "POLICY_TABLE -->";
 type TagRule = {
   name: RecordRowTypes;
   behavior: "skip" | "block";
-  open: string; // e.g., form, scope, link-url, file-docx (无尖括号)
-  close: string; // e.g., /form, /scope, /link-url, /file-docx (无尖括号)
+  open: string; // 开始标签名(无尖括号)
+  close: string; // 结束标签名(无尖括号)
 };
 
 interface ActiveTag {
@@ -42,7 +32,6 @@ interface ActiveTag {
   openTag: string;
   inner: string;
   attrs: Record<string, string>;
-  nestedFilter?: StreamXMLFilter;
 }
 
 export class StreamXMLFilter {
@@ -61,40 +50,13 @@ export class StreamXMLFilter {
     tagRules?: TagRule[];
     customProcessor?: Map<RecordRowTypes, (content: any) => void>;
   } = {}) {
+    // 规则表只留新协议适配层真正产出的 4 条:scope / image-scope / ref_links / question-cards;
+    // 特殊块 `<!-- POLICY_TABLE … -->` 不走规则表(见 processPolicyTableBlock)。
     this.tagRules = tagRules || [
-      { name: RecordRowTypes.Silence, behavior: "skip", open: "silence", close: "/silence" },
-      { name: RecordRowTypes.Form, behavior: "block", open: "form", close: "/form" },
       { name: RecordRowTypes.Scope, behavior: "block", open: "scope", close: "/scope" },
-      { name: RecordRowTypes.LinkURL, behavior: "block", open: "link-url", close: "/link-url" },
       { name: RecordRowTypes.ImageContent, behavior: "block", open: "image-scope", close: "/image-scope" },
-      // 旧协议的 <policy-list-updated> 规则已删(2026-09-28,用户确认旧对话仅测试、后续清空):
-      // 新协议适配层从不产出该块。旧测试会话清空之前,若有人打开,那串原始 JSON 会当普通文本显示。
       { name: RecordRowTypes.RefLinks, behavior: "block", open: "ref_links", close: "/ref_links" },
-      {
-        name: RecordRowTypes.OfficeURL,
-        behavior: "block",
-        open: "file-docx",
-        close: "/file-docx",
-      },
-      {
-        name: RecordRowTypes.UIForm,
-        behavior: "skip",
-        open: "ui-form",
-        close: "/ui-form",
-      },
-      {
-        name: RecordRowTypes.FollowUp,
-        behavior: "skip",
-        open: "followup",
-        close: "/followup",
-      },
       { name: RecordRowTypes.QuestionCards, behavior: "block", open: "question-cards", close: "/question-cards" },
-      {
-        name: RecordRowTypes.EnterpriseNotification,
-        behavior: "skip",
-        open: "qingpu_enterprise_notificaion",
-        close: "/qingpu_enterprise_notificaion",
-      },
     ];
 
     this.customProcessor = customProcessor || new Map();
@@ -155,13 +117,11 @@ export class StreamXMLFilter {
     return startToken.toLowerCase().startsWith(buffer.toLowerCase());
   }
 
-  /* ===========================
-   * ✅ 新增:解析 openTag 属性
-   * =========================== */
+  /** 解析 openTag 上的属性 */
   private parseAttributes(openTag: string): Record<string, string> {
     const attrs: Record<string, string> = {};
 
-    // 去掉 <tag 和 >
+    // 去掉 <tag 与 >
     const attrPart = openTag.replace(/^<\w+/, "").replace(/\/?>$/, "").trim();
 
     if (!attrPart) return attrs;
@@ -196,16 +156,19 @@ export class StreamXMLFilter {
       }
 
       // ② tag 名字已完整,后面只能是 空格 或 >
+      // ⚠️ **必须先真的以规则名开头**:少了这一步,任意文本只要在第 `open.length` 个字符处
+      //    是空格/`>` 就会被误判为"可能的前缀"而**永久挂起**(既不产出行、也不吐文本)。
+      if (!afterLt.startsWith(open)) return false;
       const rest = afterLt.slice(open.length);
       return rest.startsWith(" ") || rest.startsWith(">");
     });
   }
 
   private keepPossibleCloseTail(rule: TagRule) {
-    // 我们要找的是 "<" + rule.close 的前缀,例如 "</sc" 是 "</scope" 的前缀
+    // 要找的是 "<" + rule.close 的前缀,例如 "</sc" 是 "</scope" 的前缀
     const target = `<${rule.close}`.toLowerCase();
 
-    // 找最后一个 '<',因为只有从 '<' 开始才可能是 close
+    // 只有从最后一个 '<' 开始才可能是 close
     const lastLt = this.buffer.lastIndexOf("<");
     if (lastLt === -1) {
       // 根本没有 '<',全部都是 inner
@@ -214,14 +177,14 @@ export class StreamXMLFilter {
       return;
     }
 
-    const tail = this.buffer.slice(lastLt); // 从最后一个 '<' 到末尾
+    const tail = this.buffer.slice(lastLt);
     const tailLower = tail.toLowerCase();
 
-    // 如果 tail 是 target 的前缀(例如 tail="</sc" target="</scope"),就保留 tail
+    // tail 是 target 的前缀 → 保留 tail,等下一个 chunk 拼接
     if (target.startsWith(tailLower)) {
       const safe = this.buffer.slice(0, lastLt); // '<' 之前的部分肯定不是 close 前缀
       if (safe) this.tagStack[this.tagStack.length - 1].inner += safe;
-      this.buffer = tail; // 保留疑似 close 前缀,等待下个 chunk 拼接
+      this.buffer = tail;
       return;
     }
 
@@ -230,43 +193,9 @@ export class StreamXMLFilter {
     this.buffer = "";
   }
 
-  private splitSafeContentBeforeCloseTail(rule: TagRule) {
-    const target = `<${rule.close}`.toLowerCase();
-    const lastLt = this.buffer.lastIndexOf("<");
-
-    if (lastLt === -1) {
-      const safe = this.buffer;
-      this.buffer = "";
-      return safe;
-    }
-
-    const tail = this.buffer.slice(lastLt);
-    if (target.startsWith(tail.toLowerCase())) {
-      const safe = this.buffer.slice(0, lastLt);
-      this.buffer = tail;
-      return safe;
-    }
-
-    const safe = this.buffer;
-    this.buffer = "";
-    return safe;
-  }
-
-  private getNestedFilter(active: ActiveTag) {
-    if (!active.nestedFilter) {
-      active.nestedFilter = new StreamXMLFilter({
-        tagRules: this.tagRules,
-        customProcessor: this.customProcessor,
-      });
-    }
-
-    return active.nestedFilter;
-  }
-
   /**
-   * 主解析函数:增量处理 chunk,并记录历史
-   * 支持标签名后带属性,如 <form id="abc"> ... </form>
-   * open/close 去掉尖括号,仅匹配标签名,接受属性
+   * 主解析函数:增量处理 chunk,并记录历史。
+   * 标签名后可带属性;open/close 不含尖括号,仅匹配标签名。
    */
   processChunk(chunk: string): { type: RecordRowTypes; content: string }[] {
     this.buffer += chunk;
@@ -280,9 +209,7 @@ export class StreamXMLFilter {
         continue;
       }
 
-      /* =====================================================
-       * 1. 如果当前在某个 tag 内(stack 非空)
-       * ===================================================== */
+      // 1. 当前在某个 tag 内(stack 非空)
       if (this.tagStack.length > 0) {
         const current = this.tagStack[this.tagStack.length - 1];
         const { rule } = current;
@@ -290,31 +217,6 @@ export class StreamXMLFilter {
         const closeRe = new RegExp(`([\\s\\S]*?)<${rule.close}(\\s|>)`, "i");
         const match = this.buffer.match(closeRe);
 
-        if (rule.name === RecordRowTypes.Silence) {
-          if (!match) {
-            const safe = this.splitSafeContentBeforeCloseTail(rule);
-            if (safe) {
-              current.inner += safe;
-              result.push(...this.getNestedFilter(current).processChunk(safe));
-            }
-            break;
-          }
-
-          const innerPart = match[1];
-          if (innerPart) {
-            current.inner += innerPart;
-            result.push(...this.getNestedFilter(current).processChunk(innerPart));
-          }
-
-          const closeStart = innerPart.length;
-          const closeEnd = this.buffer.indexOf(">", closeStart);
-          if (closeEnd === -1) break;
-
-          this.tagStack.pop();
-          this.buffer = this.buffer.slice(closeEnd + 1);
-          continue;
-        }
-
         this.handleBehavior({ ...current }, result, false);
 
         // 还没等到 close:不能把可能的 close 前缀吞掉,需要 pending tail
@@ -351,9 +253,7 @@ export class StreamXMLFilter {
         continue;
       }
 
-      /* =====================================================
-       * 2. TEXT 状态:寻找 <
-       * ===================================================== */
+      // 2. TEXT 状态:寻找 <
       const ltIndex = this.buffer.indexOf("<");
 
       // 没有 <,全是安全文本
@@ -370,9 +270,7 @@ export class StreamXMLFilter {
         continue;
       }
 
-      /* =====================================================
-       * 3. CANDIDATE:buffer 以 < 开头
-       * ===================================================== */
+      // 3. CANDIDATE:buffer 以 < 开头
       let matched = false;
 
       for (const rule of this.tagRules) {
@@ -409,7 +307,7 @@ export class StreamXMLFilter {
           attrs,
         });
 
-        // ✅ 识别到开始标签,立即输出 type。ref_links / question-cards 需要完整 JSON,等待闭合标签后再产出。
+        // 识别到开始标签,立即输出 type;ref_links / question-cards 需要完整 JSON,等闭合标签后再产出。
         if (
           rule.behavior === "block" &&
           rule.name !== RecordRowTypes.RefLinks &&
@@ -435,7 +333,7 @@ export class StreamXMLFilter {
       }
 
       if (!matched) {
-        // ⭐ 关键修复:判断是否“可能是 open tag 前缀”
+        // 判断是否“可能是 open tag 前缀”;是就等更多 chunk,不是才降级为文本
         if (this.isPossibleOpenPrefix(this.buffer)) {
           // 还不能确定,等待更多 chunk
           break;
@@ -447,9 +345,7 @@ export class StreamXMLFilter {
       }
     }
 
-    /* =====================================================
-     * 4. flush 剩余 text(非常关键)
-     * ===================================================== */
+    // 4. flush 剩余 text
     if (this.tagStack.length === 0 && this.textStaging) {
       result.push({
         type: RecordRowTypes.Text,
@@ -477,19 +373,7 @@ export class StreamXMLFilter {
   ) {
     const { rule, openTag, inner, closeTag, attrs } = active;
 
-    // 透明容器:silence 本身不产出文本,但它内部可能嵌套 followup。
-    // 这里把 inner 再交给一个同规则过滤器,只取标签事件,不取普通文本。
-    if (rule.name === RecordRowTypes.Silence && isFinal && inner) {
-      const nestedFilter = new StreamXMLFilter({
-        tagRules: this.tagRules,
-        customProcessor: this.customProcessor,
-      });
-      const nestedRows = nestedFilter.processChunk(inner);
-      result.push(...nestedRows);
-      return;
-    }
-
-    // ✅ skip:中间态 & 最终态都允许实时 processor
+    // skip:中间态 & 最终态都允许实时 processor
     if (rule.behavior === "skip" && inner) {
       const processor = this.customProcessor.get(rule.name);
       if (processor) {
@@ -498,7 +382,7 @@ export class StreamXMLFilter {
       return;
     }
 
-    // ✅ block:只有 close 才产出 row
+    // block:只有 close 才产出 row
     if (rule.behavior === "block" && isFinal) {
       const content = openTag + inner + closeTag!;
       result.push({ type: rule.name, content });
@@ -506,12 +390,12 @@ export class StreamXMLFilter {
     }
   }
 
-  /** ✅ 获取完整历史 */
+  /** 获取完整历史 */
   getHistory() {
     return this.history;
   }
 
-  /** ✅ 获取合并后的纯文本内容 */
+  /** 获取合并后的纯文本内容 */
   getPlainText() {
     return this.history
       .filter((h) => h.type === "text" || h.type === "block-text")
@@ -519,7 +403,7 @@ export class StreamXMLFilter {
       .join("");
   }
 
-  /** ✅ 清空全部历史(可选) */
+  /** 清空全部历史与解析状态 */
   reset() {
     this.buffer = "";
     this.activeTag = null;
@@ -539,7 +423,7 @@ export const parsePolicyTableContent = (content: string): any[] => {
     const parsed = JSON.parse(match[1].trim());
     return Array.isArray(parsed?.data) ? parsed.data : [];
   } catch (error) {
-    console.error("Failed to parse policy table json:", error);
+    /* console.error("Failed to parse policy table json:", error); */
     return [];
   }
 };
@@ -597,7 +481,7 @@ export const parseRefLinksContent = (content: string): RefLinkItem[] => {
       })
       .filter((item) => item.title || item.content || item.linkUrl || item.type);
   } catch (error) {
-    console.error("Failed to parse ref links json:", error);
+    /* console.error("Failed to parse ref links json:", error); */
     return [];
   }
 };

+ 18 - 45
vite.config.ts

@@ -8,24 +8,14 @@ import path from "path";
 
 import basicSsl from "@vitejs/plugin-basic-ssl";
 
-// DMS token 的读取口径收在 harness/tools/dms-token.mjs(与本仓库的 DMS 脚本共用一份):
-// **显式参数 → 环境变量 → 本机文件 `.dms-token`**。
-// 关键是「每个请求现读」而不是启动时读一次 —— token 约 24 小时过期,以前换一次就要重启 dev server。
+// DMS token 读取口(与仓库 DMS 脚本共用一份实现,harness/tools/dms-token.mjs):
+// 显式参数 → 环境变量 → 本机文件 `.dms-token`;每个请求现读,换 token 不用重启 dev server。
 import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from "./harness/tools/dms-token.mjs";
 
-// [已移除] dev 期自己签 NLS token 的中间件(`/nls-token/`)。
-// 2026-09-24 起 token 改由**后端网关**提供:`GET http://121.43.55.7:10011/proxy/verify/getALYToken`
-// → `{code:200, content:"<token>"}`,前端走同源 `/api/` 代理拿(2026-09-29 前叫 `/aly-api/`)。
-// 于是前端侧**不再需要 AccessKeySecret**,vite 也不必持凭据。
-// AK/SK 自签的能力仍留在 harness/tools/nls-token.mjs(工具侧退路,仅在没有网关时用)。
-
 /** 同一个状态只提醒一次,避免每个请求都刷屏 */
 let dmsTokenWarnedFor = "";
 
-/**
- * token 有问题时给出**明确**提示 —— 以前 token 过期是**静默失败**(DMS 写入不生效、
- * 只有一条埋在别处控制台里的 warn),排查「为什么数据没存上」要花很久。
- */
+/** token 缺失/过期时给出明确提示(否则 DMS 写入会静默失败,难排查) */
 const warnDmsTokenIfNeeded = (token: string): void => {
   if (!token) {
     if (dmsTokenWarnedFor === "missing") return;
@@ -57,7 +47,6 @@ const warnDmsTokenIfNeeded = (token: string): void => {
   }
 };
 
-// https://vitejs.dev/config/
 export default defineConfig(() => {
   return {
     plugins: [
@@ -70,48 +59,38 @@ export default defineConfig(() => {
     ],
     server: {
       proxy: {
-        // ── 代理总原则(2026-09-29 用户指定)────────────────────────────────
-        // 前缀**尽量与服务真实路径一致**,只有服务端本身不带前缀时才剥:
-        //   /oauth/**  → 121.43.55.7:10086,**直通**(真实路径就带 /oauth)
-        //   /dms/**    → 121.43.55.7:10081,**直通**(真实路径就带 /dms)
-        //   /api/**    → 121.43.55.7:10011,**剥掉 /api**(网关真实路径是 /proxy/verify/…,
-        //                实测直连 /api/proxy/… 是 404)
+        // 代理总原则:前缀尽量与服务真实路径一致,只有服务端本身不带前缀时才剥。
+        //   /oauth/**    → 121.43.55.7:10086,直通(真实路径就带 /oauth)
+        //   /dms/**      → 121.43.55.7:10081,直通(真实路径就带 /dms),请求没带 token 时代理注入
+        //   /api/**      → 121.43.55.7:10011,剥掉 /api(网关真实路径是 /proxy/verify/…)
         //   /chat-api/** → 聊天后端,剥前缀(后端真实路径是 /api/chat,与前端前缀不同名)
-        // 三台后端都只有 HTTP,而 dev server 是 HTTPS ⇒ 直连会被浏览器按混合内容拦掉,
+        // 三台后端都只有 HTTP 而 dev server 是 HTTPS,直连会被浏览器按混合内容拦掉,
         // 必须由服务端这一跳(dev 是 vite 代理,生产是 nginx)代劳。
-        // 2026-09-29 之前是 /dms-api(补 /dms) + /ip-api + /aly-api(两条打同一台) —— 现在合并。
-        //
-        // ⚠️ **target 直接写在这里,不从环境变量读**(用户 2026-09-29 明确要求
-        //    「vite.config.ts 里面不要加环境变量」)—— 所以本文件不再 `loadEnv`。
-        //    换地址就改这四处;`.env.*` 只管**产物里**用到的值(VITE_DMS_API 等同源前缀)。
+        // ⚠️ target 直接写在这里、不从环境变量读(所以本文件不 loadEnv),换地址就改这四处。
 
-        // 后端网关 121.43.55.7:10011:语音识别 token(/proxy/verify/getALYToken)
-        // 与取本机 IP(/proxy/verify/getIPByBrowser)**在同一台**,所以共用这一条。
+        // 后端网关:语音识别 token(/proxy/verify/getALYToken)与取本机 IP(/proxy/verify/getIPByBrowser)
+        // 在同一台,共用这一条。
         "/api/": {
           target: "http://121.43.55.7:10011",
           changeOrigin: true,
           rewrite: (path) => path.replace(/^\/api/, ""),
         },
-        // DMS 登录服务 121.43.55.7:10086(真实路径带 /oauth,所以直通、不改写)。
-        // 前端目前不直接调它(token 是 node 侧换的);留着是为了本地能自测登录接口。
+        // DMS 登录服务 121.43.55.7:10086(真实路径带 /oauth,所以直通、不改写):
+        // 未登录访客用服务账号换 DMS token 走这里。
         "/oauth/": {
           target: "http://121.43.55.7:10086",
           changeOrigin: true,
         },
         // DMS 数据管理服务(会话/问答记录/反馈)——真实路由都在 /dms/ 下,前缀同名,直通。
-        // token(DMS 的鉴权头名就叫 token,不是 Authorization):
-        //   ① **前端自己带**:未登录访客用服务账号现换一个(`src/network/api/dms/service-token.ts`,
-        //      2026-09-29 用户要求改成这套)—— 这种请求**代理不覆盖**它的 token;
-        //   ② 请求没带时才由代理兜底注入:读仓库根 `.dms-token`(gitignored)或进程环境变量
-        //      DMS_TOKEN(`readDmsToken()` 自己会读,见 harness/tools/dms-token.mjs)
-        //      —— 命令行走代理排查(curl / verify-dms-*.mjs)靠的就是这条。
-        // 现读而非静态 headers:换 token 不用重启 dev server。
+        // 鉴权头名是 token(不是 Authorization):前端自己带(service-token.ts)时**代理不覆盖**,
+        // 只有请求没带时才由代理兜底注入(读仓库根 `.dms-token` 或环境变量 DMS_TOKEN)。
+        // 每请求现读,所以换 token 不用重启 dev server。
         "/dms/": {
           target: "http://121.43.55.7:10081",
           changeOrigin: true,
           configure: (proxy) => {
             proxy.on("proxyReq", (proxyReq) => {
-              // 前端已经带了 token 就别覆盖(否则前端的刷新逻辑白做)
+              // 前端已经带了 token 就别覆盖
               const fromClient =
                 typeof proxyReq.getHeader === "function" ? proxyReq.getHeader("token") : undefined;
               if (fromClient) return;
@@ -123,25 +102,19 @@ export default defineConfig(() => {
             });
           },
         },
-        // 新版聊天后端(POST /api/chat,SSE):后端真实路径是 /api/chat,与前端前缀不同名 ⇒ 剥前缀。
+        // 聊天后端(POST /api/chat,SSE):真实路径是 /api/chat,与前端前缀不同名 ⇒ 剥前缀。
         // 对应 .env.development 中 VITE_CHAT_API="/chat-api"。
         "/chat-api/": {
           target: "http://192.168.2.23:8000",
           changeOrigin: true,
           rewrite: (path) => path.replace(/^\/chat-api/, ""),
         },
-        // [已移除] "/stream/" 代理(数字人视频流):src/index.html 里没有任何代码往它发请求,
-        // 相关 env(VITE_STREAM_SERVER / VITE_STREAM_TOKEN / VITE_HUMAN_IDLE_MP4_URL)也已删除。
-        // [已移除] "/asr/" 代理(https://human-screen-v3.metamaker.cn):旧厂商 ASR 的退路,
-        // 2026-09-24 按用户要求把旧实现连同 env(VITE_ASR / VITE_XF_ASR)一起删除;
-        // 现在的语音识别走阿里云 NLS(见 harness/docs/reference/asr-nls.md)。
       },
     },
     build: {
       rollupOptions: {
         input: {
           main: path.resolve(__dirname, "index.html"),
-          redirect: path.resolve(__dirname, "redirect.html"),
         },
         output: {
           manualChunks: (id) => {