通用的仓库内会话进度日志。文件名沿用课程历史约定,不绑定 Claude Code—— Codex、OpenHands 等 agent 同样可用,前提是仓库指令要求它在开工时读取、收尾时更新。 agent 不会自动维护这个文件。
f:\yysk\AI_zhaoshang\zhaoshang-llmnpm run dev → https://localhost:8083(dev server 为 HTTPS,带自签证书告警属正常)npm run build(生产构建,必过);可选 npm run test(jest)file-upload(文件与图片上传)/api/chat 的请求体只接受 {thread_id, question},多余字段返回 422,
因此旧协议的 transmission.files / file_pos 发不出去。上传功能如何随请求提交的后端契约未定,
需先确认方案(后端扩展请求体 / 把 OSS 地址拼进 question / 后端提供文件登记接口)。不在本文件重复,见:
| 想了解 | 看 |
|---|---|
| 架构、目录、核心系统、关键约束 | docs/architecture.md |
| 接口契约与字段释义 | docs/reference/(先读该目录 README) |
| 已知技术债与未决问题 | docs/plans/tech-debt.md |
| 质量现状与缺口 | docs/quality.md |
| 验证流程 | sops/verification.md |
knowledge_type 的类型但未渲染,用户未要求展示顺序:按时间正序(旧 → 新),与根
README.md的日期节一致—— 最新的记录在最下面。加新记录请追加到文件末尾,不要插到开头。
harness/ 文件夹(本套文件)并在根 CLAUDE.md 加入 Harness 工作流段落feature_list.json(6 项 passing,1 项 blocked,1 项 not_started)npm run build 通过node -e "JSON.parse(...)" 校验 feature_list.json:JSON 合法、8 个功能、字段齐全、无重复 id、状态合法、in_progress 数量为 0feature_list.json 中每项 passing 功能都带 verification 与 evidence 字段harness/(README、feature_list.json、claude-progress.md、init.sh、
session-handoff.md、clean-state-checklist.md、evaluator-rubric.md);修改根 CLAUDE.mdfile-upload 与 chat-markdown-format 两项受外部条件阻塞,见各自 notesinit.sh 的提交历史检查会降级跳过下一步最佳动作:确认 file-upload 的后端契约(三选一),然后把它切到 in_progress 开始实现
日期:2026-09-15
本轮目标:修问卷/补问提交序号的问题;建立接口参考文档目录
已完成:
toChatQuestionAnswer 改为提交选项文字内容:公司候选直接发公司名,选项不再带 N. 前缀;
顺带修掉三个缺陷(只取第一题、多选只取第一项、数字开头选项被静默改写)finishTurn 里从未使用的 donePayload 死参数(误导过一次排查)docs/reference/:新协议契约 + legacy/API.md(交接时代码快照,非规格)+ 索引 READMEdos/(内容已迁入 docs/reference/)legacy/API.md:用户澄清它是"接手项目时根据源码生成的快照,后续接口会改",
据此在四处统一措辞(文档顶部横幅、索引表、CLAUDE.md、harness/README.md),
并删除原先"它是仓库内唯一依据"的错误说法——判据优先级明确为源码备份 > 生成文档运行过的验证:
npm run build 通过(本次改动后又重跑)toChatQuestionAnswer 单元断言 14/14 通过已记录证据:见 feature_list.json 的 questionnaire-submit-text
提交记录:无(用户自行提交)
更新过的文件或工件:src/components/api-chat-coordinator.ts、docs/reference/*、
CLAUDE.md、harness/README.md、harness/feature_list.json、本文件
已知风险或未解决问题:
"1")。若后端按名称
触发 refine 重新检索,会出现"选一次又弹回同一批候选"的循环。
真实后端验证未完成——排查时后端已不再返回任何补问("上海青浦发展集团有限公司能享受哪些政策"
与"帮我查一下我公司的补贴政策"两种之前都能触发的问法,现在都返回"无补问")。
需在浏览器里走一遍公司候选流程确认。legacy/API.md 重新定性)没有在改完后
立即记录,是用户追问"每次修改后有更新 harness 文档吗"才补上的,且当时记录里还留着被推翻的
旧措辞。这正是 harness 要防的失效模式——文件不会自动维护,每轮收尾必须即时更新,不要攒。下一步最佳动作:在浏览器验证公司候选提交后的行为;若出现循环,与后端确认候选确认按什么取值
api-chat.md)明确"API 由序号映射到原候选",
文本这种输入形式被保留给了 refine,语义上无法表达"我选这一个"。QuestionCard 提交时只回传 label、不回传 description,因此协调器新增
lastInterruptPayload 留存最近一次补问,toChatQuestionAnswer(answers, interrupt)
按 label 反查候选后重建完整文本。npm run build 通过toChatQuestionAnswer 单元断言 10/10 通过(含反查重建、固定动作、自由输入、多选多题、无补问降级)feature_list.json 的 questionnaire-submit-textsrc/components/api-chat-coordinator.ts、
src/components/business-assistant/useBusinessAssistantChat.ts、
docs/reference/README.md、harness/feature_list.json、本文件API.md 是初始项目的接口文档;当前用的是新接口,契约看 api-chat.md;
但两者都会滞后,真正的事实来源是跑着的后端。不要拿文档去否定实际观测。company_selection + candidates: [] + error: "provider_failure"
——上游公司数据服务(企查查)查询失败,不是"查无公司"。buildQuestionCardsContent,按接口文档要求区分三种情况
(文档原文:"空候选与查询失败需分别展示"、"有此错误时,不把空候选说成查无公司"):
①查询失败 → 说明失败原因 + 重试/取消;②查无公司 → 说明没搜到 + 建议换关键词;
③正常 → 列出候选。describeCompanyError(),把错误码翻成人话(含 search:/basic:/honors: 阶段前缀,
保留原错误码便于排查)。npm run build 通过feature_list.json 的 company-query-error-statessrc/components/api-chat-coordinator.ts、
harness/feature_list.json、本文件interrupt/done 在适配层已并入 message 通道)
而误判"后端不返回补问"。company_need(不再是 company_selection),
且后端自己的文案就写着"请核对并补充工商注册全名或统一社会信用代码;也可以输入'跳过公司查询'"。
这与用户的诉求完全一致——先读实际载荷再动手,比照文档猜要可靠。QuestionCard 新增两个可选字段(不影响既有行为):
freeformInputOnTop(输入框常驻并排在最上)、freeformPlaceholder;
并同步修正 4 处自由输入判定,使常驻输入框无需点击激活即可参与"已作答/可提交/提交内容"判断。company_need 分支改为:输入框在上 + 下方跳过选项。
跳过选项跟随后端自己的说法——提示里出现"跳过/skip"就用"跳过公司查询"(→/skip),
否则用"不需要公司信息"(→不需要),避免按钮与上方提示自相矛盾。Question.message 字段从未被模板渲染,此前写的提示一直是不可见的。npm run build 通过/skip、不需要公司信息→不需要、自由输入→原文feature_list.json 的 company-need-input-on-topsrc/components/Chat/QuestionCard.vue、
src/components/api-chat-coordinator.ts、harness/feature_list.json、本文件provider_failure),拿不到有候选的正常场景。上游恢复后必须补测。Question.message 未被渲染——若需要显示提示文案,需另行改模板(本轮未改,避免扩大改动面)。getCardOptionLabel(card, optIdx):freeformInputOnTop 时选项序号整体后移一位,
输入框自己取 A。常规选项与两处下拉项共 3 处已替换。answer.text(渲染在消息正文)、
又放进 interrupt.question(实测两者字符串完全相同)。改为
buildQuestionCardsContent(interrupt, shownText),正文已含该段时卡片里不再重复;
模板侧问题行加 v-if="card.question",避免只剩一个"(单选)"。/skip:改为发选项文本「跳过公司查询」。
依据:该变体的后端 input_help 明确写着 输入"跳过公司查询"或 /skip 继续——两种都收。
同时移除映射表里对应的 token。npm run build 通过feature_list.json 的 company-need-input-on-top(已更新)src/components/api-chat-coordinator.ts、
src/components/Chat/QuestionCard.vue、harness/feature_list.json、本文件不需要公司信息 仍然发 "不需要" 而不是选项文本。
理由是那个变体的后端 input_help 写的是"不需要则输入'不需要'",按它给的原字符串发最稳。
若统一改成发选项文本,需确认后端也认"不需要公司信息"这个说法。status 字段替掉猜测式判定interrupt 有 status 字段(found/not_found),
查证后确认我完全没用,而是靠三样替代品在猜:
kind === 'company_selection'、!candidates.length、以及最脆的——
拿 input_help 的中文文本做正则 /跳过|skip/i 来决定按钮文案。ChatInterruptPayload.status 类型(含 found / not_found 与一一对应关系的注释)。status 为权威信号:showCandidateList = hasCandidates && status !== 'not_found'。
用户确认另外两种组合(company_selection+not_found、company_need+found)不会出现。input_help 正则嗅探,跳过选项文案改由状态决定。result.error 分支(查无 vs 查询失败要分开说),status 缺失时退回"有没有候选"兼容旧版本。npm run build 通过kind=company_need status=not_found → 输入框在上 + [跳过公司查询] + 问题字段为空(已去重)feature_list.json 的 company-interrupt-status-matchingsrc/components/api-chat-coordinator.ts、
harness/feature_list.json、本文件question || '未查询到匹配的公司。',
而"正文已展示则清空问题"的去重逻辑把 question 置空后,被这个 || 又填回来了,等于去重失效。
已改为用 questionAlreadyShown 标志显式保留去重结果。教训:清空哨兵值时,后续的 || 兜底会把它复活。failed 此前会落进 not_found 分支,问题文案被填成"未查询到匹配的公司"——
把失败说成了查无,违反接口文档"空候选与查询失败需分别展示""不把空候选说成查无公司"。
现在 failed 独立分支,文案说明失败原因(result.error 经 describeCompanyError 翻译),
无 error 时退回"公司查询服务暂时失败"。不需要),
与该变体后端 input_help 的"不需要则输入'不需要'"一致。fallbackQuestion(),统一处理"已去重则保持为空"的逻辑,避免再次被 || 兜底复活。npm run build 通过failed 文案不含"未查询到";not_found 文案为"未查询到匹配的公司"docs/reference/README.md 新增「interrupt 的全部状态」表;
见 feature_list.json 的 company-interrupt-status-matching(已更新)src/components/api-chat-coordinator.ts、
docs/reference/README.md、harness/feature_list.json、本文件failed 与"无 status 初次询问"两种情况只有静态断言,未在真实后端遇到过
(上游持续 provider_failure,实际只观测到 not_found)。真实出现时需确认文案是否合适。http://47.103.92.60:3003/skyversation/zhaoshang_client_ui.git
(远程原为空仓库)。107 个文件、64595 行。
按用户确认排除 .claude/settings.local.json(本机权限配置)与 stats.html
(965KB 构建产物),两者已加入 .gitignore。stream-message-coordinator-v2.ts 与 src/hooks/;修正 src/network/api/ 清单;
修正"3D avatar rendering"(three 仅用于粒子背景);修正主壳描述(BusinessAssistant.vue
只是 PC/移动端切换);CI/CD 段说明仓库内无 .gitlab-ci.yml;代理表补 /chat-api。git ls-remote origin 与本地 HEAD 一致(08524fb)8082 / Vuex / stream-message-coordinator-v2 / src/hooks /
gitlab-ci 等过时表述残留(剩余匹配均为"没有…"这类修正后的否定陈述)08524fb(基线)、975dee9(文档修正)08524fb、975dee9 均已推送 origin/mainREADME.md、CLAUDE.md、.gitignore、本文件status: found 一次都没出现过);②answer.text 与 summary.text 首句重复,
已确认是后端内容重复(非前端渲染),待后端修或前端去重.env.* 已入库,含 VITE_APP_KEY / VITE_STREAM_TOKEN。这类 VITE_ 变量本就会被
编译进前端产物、对访问者可见,不属额外泄露;但若安全规范要求不入库,现在改成本最低
(历史仅两个提交)docs/ 与 harness/ 未入库。
原因是项目原有的 .gitignore 第 4、5 行就是 /docs 与 /harness——
我创建这两个目录时被静默排除,而我在提交信息里却写了"含 docs/reference 与 harness",
属表述失实。经用户确认这两处是用户有意排除的,无需上传。
教训:新建目录后应 git check-ignore 确认是否被忽略,不要假定 git add -A 会带上。CLAUDE.md 与 agents.md 按用户要求改为不入库(加入 .gitignore,
git rm --cached,磁盘文件保留)src/styles/common/define.less 与
rule.less 头部指向飞书设计规则的外链(保留文件用途说明,仅去外链)grep -riE "飞书|feishu|lark|awbm" 在工作区与 git 跟踪文件中零命中npm run build 通过(改动了 .less 文件,确认不影响构建)git status 干净,已推送 origin/maind8178ca(去跟踪)、a41eec8(README CHANGELOG)、
14bab9f(README 精简 + 去飞书).gitignore、README.md、src/styles/common/define.less、
src/styles/common/rule.less、本文件08524fb 初始提交、a41eec8)。用户确认留着无所谓,
故不做历史重写status: found 一次都没出现过);
②answer.text 与 summary.text 首句重复(已确认是后端内容重复,非前端渲染)已完成:
结构调整(参考资源库的 OpenAI 高级骨架 + "短入口,深链接"原则):
harness/
├── README.md 结构导航(入口)
├── progress.md ★ 进度日志(原 claude-progress.md 更名)
├── feature_list.json ★ 功能清单
├── init.sh
├── tools/ ← 新增:临时脚本(以前写在项目根目录、用完就删)
├── docs/ ← 原仓库根的 docs/ 整体迁入
│ ├── architecture.md ← 新增:从 CLAUDE.md 拆出的架构深内容
│ ├── quality.md ← 新增:质量评分(按领域 + 架构层)
│ ├── plans/ ← 新增:执行计划(active / done / tech-debt)
│ └── reference/ ← 接口参考(原样迁入,4 份)
└── sops/ ← 流程(原散落的三个文件归入并补两份)
├── session-start.md ← 新增:开工流程
├── session-end.md ← 原 clean-state-checklist.md
├── handoff.md ← 原 session-handoff.md
├── verification.md ← 新增:验证流程
└── evaluator-rubric.md← 原文件迁入
根 CLAUDE.md 瘦身为简短入口:只留开工步骤、规则、完成门槛、速览与索引表;
架构细节整体移入 harness/docs/architecture.md
把已知未决项沉淀为 docs/plans/tech-debt.md(5 条):
①公司候选提交文本 vs 序号(可能死循环,从未验证)②answer/summary 首句重复
③文件上传待定 ④部门筛选在新数据下筛不出(刻意保持,勿当 bug 修)
⑤Question.message 字段定义了却没渲染
tools/README.md 写清了为什么要有这个目录:以前脚本写在项目根、用完即删,
导致根目录被污染、好用的脚本丢失、当时的验证方式无迹可查
运行过的验证:
npm run build 通过(结构改动只涉及文档与目录)progress.md 里 Session 000–009 的历史记录保留旧路径不改——那是当时的事实已记录证据:本文件 Session 010;harness/README.md 结构表
提交记录:无(harness/ 与 CLAUDE.md 均不入库)
更新过的文件或工件:整个 harness/ 目录重构;根 CLAUDE.md 重写;
原根 docs/ 迁入 harness/docs/
已知风险或未解决问题:
quality.md、plans/、sops/session-start.md、sops/verification.md,
同样有过期无人用的风险。判断它们是否有用的标准:下一轮开工时是否真的照着走了。
若某个文件连续几轮没被读过,就该删掉或合并——harness 简化是常规工作。harness/docs/ 与 harness/ 均不入库(用户要求),团队 clone 看不到;
入库的改动记录在根 README.md下一步最佳动作:等上游公司数据服务恢复后,补测 tech-debt.md #1(有候选时的选中流程)
policies_public.v1.json 里的政策
(实测 262 条申报事项 / 38 个政策,由 index.html 预加载到 globalThis.policiesPublic)。
并验证了它与后端字段同源:后端 is_policy_library=true 的那条标题,
在库中能匹配到(该政策名下有 44 条申报事项)。is_policy_library(精确、无需匹配),
缺失时回退到"标题能在库里找到"。标题匹配做了归一化——
库里带《》、卡片标题不带,另有全半角括号、连接符、空白差异。v-if="selectedIsPolicyLibrary",
非惠企政策不显示该入口。listSource(cards | library):
卡片区「更多 >>」→ cards(维持卡片数据,不动);
惠企政策详情「返回」→ library(惠企政策库,本轮匹配到的惠企政策置顶)。normalizePolicy 能吃
name/department/declaration_item,且 auto_granted 会自动出「免申即享」标签。
顺带说明:分类筛选(免申即享)在这个数据源上是真正可用的——
它就是为这个库存设计的字段(此前在卡片数据上必然失效)。src/components/Chat/policy-library-utils.ts,
让验证脚本引用同一份实现而非复制——复制会漂移,测的就不是真代码。computed(() => globalThis.policiesPublic)。但 globalThis 的属性不是响应式依赖,
computed 会把首次读到的值缓存住 —— 而 policiesPublic 是 index.html 异步 fetch
加载的(且应用不等待它,dataLoaded 事件与它无关、src 里无人监听),
首次读到很可能是空数组,加载完成后也不会更新。
改为 函数 getLibraryPolicies() / getLibraryNameSet(),每次调用现读。npm run build 通过;policy-library-utils.ts 无新增类型错误harness/tools/verify-policy-library.mjs(13 项断言,全通过):
名称归一化、《》差异、判定正例与负例、命中收集、置顶排序(含"无命中时顺序不变")harness/tools/verify-policy-library.mjs;
feature_list.json 的 policy-library-back-entrysrc/components/Chat/PolicyMatch.vue、src/components/Chat/policy-library-utils.ts(新增)、
harness/tools/verify-policy-library.mjs(新增)、
harness/tools/_policy-library-utils.mjs(esbuild 产物,供脚本 import)、
harness/feature_list.json、根 README.mdpoliciesPublic 异步加载且应用不等待它。
已修掉 computed 缓存问题(现读),但如果用户在下载完成前(约一秒内)就点开
惠企政策详情并点「返回」,列表会是空的。实测时请留意这一点;
若实际会发生,可加一个"库未就绪时短暂重试"的兜底。harness/tools/_policy-library-utils.mjs 是 esbuild 产物,改了源文件要重新打包
才能在脚本里生效(脚本顶部已注明)。tech-debt.md #1(上游恢复后补测选中流程)listSource: cards | library:
卡片「更多」看卡片数据、详情「返回」看惠企政策库),这是理解错了——
用户要的是一个列表、两个入口。openPolicyList(),模板两处按钮都指向它listSourcenpm run build 通过harness/tools/verify-policy-library.mjs 13/13 通过(判定与置顶逻辑未受影响)listSource / openLibraryList / openList 均无残留openPolicyListsearchKeyword / currentDeptFilter / currentFilterOption
只由用户点击触发,无程序写入README.md 的 20260916 节已同步更正src/components/Chat/PolicyMatch.vue、根 README.md、
harness/feature_list.json、本文件tech-debt.md #1(上游恢复后补测选中流程)CLAUDE.md 新增「提交约定」一节:最低要求(npm run build 通过 +
已更新进度日志与功能清单)、提交信息写法(中文,说清改了什么为什么,
结尾带 Co-Authored-By)、命令示例;并注明 harness/、CLAUDE.md、agents.md
不入库是预期行为sops/session-end.md 的逐项检查首项改为"已提交并推送"sops/handoff.md 的常用命令补上提交推送103e53b)npm run build 通过git push 成功;git log 确认提交已上远程103e53b;本文件 Session 013103e53b 已推送 origin/main(本条记录本身因 harness/ 不入库,不进提交)CLAUDE.md、harness/sops/session-end.md、
harness/sops/handoff.md、本文件harness/ 与 CLAUDE.md 被 .gitignore 排除,
所以"每次改动都提交"实际上只覆盖入库文件;状态记录与工作规则不会进提交。
这是用户有意的安排(Session 009 确认过),不是疏漏。tech-debt.md #1(上游恢复后补测选中流程)openPolicyList() 里每次进入都重置筛选:页码归 1、清空关键词、
部门回"全部部门"、分类回"所有分类"。watch([currentFilterOption, currentDeptFilter, searchKeyword])
与 watch(currentPage),导致列表被重复构建一次——两次结果相同,可接受,未做去抖。npm run build 通过openPolicyList 中四个重置项齐备;两个 entry(模板第 29 行的卡片「更多」、
第 220 行的详情「返回」)都走它feature_list.json 的 policy-library-back-entry;
根 README.md 的 20260916 节src/components/Chat/PolicyMatch.vue、根 README.md、
harness/feature_list.json、本文件tech-debt.md #1(上游恢复后补测选中流程)typingCharsPerTick 2 → 4(≈121 字符/秒),
积压加速档位同步加倍(8/12/16/20/24)。npm run build 通过README.md 的 20260916 节;
代码注释中写明基准来源与取值理由src/components/Chat/TextContent.vue、根 README.md、本文件typingCharsPerTick 一个数。TextContent 的逐字机 + BusinessRecord 的 gapTime = 30),
本轮只动了前者。若实测仍偏慢,下一处该看 BusinessRecord.vue 的 gapTime。normalizeSessionHistory 用 content.trim()(<scope> 进度块算有内容),
isInterruptedEmptyScopeMessage 用 stripScopeBlocks()(进度块不算内容)。
同一段内容一处认为「有」、一处认为「没有」。
新协议的正文要等 done 才写入,处理期间内容只有进度块,
把这个缝隙从偶发放大成「一切会话就出现」。<scope> 标记是适配层发明的、后端看不见;
③后端能做的是改成流式(让回答更早出现),那是体验优化、是掩盖不是修复。hasVisibleMessageContent()(src/utils/interrupted-message.ts),
归一化与渲染层都改用它(渲染层顺带把重复的两处内联 strip 也收敛了)。harness/tools/verify-empty-content-agreement.mjs:
这个 bug 的本质是「两处不一致」,所以断言的不变量就是
两处必须一致(9 例基准 + 18 例一致性 + 3 例回归 = 30 项)。npm run build 通过渲染=true / 归一化=false(即不一致),现在两处一致feature_list.json 的 empty-content-agreement;
harness/tools/verify-empty-content-agreement.mjs;根 README.md 的 20260917 节src/utils/interrupted-message.ts、
src/components/business-assistant/shared.ts、
harness/tools/verify-empty-content-agreement.mjs(新增,含 esbuild 入口 _entry-empty-content.ts)、
根 README.md、harness/feature_list.json、本文件PolicyMatch.vue 第 406-418 行,用 <!-- --> 整块包住)selectedCitations 计算属性[已移除] 标记说明原因resolveFieldCitations 本来就没被 PolicyMatch 引入,
selectedCitations 也只被这个板块使用npm run build 通过<!-- --> 包住(grep 分不清注释内外,所以直接看了原文)feature_list.json 的 policy-detail-blocks(已改为两块);
根 README.md 的 20260917 节src/components/Chat/PolicyMatch.vue、根 README.md、
harness/feature_list.json、本文件tech-debt.md #1 —— 公司候选提交文本是否会被当作 refine 而循环,从未验证
(上游持续 provider_failure,status: found 一次都没出现过)
②政策事项列表的「政策名称」跳转 —— 库里 262 条 apply_link 全空,
仅 21 条能从 资源申请备注 取到可用外网地址(另有 25 条指向内网登录页);
已向用户说明,等其定方向https://zwdt.sh.gov.cn/qykj/shell_oc_policy_zq/policy/policy-detail?id=市级政策iddata.市级政策id 有 261 / 262 条(40 个不同 id),
只有 1 条缺 —— 覆盖率远好于之前考察的两个来源:apply_link 字段:262 条全为空资源申请备注 里的 URL:只有 46 条有,且25 条指向内网登录页
(http://10.235.238.34:7202/imanage/login,用户打不开),
可用外网地址仅 21 条;且同名政策下的 URL 是按申报事项分别对应的,
不能互相借(借了会指向别的申报事项的页面)buildPolicyDetailUrl() 到 src/components/Chat/policy-library-utils.ts
(纯逻辑,可被验证脚本引用);没有 id 时返回空串——不猜,
宁可没有链接也不给打不开的地址normalizePolicy 的 apply_link 兜底链:卡片的 apply_link 优先,
库条目的 市级政策id 兜底npm run build 通过harness/tools/verify-policy-library.mjs 扩到 21 项断言,全通过:
新增 7 项地址拼接用例(有 id / 无 id / data 缺失 / null / 空白 id / 去空格 / 特殊字符编码)harness/tools/verify-policy-library.mjs;
feature_list.json 的 policy-library-back-entry;根 README.md 的 20260917 节src/components/Chat/policy-library-utils.ts、
src/components/Chat/PolicyMatch.vue、harness/tools/verify-policy-library.mjs、
根 README.md、harness/feature_list.json、本文件市级政策id,
该条不显示链接apply_link 仍来自后端下发的 source url,与库路径的拼接地址
是两套来源 —— 若后端统一提供,可简化为一套tech-debt.md #1(上游恢复后补测选中流程)ScopeContent 按卡片宽度切样式(isMaxWidthReached = cardWidth >= parentWidth - ε):
没撑满容器时标题 nowrap + 省略号,撑满时整块切成 pre-wrap。
原协议标题是固定的短句"正在思考中...",永不撑满、从不翻转;
新协议我把整条阶段文案放进了标题(长得多),撑满即翻 → 突然换行、卡片高度跳动。
改法:把原本同时管标题与正文的那条规则拆开 —— 正文保持 pre-wrap(它是为多行设计的),
标题恒定 nowrap + 省略号。用户选的就是「一律不换行」。buildPolicyDetailUrl,不是没编译进去apply_link 的编译结果正确:item.apply_link || item['政策原文地址'] || item.data['政策原文地址'] || buildPolicyDetailUrl(item) || ""v-if="selectedPolicyItem?.apply_link" → <a>,否则纯文本).policy-link 样式正确(紫色 + 下划线 + 手型)pointer-events 阻挡buildPolicyDetailUrl 对真实库数据覆盖 261/262(脚本断言过)
剩下的两个可能:
① 页面/内存状态陈旧 —— HMR 更新了组件代码,但已构建好的 policyListItems
(旧 apply_link 为空)仍在内存里;硬刷新或关掉列表重新进才会用新逻辑重建
② 当时列表其实走的是兜底路径(globalThis.policiesPublic 未加载完 → 退回落卡片数据),
点的是卡片而非库条目 —— openDetailByItem 会匹配到它自己那张卡(index >= 0),
用的是卡片的下发地址;若该来源没有 url,apply_link 就是空 → 纯文本。
这与「库路径」是两套地址来源(见 Session 018 记录的风险项)npm run build 通过nowrap 分支在 :not(.is-max-width-reached) 与 .is-max-width-reached 下各有一份,
正文的 pre-wrap 未被误改README.md 的 20260917 节src/components/Chat/ScopeContent.vue、根 README.md、本文件
(feature_list.json 未动:本轮是样式修正,无新增能力)ScopeContent(共享组件)的样式。理由是用户明确要求「一律不换行」,
且该组件当前只有新协议在用(旧协议未使用)data.市级政策id |
|---|---|---|
| 本地 public/merchant-agent/policies_public.v1.json | 262 | 261/262 有 |
| 远端 VITE_DOWNLOAD_URL | 327 | 0/327 全无 |市级政策id;它有的 policy_id 是 40 位、且与该条 id 相同,
与 市级政策id(24 位,如 676ea4ff61aee302a854ab72)是两套 id 体系,拼不出一网通办的地址index.html 原本是远端优先,而远端带 CORS 头(实测 Access-Control-Allow-Origin
回显 https://localhost:8083 与 https://aixq.shqp.gov.cn)→ 浏览器里远端确实赢了
→ buildPolicyDetailUrl 取不到 id → 返回空串 → 「政策名称」纯文本VITE_POLICY_LOCAL_FIRST:仅 .env.development 为 true,
.env.production / .env.qingpu / .env.test 显式写 false(四份都写,避免占位符不被替换)index.html 改为按该开关排来源顺序:dev 本地优先、远端兜底;生产仍远端优先、本地兜底。
错误日志按来源名打印(不再用 isFallback 布尔反推)npm run build 通过dist/index.html:var localFirst = "false" === "true" → 生产行为未变"true" === "true"
(Vite 检测到 .env 变更会自行重启,故 8083 也已生效,无需手动重启)/merchant-agent/policies_public.v1.json 与仓库本地文件
sha256 一致(1a5127c0…),即 262 条、261 条带 idharness/tools/verify-policy-library.mjs 21/21 通过harness/tools/check-policy-sources.mjs:对比两个来源,输出
本地 262 / 261 带 id、远端 327 / 0 带 id,退出码 0(与预期一致)harness/tools/check-policy-sources.mjs;
feature_list.json 的 policy-library-back-entry;根 README.md 的 20260917 节.env.development、.env.production、.env.qingpu、.env.test、
index.html、harness/tools/check-policy-sources.mjs(新增)、根 README.md、
harness/feature_list.json、本文件data.市级政策id(或让远端也带 id)。
开关的移除条件已写进 check-policy-sources.mjs 的输出——
远端哪天有 id 了,脚本会提醒可以去掉这个开关bash harness/init.sh(sops/session-start.md 第 4 步「跑基础验证」)——
事后补跑,exit 0 通过docs/architecture.md —— 根 CLAUDE.md 写明「第一次进这个仓库必读」,
而本会话是第一次进,我跳过了harness/docs/reference/ 时,ls 的输出里没有 DMS 三份文档,
我据此说「缺失」,随后 find 证明它们存在(mtime 9/16 17:17,不是新拷入的)。
原因不明(疑似输出被截断),但教训明确:不要用单次 ls 的输出当「文件不存在」的证据,
要用 find / test -e 复核F:\yysk\AI_zhaoshang\DMS_Data_Migration\harness\docs\ 复制来的,
复制时没带同伴,导致 7 个引用全部指向不存在的位置。已逐一核实外部原件存在后,
把相对链接改为绝对路径,并在每份文档头部加了「引用的 artifacts / tools 不在本仓库」的说明:
dms_openapi.json(118KB)、created_columns.json、dms_column_fields.json、
create_dms_columns.py、fix_field_alias.py、RUNBOOK.md、SKILL.md、apifox-dms.md —— 8/8 均存在reference/README.md 索引表补上 DMS 三份文档(原索引只列了 api-chat 系列,
而 DMS 文档在里面躺了一整天没人知道),并写明 DMS 栏目当前全空、别当在线数据源用docs/plans/active/dms-api-replacement.md(该目录此前一直空着):selectContentList 语义吻合delContentById 参数未文档化)code=202),数据迁移(F4-*)没做完不能切bash harness/init.sh 通过(exit 0)—— 事后补跑的开工验证grep 确认三份 DMS 文档已无残留的 ../artifacts / ../tools / (RUNBOOK.md) 相对引用test -e 确认存在(含带空格的 2. DMS-Manage 路径)docs/plans/active/dms-api-replacement.md;
docs/reference/README.md 的 DMS 索引节harness/ 下的文档与方案)按 .gitignore 约定不入库,
故无内容可提交。这是预期行为,不是漏提交docs/reference/DMS_API.md、docs/reference/DMS_COLUMNS.md、
docs/reference/DMS_MAPPING.md、docs/reference/README.md、
docs/plans/active/dms-api-replacement.md(新增)、本文件EnterpriseInfo 的 2 个字段:name、credit_code
(useEnterpriseAuth.ts:58-59;另外 19 个字段在 src/ 里零引用)
→ 用 DMS 的企业信息替换不需要字段映射层,比预想的简单c_feedback_status / c_feedback_option / c_feedback_remark / c_feedback_at
→ 前端 POST /chat/feedback 理论上可由 1889 承担feature_list.json 也未新增条目
(避免在没有决定前把未定的事写成待办)selectContentList 等)在文档里标的是 📄未实测,
本项目从未调用过 —— 方案第 0 步就是先小样本验证ls 漏文件那件事没有查到根因。若再遇到「列目录看不到文件」,先 test -e 复核再下结论feature_list.json 条目逐项做docs/plans/active/dms-api-replacement.md 的后续落地;
本轮方案与用户拍板记录见本文件末尾「Step-0 结论」。
用户拍板三条:①反馈写入 DMS;②会话改名/删除一并切;③访客 id 复用埋点 visitorId
(c_credit_code = 访客_<assistant_statistics_visitor_id>)跑法:node harness/tools/dms-step0-verify.mjs <TOKEN>(14/17 通过)+ dms-delete-probe.mjs。
三个 FAIL 里一个是我判据写错,两个是真发现:
| # | 结论 | 影响 |
|---|---|---|
| 1 | addContent 不需要 c_id(自动填 0) |
✅ 最大阻塞风险解除(模型里 c_id 是 must=true,实测不强校验) |
| 2 | addContent 返回记录 uuid,形态是纯字符串:{"code":200,"content":"056dbae8-…"} |
✅ 新增行后不必再反查一次;不是 content.id 对象 |
| 3 | c_created_at 不自动填,必须自己写 |
⚠️ 写入时要带时间戳 |
| 4 | 时间戳格式:"YYYY-MM-DD HH:mm:ss" 与 ISO 串都接受;读回是 epoch 毫秒(数字) |
写入用前者;读取按毫秒处理 |
| 5 | 删除不是 delContentById(各种传法都 code=-1/POST 405) |
⚠️ 见 #6 |
| 6 | 删除的正确姿势:POST /content/updateAudit,form {columnId, id:<uuid>, state:4} |
✅ state=4=销毁,返回 200 且行从列表消失。这条文档里完全没有,是本次试出来的 |
| 7 | 长文本(1105 字含 <scope>/POLICY_TABLE/<question-cards> 标记)逐字节往返一致 |
✅ 协议标记可原样存 DMS |
| 8 | 中文+下划线的 c_credit_code 精确 search 命中 |
✅ 访客 访客_<uuid> 可查 |
| 9 | 反馈字段(c_feedback_status/option/remark/at)经 updateContent 写入生效 |
✅ 反馈方案可行 |
| 10 | pageSize=200 被接受;写后不带 states 也能查回 |
— |
⚠️ 测试数据已全部清理(1887/1889 复查均 0 行)。清理过程中发现探测脚本把 1887 的 columnId 用到了 1889 的行上,导致一度残留 1 行,已手工补删。
记录:本文件 Session 022;脚本 harness/tools/dms-step0-verify.mjs、dms-delete-probe.mjs
新增:
src/network/api/dms/client.ts —— DMS HTTP 层(token 由代理注入、form body、code===200 判定、202 当空、任何失败都不抛异常)src/network/api/dms/chat-sessions-dms.ts —— 业务层:upsertDmsSession / upsertDmsRecord /
fetchDmsSessions / fetchDmsSessionRecords / deleteDmsSession / writeDmsFeedback,
含顺序链去重(同幂等键的写串行,避免「提交写 question」与「完成补 answer」并发各 add 一行)harness/tools/verify-dms-chat-storage.mjs + _entry-dms.ts(验证脚本,26 项断言)改:
src/network/api/chat-sessions.ts —— 4 个导出换成 DMS 实现,签名与返回类型不变(调用方零改动);
RemoteSessionRecord 新增可选 record_idsrc/components/business-assistant/useBusinessAssistantChat.ts —— 挂钩:
sendMessage 提交时 upsertDmsSession + upsertDmsRecord(question);
totalResponse 补 answer;close(停止/断流)也补一次(否则中断的问答只有 question);
submitQuestionAnswers 补问路径同样写入(存真实提交文本,不存「已提交企业信息补充」占位);
历史回读用 record_id 回填 AI 消息 id → 从 DMS 读回的历史消息点赞仍命中原始行;
syncSessionTitleToServer / syncSessionDeleteToServer 去掉登录 guard(访客也写)src/components/Chat/BusinessRecord.vue —— 反馈三函数换 writeDmsFeedback,移除 cardAPI 调用vite.config.ts —— 改用 loadEnv(mode, cwd, "") + 新增 /dms-api/ 代理
(rewrite 补回 /dms 前缀、在代理侧注入 token,浏览器产物里没有 token)src/utils/runtime-config.ts —— 新增 getDmsApiBaseUrl();index.html 注入 globalThis.VITE_DMS_API.env.development —— VITE_DMS_API="/dms-api"、VITE_DMS_TARGET.env.development.local(不入库,.gitignore 已覆盖 *.local)—— DMS_TOKENnpm run build 通过(改动后重跑)harness/tools/verify-dms-chat-storage.mjs,打的是真实 DMS,
经 vite 代理、与浏览器同一条路径):会话增/改不重复、问答 question→answer 补写不重复、
长文本含协议标记往返一致、反馈三种状态、删除连带清理、时间戳解析、访客归属https://localhost:8084/dms-api/... 查询 → 202(token 已注入);
直连 DMS 不带 token → 208 无token → 证明 token 确实只在代理侧202 空(测试数据已清理干净)findRowBy 里写死了 orderBy: [{field:'c_updated_at'}],但栏目 1889 没有这个字段:
按不存在的字段排序 → DMS 报错(返回非标准响应)→ 反查恒空 →
每次 upsert 都判定「不存在」→ 新增,同一问答被写成多行(实测第 4、5 步各多一行)。
修法:findRowBy 不加 orderBy(两栏目字段不同,只有 c_created_at 是共同的)。
fetchDmsSessions 的 c_updated_at 排序保留 —— 那个字段在 1887 里确实存在。
教训:跨栏目复用同一个查询函数时,排序字段必须两个栏目都有。
useBusinessAssistantChat(PC/Mobile 共用),
逻辑与 DMS 契约均已用真实 DMS 验证,但「发一条消息 → 库里出现两行」这一步
仍需在浏览器点一遍确认。.env.development.local 并重启 dev server)。.env.production 未配 VITE_DMS_API,需 nginx 做等价转发
(location /dms-api/ { proxy_pass http://121.43.55.7:10081/dms/; proxy_set_header token "…"; }),
且 DMS 的长效凭据方案待与 DMS 侧确认(现在给的是临时 token)。fetchRemoteSessions 对空 credit_code 直接返回 [](保持原行为),
符合用户「访客也存」的要求;若将来要「访客也能看自己的历史」,需要另开一条读路径。浏览器实测:发一条消息 → 查 DMS 两栏目应各出现一行(c_session_id=localStorage
ba_current_session_id);刷新后列表出现该会话;改名/删除/点赞各验一次。
用户原话:「每次一问一答就会在 DMS 存一条消息,我希望一条消息同时包含同一个会话窗口的所有消息」。 用户三个决策:一行一个会话(1889);反馈存进该行 JSON 里对应那条消息;dev 自动登录去掉。
chat-sessions-dms.ts:
saveDmsTranscript() 取代 upsertDmsRecord() —— 幂等键从 c_record_id 改为
c_session_id,整段消息序列化成 {version:1, messages:[…]} 存进 c_answer,
c_question 存首问fetchDmsSessionRecords() 改成从该行 JSON 里还原问答对writeDmsFeedback() 改成「读整行 → 只改命中那条消息 → 写回」,与对话写入
共用 session: 顺序链;定位不到时不新增行(避免库里出现只有反馈没有内容的行)Date.now()
会让所有消息的时间都变成最后一次保存的时刻 —— 假数据比没有更糟。顺序由数组顺序表达useBusinessAssistantChat.ts:4 处调用点(sendMessage / totalResponse / close /
submitQuestionAnswers)统一换成 saveSessionTranscriptToDms(session)useEnterpriseAuth.ts 里 isDevLogin = MODE === 'development' 时无条件用
硬编码的 devLogin token 登录 → 表现为「我没登录也进入登录态」?access_token= 或 ?credit_code=。
开发环境默认即访客态,正好方便验证访客记录。globalThis.token 不再自动注入,依赖它的旧接口(企业信息等)
需要显式登录才可用 —— 本轮涉及的会话/历史/反馈已全部走 DMS(代理注入 token),不受影响参考:https://walkinglabs.github.io/learn-harness-engineering/zh/resources/ (该站被网络策略挡了,改从 GitHub raw 取到正文与 OpenAI 高级包结构)
docs/plans/ → docs/exec-plans/,done/ → completed/,
tech-debt.md → tech-debt-tracker.md(对齐参考资料命名)harness/docs/exec-plans/active/。
用户指出的问题属实:本轮的实施计划先前被写到了 Claude Code 自带的
~/.claude/plans/(仓库外),换个会话接手看不到。已把内容落进仓库
(exec-plans/active/dms-chat-storage.md),并在 CLAUDE.md、
exec-plans/README.md、sops/session-end.md 三处写明harness/tools/validate-harness.mjs:参考资料的「机械约束优先于
口头约定」。校验必备文件、游离的计划文件(仓库根/src/ 下)、
feature_list.json 的假 passing / 重复 id / 多个 in_progress、
progress.md 会话编号重复。已实测它能报错(放一个 plan-test.md 后 exit=1)sops/session-start.md 加「4b 校验结构」;sops/session-end.md 加两条收尾项harness/README.md 结构图与「机械约束」一节更新更正 reference/README.md 里已过期的「DMS 五个栏目全空、别当在线数据源用」——
1887/1889 现在正是前端在用的在线数据源
运行过的验证:
npm run build 通过node harness/tools/verify-dms-chat-storage.mjs <代理> —— 36/36 通过
(新增关键断言:*第二轮后仍是同一行*、*反馈只改命中那条消息*、
*另一条消息未被误改*、*定位不到时不新增行*)node harness/tools/validate-harness.mjs —— 通过;并实测其能报错(exit=1)已记录证据:本文件 Session 023;docs/exec-plans/active/dms-chat-storage.md;
harness/tools/validate-harness.mjs;根 README.md 的 20260917 节
更新过的文件或工件:src/network/api/dms/chat-sessions-dms.ts、
src/components/business-assistant/useBusinessAssistantChat.ts、
src/components/useEnterpriseAuth.ts、harness/docs/exec-plans/*(重构)、
harness/tools/{validate-harness.mjs,_entry-dms.ts,verify-dms-chat-storage.mjs}、
harness/README.md、harness/sops/{session-start,session-end}.md、
harness/docs/reference/README.md、harness/docs/quality.md、CLAUDE.md、根 README.md
已知风险或未解决问题:
BusinessRecord 的 localFeedback
初始恒为 None,没做「从 DMS 读回反馈态」的回填。要做得另接一条线.gitignore 有意排除,Session 009/013 用户确认过)。
参考资料的原则是「计划、质量、技术债和代码一起版本化」,本仓库与之有张力:
团队 clone 看不到 harness。若哪天想改,是一行 .gitignore 的事 —— 但那是用户的决定,没动下一步最佳动作:浏览器实测(发消息 → DMS 一行 → 刷新 → 列表/历史 → 点赞 → 改名/删除); 另需用户提供新 token(旧的今晚过期)
harness/ 排除在 git 之外).gitignore 去掉 /harness、/CLAUDE.md、/agents.md 三条排除,
改为入库;并在原位置留了说明注释(为什么改、何时改).env.*.local、.claude/settings.local.jsonharness/tools/_*.mjs:那是 esbuild 从 _entry-*.ts 生成的
构建产物,入库会与源码漂移、让人误以为测的是新代码_policy-library-utils.mjs 的入口文件从来没被保留
(Session 011 生成产物后丢了入口),产物一旦不入库,政策库验证脚本就没人能重跑。
已补 _entry-policy-library.ts,并模拟 fresh clone(删光三个产物 → 按 README
的命令重新生成 → 跑验证)确认全链路可用harness/tools/README.md 补上三个产物逐一可抄的重新生成命令,
并写明 --define:import.meta.env='{}' 这个坑(node 里没有 import.meta.env)CLAUDE.md 的提交约定、
harness/README.md 的关系表legacy/API.md 里两处带 ... 的截断示例,无真实凭据git check-ignore 逐项核对:.env.development.local(含 DMS token)已排除 ✓vite.config.ts、.env.* 属同一类,不是新增暴露面harness/tools/_*.mjs → 按 README 命令重新生成
三个产物 → 四个验证脚本全绿verify-empty-content-agreement.mjs 30/30verify-policy-library.mjs 21/21(用新入口重新生成的产物)verify-dms-chat-storage.mjs 36/36(打真实 DMS)check-policy-sources.mjs 符合预期node harness/tools/validate-harness.mjs 通过npm run build 通过.gitignore;harness/tools/README.md;
根 README.md 的 20260917 节.gitignore、CLAUDE.md、harness/README.md、
harness/tools/README.md、harness/tools/_entry-policy-library.ts(新增)、
根 README.md、本文件progress.md),是入库必然的progress.md 会持续变大(已 1000+ 行),后续可考虑按会话分文件或定期归档,
但那是另一个改动,本轮不做item 事件里:id / policy_id 恒为 undefined;每条唯一的信息在
data.title(形如「政策名 - 申报事项」,如 …通知 - 创业开办费补贴)与 data.source_idtoPolicyTableItem(api-chat-coordinator.ts:322-337)把 title 取自
card.name.text(只有政策名),并把 declaration_item 也设成同一个值 ——
每条唯一的 data.title 被丢掉了declaration_item 完全相同,
而 openDetailByItem 的兜底匹配正是拿它比 → findIndex 永远命中第 0 张resolvePolicyDetailIndex()
(policy-match-utils.ts),匹配顺序改为:
PolicyMatch.vue 的 openDetailByItem 改用它;未改动任何 UI 文案或样式npm run build 通过harness/tools/verify-policy-detail-index.mjs —— 10/10 通过,
关键断言「点第 2 张 → 下标 1(修复前这里会返回 0)」与「字段无法区分时返回 -1」harness/tools/probe-policy-cards.mjs(打 item 原始事件,
确认 id/policy_id 为 undefined、card.name.text 两条相同)harness/tools/verify-policy-detail-index.mjs;
feature_list.json 的 policy-detail-index-mismatchsrc/components/Chat/policy-match-utils.ts、
src/components/Chat/PolicyMatch.vue、harness/tools/_entry-policy-match.ts(新增)、
harness/tools/verify-policy-detail-index.mjs(新增)、
harness/tools/probe-policy-cards.mjs(新增)、harness/tools/_entry-coordinator.ts(新增)、
harness/feature_list.json、根 README.md、本文件data.title 里的申报事项后缀,
所以详情面板的标题(declaration_item)对同一政策的所有申报事项都一样——
内容虽然修好了,但用户从标题上仍分不出自己看的是哪个申报事项。
要改就得动显示文案,属于 UI 变更,需用户拍板(已在回复里提出)用户选择「详情标题显示具体申报事项」。改动:
api-chat-coordinator.ts 的 toPolicyTableItem:declaration_item 改用后端每条唯一的
item.title(同政策多事项时形如「…的通知 - 创业开办费补贴」),不再与卡片标题取同一个值。
卡片标题 title 仍旧取 card.name.text(政策名)—— 卡片区观感不变panelData.title(卡片区标题)原先优先取
originalData.declaration_item,改后会连带把卡片标题变成「政策名 - 申报事项」。
已改成优先取 item.title(政策名),卡片区保持原样——用户只批准改详情标题buildRecordPolicyList 兜底列表会显示完整申报事项——
列表本就叫「政策事项列表」,且只在库未加载时走这条路,更正确新增验证 harness/tools/verify-policy-card-fields.mjs —— 8/8 通过:
①同政策两条的 declaration_item 必须不同 ②卡片标题仍是政策名(观感不变)
③无后缀时退化为政策名不出现空值 ④与 resolvePolicyDetailIndex 配合能直接命中下标 1
(跑这段测试时先写错过夹具:把「无后缀」场景的 item.title 与 card.name.text 传成了不同值,
那其实是「有后缀」形态;已修正夹具,不是代码问题)
docs/exec-plans/active/policy-card-list-sidebar.mdcardList(panelMode: "list" | "cardList" | "detail")。
旧列表逻辑靠 fetchPolicyList 开头的 panelMode !== "list" 守卫自然短路,
openPolicyList / watchers / filteredPolicies 一行未动useBusinessAssistantChat.ts 新增 provide('collectSessionPolicyCards', …)
(放在既有 provide('submitQuestionAnswers', …) 旁,PC/Mobile 零改动,
沿用同一先例)。调用时才读 currentSession → 切会话自动跟随policy-match-utils.ts 新增 5 个纯函数:
collectPolicyCardsFromMessages(从 AI 消息 content 重新解析 POLICY_TABLE)、
buildPolicyCardDedupKey、dedupePolicyItems(首见优先)、
sortPolicyCardsByMatchScore(降序 + 稳定 tie-break)、matchesPolicyCardKeywordmergeWithPublicPolicies/normalizePolicy);
inject 拿不到 provider 时回退本组件卡片,不崩不空白declaration_item(2026-09-17 起 =「政策名 - 申报事项」,每条唯一);
精简格式的占位符「查看政策详情」会退化为「标题|匹配度|匹配理由|id」组合键,
避免把不同卡片错并成一条openCardList;背景类/标题按模式分叉;
list 与 cardList 共用同一个 body 与全部样式类(CSS 零新增);
筛选块加 v-if="panelMode === 'list'" → cardList 下隐藏部门/分类,
保留搜索框与热门搜索openPolicyList 顶部原写「卡片区『更多』与详情返回走同一条路径」,
改后已不成立——不改写会误导后续维护者npm run build 通过harness/tools/verify-policy-card-list.mjs —— 31/31 通过:
跨消息聚合并集+保序、用户消息跳过、坏 JSON/空输入/null 不抛错、
去重键主键与占位符退化、首见优先(保留首见的 match_score 而非后来的)、
降序+同分稳定、六个检索面+大小写+空词、端到端组合node harness/tools/validate-harness.mjs 通过本文件 Session 026;docs/exec-plans/active/policy-card-list-sidebar.md(计划已落库);
harness/tools/verify-policy-card-list.mjs;feature_list.json 的 policy-card-list-sidebar
src/components/Chat/policy-match-utils.ts、src/components/Chat/PolicyMatch.vue、
src/components/business-assistant/useBusinessAssistantChat.ts、
harness/tools/{_entry-policy-cards.ts,verify-policy-card-list.mjs}(新增)、
harness/docs/exec-plans/active/policy-card-list-sidebar.md(新增)、
harness/feature_list.json、根 README.md、本文件
openPolicyList 现在只由
详情页的返回按钮使用,若将来也不需要了,可连同筛选状态一并清理declaration_item 的唯一性——若后端将来改回只下发政策名,
同政策的多个申报事项又会被并成一条(届时需换键,如 source_id)浏览器实测上述验收要点;若通过,本条目转 passing
active/,没移进 completed/)chat-sessions-dms.ts:撤掉「一会话一行 + 整段对话 JSON」的实现,恢复
upsertDmsRecord()(幂等键 c_record_id,c_question / c_answer 各存一半);
删除 DmsTranscriptMessage / serializeTranscript / parseTranscriptfetchDmsSessionRecords() 改成按 c_session_id 查、按 c_created_at 正序,
逐行映射(record_id 回填 c_record_id,反馈闭环保留)writeDmsFeedback() 改回「按 c_record_id 找行 → 更新 c_feedback_* 列」,
不再做 JSON 读-改-写;定位不到时不新建行(避免造出只有反馈没有问答的空记录)deleteDmsSession() 改回「删该会话名下全部问答行 + 会话行」useBusinessAssistantChat.ts 四处挂钩(sendMessage / totalResponse / close /
submitQuestionAnswers)改为调 saveTurnToDms()(提交写 question、完成补 answer);
Session 026 加的聚合 provider 原样保留chat_record 就是一行一轮问答),也便于按轮次检索/统计completed/ 完全是空的,三个计划全在 active/,
其中 dms-chat-storage 与 policy-card-list-sidebar 早已实现completed/,并更新其状态头为实际结果(含「浏览器人工点验未做」的显式标注)dms-api-replacement.md 保留在 active,但加了「落地进度」表:
1887/1889 相关的 3 读 3 写已实现,企业信息(1888)未做——如实标出,不假装全做完validate-harness.mjs 加了两条检查(机械约束,不靠人记):
active/ 里的计划若状态写着「✅/已完成」→ 提醒该归档completed/ 里的计划若状态仍写「🔄/进行中/待拍板」→ 提醒状态与位置不一致harness/docs/exec-plans/README.md 的「机械检查」一节已说明该脚本会管归档
运行过的验证:
npm run build 通过node harness/tools/verify-dms-chat-storage.mjs 36/36 通过(已改为「一问一答一条」的断言:
第 1 轮 1 行 → 补 answer 仍 1 行且不覆盖 question → 第 2 轮 2 行 →
反馈只改命中那一轮、另一轮未被误改 → 删除连带清理)node harness/tools/validate-harness.mjs 通过;并实测新检查会报错已记录证据:本文件 Session 027;completed/ 下两个计划的状态头;
harness/tools/validate-harness.mjs 的归档检查
更新过的文件或工件:src/network/api/dms/chat-sessions-dms.ts、
src/components/business-assistant/useBusinessAssistantChat.ts、
harness/tools/{_entry-dms.ts,verify-dms-chat-storage.mjs,validate-harness.mjs}、
harness/docs/exec-plans/(两个计划归档 + 一个加进度表)、根 README.md、本文件
已知风险或未解决问题:
exec-plans/README.md 里早就写了
「做完移入 completed/」,我没做。现在有了机械检查,但仍需要我每次收尾时真的跑它8f60119 里,未丢失)下一步最佳动作:浏览器验收 DMS 与政策列表两条;以及从本轮起,收尾必跑
validate-harness.mjs 并当场归档计划(已加进 sops/session-end.md 的清单)
1887 助手会话:该会话 1 行,c_title = 首问「猫和老鼠讲述了什么故事」,
c_credit_code = 访客_68e82b71-a3ea-4332-b616-d174c4e9e4d41889 助手问答记录:2 轮 = 2 行(同一 c_session_id、不同 c_record_id,
c_question 与 c_answer 均已写入)访客_ 前缀)、开发环境确实处于访客态(没有自动登录)dms-chat-storage、policy-card-list-sidebar、remove-dev-auto-loginnode harness/tools/validate-harness.mjs 通过feature_list.json 三条的 evidence/verification;
completed/ 下两个计划的状态头harness/feature_list.json、
harness/docs/exec-plans/completed/{dms-chat-storage,policy-card-list-sidebar}.md、
根 README.md、本文件policy-detail-index-mismatch(同政策多事项点详情内容一样)的浏览器验证仍未单独确认。
用户本次点验的是 DMS 与政策列表两条;那条 bug 的验收场景是「同一政策的两个申报事项
分别点开、内容不同」,属于特定构造场景。保守起见仍保留「浏览器未人工点过」的标注,
等用户确认过再改——不靠推断把状态改好看.env.development.local 并重启 dev serverdocs/exec-plans/active/multi-session-parallel-generation.mdInputShell 的 isGenerating ? emit('stop') : emit('send')
用的是全局 isGenerating → 切到别的会话后按钮仍是「停止」,用户想发送却触发了停止handleStopGenerate 先 smc.stopGenerate()(mitt 同步 emit close
→ 监听器把 activeGenerationSessionId 清空),再取目标时
getActiveGenerationSession() || currentSession 必然 fallback 到当前会话 →
停止打在用户正在看的会话上;stopAiMessage 还会 ensurePendingAiMessage
凭空造出一条空的"请求已取消"消息chat-generation-task.ts(类型 + 4 个纯决策函数,无 vue/网络依赖,可被 harness 打包):
isSessionGenerating / resolveSendAction / resolveStopTargetTaskId / shouldNormalizeSessionHistorytasks = ref(new Map<sessionId, ChatGenerationTask>());
finishTask / stopTask / stopAllTasks;补问数据按会话存 sessionInterruptPayloadssmc / messageChunkBuffer / activeGenerationSessionId / dmsAnswerWrittenFor /
getActiveGenerationSession / finishActiveAiMessage / buildAppendHistoryPayload / setupCoordinatorisGenerating 改 computed(() => tasks.size > 0);新增 currentSessionGenerating 给按钮用handleStopGenerate 改为 resolveStopTargetTaskId → stopTask —— 旧 bug 自然消失startNewChat 不再打断生成(后台任务继续,与主流产品一致)toastMessage + showToast;PC/Mobile 各挂 <Toast>;
info 事件终于有人监听了(此前 409/错误提示用户完全无感)
stopAllTasks()(在 loadHistory 之前,避免旧账户任务写到新身份下):is-generating 绑定改 currentSessionGenerating;InputShell/Composer 零改动npm run build 通过;npx vue-tsc --noEmit 本次涉及文件无新增类型错误verify-chat-task-utils.mjs — 24/24:空/ null 边界、发送决策三态、
停止目标解析(「当前在 B 时不会误停 A」)、归一化守卫、多会话并行组合语义verify-coordinator-multi-instance.mjs — 11/11:
两个协调器实例的事件完全隔离(停 A 时 B 零事件、B 出错时 A 零事件、
各自计数独立)—— 这是并行化的地基validate-harness 通过本文件 Session 029;两个新脚本;exec-plans/active/multi-session-parallel-generation.md
src/components/business-assistant/chat-generation-task.ts(新增)、
src/components/business-assistant/useBusinessAssistantChat.ts、
src/components/BusinessAssistantPC.vue、src/components/BusinessAssistantMobile.vue、
harness/tools/{_entry-chat-tasks.ts,verify-chat-task-utils.mjs,verify-coordinator-multi-instance.mjs,_entry-coordinator.ts}、
harness/docs/exec-plans/active/multi-session-parallel-generation.md、根 README.md、本文件
typewriterTestSession,已同步改为 per-session 判断浏览器双会话实测(上述清单),特别是 mock 测试按钮回归
ref(new Map())。ref 会把 Map 变成响应式代理,
tasks.value.get(id) 返回的是代理、不是存进去的那个对象。
实测:ref(new Map()).get('A') === 原始对象 → false;shallowRef → true。tasks.value.get(sessionId) !== task →
永远成立 → 监听器全部提前返回finishTask 从不执行 → 任务永驻 → isGenerating 恒真 →
按钮卡在"停止"态(用户看到的);真实对话的内容追加也被同一个守卫挡掉
(message 监听器同样提前返回)—— 这是比用户报告更严重的一面,同轮一并修掉tasks 改 shallowRef(new Map()) + 整体替换;新增纯函数
withTaskAdded / withTaskRemoved(复制出新 Map,不改原 Map),4 处写入点全部改用chat-generation-task.ts 末尾写清这个坑(含实测结论),防止以后被"简化"回去verify-chat-task-utils.mjs 第 6、7 节,脚本从 24 → 33 项):
ref(new Map()).get(k) !== raw(若哪天 Vue 行为变了这条会失败,
提示需重新评估写法)、shallowRef 下同一性保持、整体替换能触发 computed、
原地 mutate 不触发响应式_entry-chat-tasks.ts 从 vue 重导出 ref/shallowRef/computed(仅测试用)npm run build 通过verify-chat-task-utils.mjs 33/33;其余全绿(11/31/8/10/21/30);validate-harness 通过verify-chat-task-utils.mjs 第 6、7 节src/components/business-assistant/chat-generation-task.ts、
src/components/business-assistant/useBusinessAssistantChat.ts、
harness/tools/{_entry-chat-tasks.ts,verify-chat-task-utils.mjs}、harness/feature_list.json、本文件multi-session-parallel-generation → passing(用户确认:mock 测试按钮跑完恢复、
真实对话内容正常流式输出);verification 里那条「❌ 未做浏览器验证」已替换为实测确认multi-session-parallel-generation.md → 从 active/ 归档到 completed/,
状态头改为「已完成并人工验证」,并注明过程中踩过的响应式陷阱(Session 030)validate-harness 通过(19 项功能,in_progress 0 项)completed/multi-session-parallel-generation.md;
feature_list.json 的 evidenceharness/feature_list.json、harness/docs/exec-plans/(归档)、
根 README.md、本文件dms-api-replacement 里剩下的企业信息(1888) —— 计划仍在 active/,
1887/1889 相关的 3 读 3 写已实现,企业信息那条没做(前端只用到 name/credit_code
两个字段,替换成本很低)。需要用户拍板要不要做。policy-detail-index-mismatch 的浏览器验证仍未单独确认 —— 那个 bug 的验收场景是
「同一政策的两个申报事项分别点开、内容不同」,属特定构造场景(Session 025 修,
自动化证据齐备;用户尚未针对该场景单独确认过)。file-upload(not_started)/ chat-markdown-format(blocked) —— 都卡在等待决策
(后端契约 / 是否接受在适配层做格式归一化),见各自条目。.env.development.local 并重启 dev server。/data/dms/dms_upload/yszs,并说明后端地址是 http://192.168.2.23:8000dms_upload 的任何引用,
是全新信息。问了两个问题才定清楚:①用途 = 后端存放会话信息 JSON 的目录;
②形态 = 前缀是后端地址(http://192.168.2.23:8000).env.development 新增两个变量:VITE_CHAT_TARGET="http://192.168.2.23:8000" —— 聊天后端真实地址。
dev 下 VITE_CHAT_API 只是代理前缀 /chat-api,真实地址此前硬编码在
vite.config.ts 里;现改为读 envVITE_CHAT_SESSION_JSON_DIR="/data/dms/dms_upload/yszs".env.production / .env.qingpu / .env.test 各加 VITE_CHAT_SESSION_JSON_DIR
(值相同,后端同一个)vite.config.ts 的 /chat-api/ 代理 target 改读 env.VITE_CHAT_TARGET(保留同值兜底)docs/architecture.md 环境变量表补上两个新变量,并写明「地址往 env 放、别写死在代码里」npm run build 通过npm run dev(用户已在跑的 8083,vite 检测到 config 变更自动重启)
→ 首页 200;经 /chat-api/api/chat 打真实后端 → http=200(不是 502).env.* 与 vite.config.ts 的改动;
docs/architecture.md 的环境变量表.env.development、.env.production、.env.qingpu、.env.test、
vite.config.ts、harness/docs/architecture.md、根 README.md、本文件/data/dms/dms_upload/yszs 当作
URL 路径直接接在后端地址之后(http://192.168.2.23:8000/data/dms/dms_upload/yszs)。
如果 /data/dms/ 其实是服务器上的文件系统前缀、对外访问路径是别的
(例如 http://192.168.2.23:8000/dms_upload/yszs),告诉我改一行即可VITE_CHAT_SESSION_JSON_DIR 目前没有消费方(前端还没有读会话 JSON 的功能),
只作约定记录——已在变量注释与架构文档里写明,避免以后有人以为它在起作用VITE_CHAT_SESSION_JSON_DIR 取,不要另写死地址/data/dms/dms_upload/yszs 到底哪种形态有数据
(承接 Session 032 我按字面把 URL 假设写进 env 的那处不确定)| 探测 | 结果 |
|---|---|
192.168.2.23:8000 上 /data/dms/dms_upload/yszs/、/dms_upload/yszs/、/dms/dms_upload/yszs/ 等 7 种路径 |
全部 404,响应体固定 9 字节 Not Found |
同主机的 /openapi.json、/docs、/redoc |
也全是 404 —— 该服务不提供任何静态文件,是纯 API 服务 |
121.43.55.7:10081 上 /data/...、/dms_upload/... |
404(649 字节,Tomcat 默认错误页) |
同主机 /dms/dms_upload/... |
404(150 字节,Spring 的 JSON —— 说明这些请求确实进了 DMS 应用,但没有对应路由) |
拿真实会话 id(你测试留下的 a2fdfbc2-…)当文件名去探 |
仍全 404 |
| 聊天后端原始 SSE 里找路径/文件线索 | 无任何线索(不含路径字段) |
| 两个 IP 是否同一台机器 | 不是:192.168.2.23 只开 8000,121.43.55.7 只开 10081 |
POST /dms/file/getFilespath(form body),且是相对 DMS 进程工作目录(Tomcat 的 bin)的. = Tomcat bin;.. = Tomcat 根(含 webapps_dms、logs、uploads);
../.. = /usr/local;../../../.. = /;../../../../data/dms/dms_upload 才是目标/data/dms/dms_upload/yszs 存在,但返回里没有 content 字段 = 目录为空
(对比:同级 photo 7027 项、file 6324 项、video 3 项、other 1 项)getFiles 只能列目录、读不了文件内容(把文件路径传进去 → 「当前文件不是文件夹,无子文件」)/data/dms/dms_upload/yszs 是 DMS 服务器上新建的目录,目前没有数据;
它不是 URL(Session 032 我那句「完整地址 = {后端地址}{路径}」是错的),
前端也拼不出来。要读它只能走 DMS 接口,且目前没有读文件内容的接口。
.env.* 的注释(4 份):如实写明「不是 URL、实测全 404、读取要走 getFiles、
相对路径怎么写、目录当前为空」,并删掉那句错误的「完整地址 = …」docs/reference/DMS_API.md 新增 §8「浏览服务器文件目录」:参数、相对路径换算表、
返回形态(空目录没有 content 字段 / 传文件路径会怎样 / 参数名写错会 500)、
实测到的目录布局docs/architecture.md 里该变量的说明同步为「服务器目录,不是 URL」
运行过的验证:上述探测全部有原始响应为证;npm run build 通过;
validate-harness 通过
已记录证据:本文件 Session 033;docs/reference/DMS_API.md §8;四份 env 的注释
更新过的文件或工件:.env.development、.env.production、.env.qingpu、.env.test、
harness/docs/reference/DMS_API.md、harness/docs/architecture.md、根 README.md、本文件
已知风险或未解决问题:
dms_upload/ 根下确实有 7 个 uuid 命名的 .json,
但我没有读文件内容的接口,无法确认它们是不是会话 JSON)192.168.2.23:8000,而该路径在 121.43.55.7 上(两台机器)——
若后端要写这个目录,是跨机器写(可能经 DMS 的文件接口),这点需用户/后端确认下一步最佳动作:请用户确认两件事 ——①后端是否已经开始往这个目录写会话 JSON; ②如果已有文件而我看不到,请给一个文件名/完整示例,我按它验证读取路径
/data/dms/dms_upload/yszs 有关的改动
(问清过范围:只针对这个路径,不是今天全部改动).env.*:删掉 VITE_CHAT_SESSION_JSON_DIR 变量及其 6 行注释块docs/architecture.md:删掉该变量的表格行docs/reference/DMS_API.md:删掉 §8「浏览服务器文件目录」(它是为查这个路径写的)README.md:删掉该变量的那条记录VITE_CHAT_TARGET(聊天后端真实地址)——它与 yszs 路径无关,
是「把硬编码在 vite.config.ts 里的后端地址收进 env」的独立改进。若也要一并回退,说一声即可.env.*、architecture.md、DMS_API.md 里已无 dms_upload /
VITE_CHAT_SESSION_JSON_DIR 的任何引用npm run build 通过;node harness/tools/validate-harness.mjs 通过.env.development、.env.production、.env.qingpu、.env.test、
harness/docs/architecture.md、harness/docs/reference/DMS_API.md、根 README.md、本文件0caf1ae、7ce6ed3)——
包括那次探测得到的「DMS 有 POST /file/getFiles、path 是相对 Tomcat 工作目录的路径」
这条通用知识(不止对 yszs 有用)。如果哪天要浏览 DMS 服务器上的文件,
可以 git show 7ce6ed3 取回,或让我把不含 yszs 的部分重新写进参考文档下一步最佳动作:无(本轮为回退);功能清单里仍无 in_progress,可做的事见 Session 031 列的优先级
日期:2026-09-17
本轮目标:用户指出「进度日志顺序反了」——修正 progress.md 的会话记录顺序
已完成:
README.md 的日期节一致(README 的正序是 Session 016 时用户明确要求的)运行过的验证:
Session 000(基线) → … → Session 034,升序正确已记录证据:本文件 Session 035;上面的逐块校验输出
更新过的文件或工件:harness/progress.md
已知风险或未解决问题:
validate-harness.mjs 目前只查会话编号重复,
不查顺序)。若将来有人又插到开头,需要靠这条约定挡;要更强的话可以加一条校验下一步最佳动作:无(本轮为文档整理)
补充:同轮给 validate-harness.mjs 加了正序校验(会话编号必须升序),并实测能抓到乱序——故意把最新一条插到开头,校验器立刻报出「Session 035 之后跟了 Session 000」。测试后已恢复正序
docs/reference/current-api-call-sites.md,分三类列出
(已迁新接口 / 仍在用老接口 / 已无调用方的死代码),并在 reference/README.md 索引里登记/enterprise/login、企业信息 /fta_ent_policy/enterprise_info、
统计埋点(page_view / feedback)、ASR、OSS 上传(入口被注释,用户触发不到){VITE_MODEL_SERVER}/v1/policies —— fetchPolicyList 前面两档
(惠企政策库 / 本轮卡片兜底)几乎总能命中openid_to_token(四份 env 里只有 .env.test 的
VITE_USE_KNOWLEDGE_API=false)src/network/api/card/index.js 的三个函数
(getMediaList / giveFeedback / appendHistory)都没有调用方——
反馈切 DMS 后最后一个调用点消失;目前只剩 useBusinessAssistantChat.ts:2
一个未使用的 importPolicyListUpdatedMatch.vue 仍挂在 BusinessRecord 上,但新协议不再产出
policy-list-updated 块,只有旧历史内容可能触发node harness/tools/validate-harness.mjs 通过docs/reference/current-api-call-sites.md(含可重跑的复核命令)harness/docs/reference/current-api-call-sites.md(新增)、
harness/docs/reference/README.md、本文件docs/reference/current-api-call-sites.md
c_credit_code 归属 —— 共 5 处依赖c_feedback_*(业务),又发统计接口(运营)VITE_ASSISTANT_STATISTICS_BASE_URL 只有 .env.qingpu 配了,
其余环境回退 VITE_APIuseBusinessAssistantUpload.ts 里的appendHistory 的来历(早就注释,历史改由接口 enable_history 控制)fetchPolicyList
前两档 return 才能判断几乎走不到);validate-harness 通过docs/reference/current-api-call-sites.mdharness/docs/reference/current-api-call-sites.md、本文件title 同步 ②存问答记录时把问题文本写进 titleharness/tools/probe-dms-title.mjs,用完即清理),查出三件原本不知道的事:
must=true
(summary 摘要 / qpyszx 是否青浦营商咨询 / qyzt 是否识别到企业主体 / rzqpyx 是否落户青浦意向)。
不带它们写入会被 214 数据错误 拒绝(实测:不带 214、带上 200)title 只在创建那一刻初始化:改 c_title 后 title 不跟随
(实测:改完 c_title=标题B、title 仍是 标题A)——这正是用户说"改名后 title 不同步"的真因title 可以直接在 content 里写(写了就是那个值),且 DMS 会静默忽略未知字段
(1889 没有 summary 字段,多发不会报错)src/network/api/dms/chat-sessions-dms.ts):
buildAnalyticsFields(summary):补上那 4 个 must 字段。
只有新增时才带(实测更新不校验 must);取值:qpyszx 恒 true、
qyzt = 有企业信用代码、summary = 会话用标题 / 记录用问题文本、
rzqpyx 前端无从判断 → 先 false(已在代码注释里标明是猜的,待确认/后端回填)upsertDmsSession:每次 upsert 都显式带 title = 会话标题 → 改名后 DMS 的 title 跟着变upsertDmsRecord:新增记录时 title = 问题文本、summary = 问题文本
→ 不再是字符串 "null",DMS 列表里能看出是哪一轮verify-dms-chat-storage.mjs 扩到 42 项(新增第 7 节 title 断言),42/42 通过:
新建带 title、改名后 c_title 与 title 都变、记录 title = 问题文本、
以及「1889 无 summary 字段、多发被忽略」这条差异npm run build 通过;其余脚本全绿(33/11/31);validate-harness 通过verify-dms-chat-storage.mjs 第 7 节;
harness/tools/probe-dms-title.mjs(探测脚本,保留可重跑)src/network/api/dms/chat-sessions-dms.ts、
harness/tools/{probe-dms-title.mjs,verify-dms-chat-storage.mjs}、根 README.md、本文件rzqpyx(落户青浦意向)的取值是猜的——前端拿不到判断依据。若该字段应由后端
分析会话后回填,请告知,我把前端这一项去掉或改值(改一个常量即可)qpyszx(恒 true)与 qyzt(有企业码为 true)也请确认是否符合业务口径qpyszx / qyzt / rzqpyx 三个字段不要传值,
后续要传哪些他会告知chat-sessions-dms.ts 的 buildAnalyticsFields 去掉那三个字段,只保留 summary
(会话用标题、记录用问题文本)must=true,若再出 214 先看这里」212 无效token(32 项失败全部由此引起,与本次改动无关)must=true,
按 Step-0/上一轮的实测规律,缺 must 字段很可能再次被 214 数据错误 拒绝node harness/tools/verify-dms-chat-storage.mjs → 因 token 过期无法验证(已如实记录,
未把失败算到改动头上)summary(否则会误报)verify-dms-chat-storage.mjs 第 7 节的注释src/network/api/dms/chat-sessions-dms.ts、
harness/tools/verify-dms-chat-storage.mjs、根 README.md、本文件.env.development.local 的 DMS_TOKEN
并重启 dev server)verify-dms-chat-storage.mjs,确认写入是否仍通chat-sessions-dms.ts 删掉 buildAnalyticsFields 及其两处调用;
写入只发业务字段 + title:c_credit_code c_session_id c_title title c_source c_created_at c_updated_atc_credit_code c_session_id c_record_id c_question title c_source c_created_atharness/tools/verify-dms-payload-fields.mjs —— 打桩 fetch 抓真实请求体,
解析 form 里的 content 键集合,断言「不多不少」:9/9 通过title,不含 summary/qpyszx/qyzt/rzqpyx)title(原先不带 → DMS 里标题不跟随改名)title 的值就是问题文本globalThis.fetch 打桩即可断言发出的内容npm run build 通过;validate-harness 通过harness/tools/verify-dms-payload-fields.mjssrc/network/api/dms/chat-sessions-dms.ts、
harness/tools/{verify-dms-payload-fields.mjs(新增),verify-dms-chat-storage.mjs}、
根 README.md、harness/feature_list.json、本文件verify-dms-chat-storage.mjs 确认真实写入是否通过.env.development.local,按约定不入库;
vite 代理已自动重启拾取,实测经代理查询返回 200)title = 问题文本 已确认生效 |
| 1887 助手会话 | ❌ 被 214 数据错误 拒绝 || 载荷 | 结果 | |---|---| | 全不带(= 当前前端) | 214 | | +summary | 214 | | +summary+qpyszx | 214 | | +summary+qpyszx+qyzt | 214 | | 四个都带 | 200 成功 | | 只带 qpyszx(不带 summary) | 214 |
must=true。二者不能同时满足,必须有一方让步。verify-dms-chat-storage.mjs(打真实 DMS)——
1889 相关全通过、1887 相关因 214 失败(如实记录,未粉饰).env.development.local(不入库)、本文件must=false)→ 前端就能只传 title(最贴合用户要求)
②前端仍传这四个 → 取值口径要定;也可以先传空值/默认值(summary:"" 等)
让写入先通,后续再定口径 —— 但这是"为了通过校验而填",需用户认可
③维持现状 → 1887 会话写入持续失败summary:"" + 三个 false → 200 成功 ✓
(must 只校验"字段在不在",不校验非空)chat-sessions-dms.ts):
buildRequiredSessionFields(),返回 {summary:"", qpyszx:false, qyzt:false, rzqpyx:false}verify-dms-chat-storage.mjs 43/43 通过 —— 1887 写入已恢复
关键项:新建会话带 title ✓、改名后 c_title 与系统 title 都跟着变 ✓、
四个必填字段确实写进去了且为空/默认 ✓、记录 title = 问题文本 ✓verify-dms-payload-fields.mjs 10/10 通过(打桩抓请求体,不需要 token):
会话新增字段集合「不多不少」、更新不带那四个字段、记录不带npm run build 通过verify-dms-chat-storage.mjs 里「摘要应为会话标题」——已过期,改成
「写入且为空(不猜业务值)」+「三个布尔为默认 false」verify-dms-payload-fields.mjs 头部说明还写着旧的「不要传 summary」,已更正src/network/api/dms/chat-sessions-dms.ts、
harness/tools/{verify-dms-chat-storage.mjs,verify-dms-payload-fields.mjs}、
根 README.md、harness/feature_list.json、本文件qpyszx/qyzt/rzqpyx 都是 false、summary 为空。
若业务口径定了(或该由后端回填),需要再改这里[已停用] 与原因):
sendMessage:去掉 upsertDmsSession(会话行不再新建/更新)submitQuestionAnswers:同上renameSessionAt):不再同步到服务端,只改本地syncSessionTitleToServer 函数整体注释;upsertDmsSession / updateSessionInfo
两个 import 一并去掉(注释说明去处)title=问题文本)、反馈写入、删除会话仍清理 1889 记录verify-dms-payload-fields.mjs 扩到 12 项(新增第 4 节源码级断言):
断言 composable 里不再有未注释的 upsertDmsSession / syncSessionTitleToServer 调用,
且停用点以注释形式保留 —— 12/12 通过
(这条是为防「以后被顺手改回去」——毕竟它是靠注释实现的)verify-dms-chat-storage.mjs(打真实 DMS):43/43,跑了 6 次npm run build 通过verify-dms-payload-fields.mjs 第 4 节src/components/business-assistant/useBusinessAssistantChat.ts、
harness/tools/verify-dms-payload-fields.mjs、根 README.md、
harness/feature_list.json、本文件mergeRemoteSessions 拿到空数组是无害的,行为与接 DMS 前一致)docs/reference/current-api-call-sites.md:card/index.js 仍无调用方,唯一残留引用是
useBusinessAssistantChat.ts:2 那个未使用的 import(仍在)POST /api/chat(对话)、DMS 1889(问答记录写入+读取、反馈、删除清理)/enterprise/login、企业信息 /fta_ent_policy/enterprise_info、
统计埋点(page_view / feedback,与 DMS 双写)、ASR、OSS 上传(入口被注释){VITE_MODEL_SERVER}/v1/policiesopenid_to_tokenvalidate-harness 通过;每条与代码核对过docs/reference/current-api-call-sites.mdharness/docs/reference/current-api-call-sites.md、本文件useBusinessAssistantChat.ts:2 那个未使用的 import cardAPI 还在(是死模块 card/index.js
的唯一引用)——要清理另说,属独立的小清理BusinessAssistant.vue(页面浏览)、BusinessRecord.vue(反馈统计)src/network/api/assistant-statistics.ts 删除VITE_ASSISTANT_STATISTICS_BASE_URL 从 .env.qingpu 删除getOrCreateAssistantVisitorId)——DMS 的
访客_<id> 归属在用它,已搬进 src/network/api/dms/chat-sessions-dms.ts;
localStorage 的 key 字符串保持不变(assistant_statistics_visitor_id),
改了会让已存在的访客换新 id、同一浏览器在 DMS 里变成两个访客src/network/api/card/index.js 整文件删除(三个函数都无调用方),
连同 useBusinessAssistantChat.ts 里那个未使用的 import cardAPIassistant-statistics.ts(同上)PolicyListUpdatedMatch.vue 有意保留:它虽对新协议无效,
但老会话的历史内容里可能还有 <policy-list-updated> 块,删了那些卡片列表渲染不出来npm run build 通过;vue-tsc --noEmit 本次涉及文件无报错verify-dms-payload-fields.mjs 12/12;verify-dms-chat-storage.mjs(真实 DMS)43/43assistant-statistics / network/api/card 在 src 下零引用;validate-harness 通过docs/reference/current-api-call-sites.mdsrc/network/api/{card/index.js,assistant-statistics.ts};
改 src/network/api/dms/chat-sessions-dms.ts、src/components/BusinessAssistant.vue、
src/components/Chat/BusinessRecord.vue、
src/components/business-assistant/useBusinessAssistantChat.ts、.env.qingpu、
docs/reference/current-api-call-sites.md、根 README.md、本文件page_view / feedback 看板,需要告知他们
(这是用户明确要求删除的)git log --diff-filter=D --name-only)查证结果:只依赖 1 个 WebSocket 接口
wss://qingpu-data-api.metamaker.cn/common/asr_hub
?project=qingpu&engine=aliyun_dashscope&is_long_connection=true&heartbeat=true
VITE_ASR;引擎是 aliyun_dashscope(阿里云百炼),不是讯飞src/three-libs/asr/index.ts(JWT iss: human-large-screen,
exp 2034-12-29)→ ASR 没有 DMS 那种每天换 token 的问题appKey,但当前分支用不到(只在讯飞分支的 gen_auth 用)gen_auth + wss://{VITE_XF_ASR}/api/human_asr/v2/asr)走不到:
index.html 硬编码 globalThis.isXF = false⚠️ 修正了我自己写错的文档:current-api-call-sites.md 第 3 条原先写
「接口:{VITE_ASR}/api/human_asr/v2/asr/gen_auth 取鉴权,再开 WebSocket」——
那是讯飞分支的地址、而且是未启用的那条;已改成实际在跑的 /common/asr_hub
(含引擎、鉴权、未启用分支三部分)
运行过的验证:逐行读 asr/index.ts 的 fetchToken / openSocket 分支与
isXF 赋值处;validate-harness 通过
已记录证据:本文件 Session 046;docs/reference/current-api-call-sites.md 第 3 条
更新过的文件或工件:harness/docs/reference/current-api-call-sites.md、本文件
已知风险或未解决问题:
VITE_XF_ASR / 讯飞分支整条是死代码(isXF 恒 false),但没删(不在本轮范围)下一步最佳动作:无(本轮为查证 + 改文档)
three-libs 不是数字人
find src/three-libs -type f → 只有 asr/index.ts 一个文件BusinessAssistantPC.vue:347 与 BusinessAssistantMobile.vue:405
都 import ASR from '@/three-libs/asr/index' —— 点麦克风走的就是它architecture.md 的一句失实描述:「src/three-libs/metamaker
是未被引用的 SDK 包」——该目录根本不存在(同日核查)。已改为
「three-libs 下只有 asr,且在用;目录名与 three.js 无关,有误导性」VITE_STREAM_SERVER / VITE_STREAM_TOKEN /
VITE_HUMAN_IDLE_MP4_URL 三个无人读取的 env + vite 的 /stream/ 代理(target 硬编码)architecture.md 的失实描述(上)find 列出 three-libs 全部文件;grep 反查 import 方;validate-harness 通过architecture.md 的更正harness/docs/architecture.md、本文件three-libs/asr 不能删docs/exec-plans/tech-debt-tracker.md 第 6 条,
免得下次会话又从头讨论:wss://qingpu-data-api.metamaker.cn/common/asr_hub(引擎 aliyun_dashscope),
token 与 appKey 硬编码在源码里(token exp 2034,入库可见)reference/current-api-call-sites.md)→ 自建网关时前端零改动isXF 写死改成 env 开关,便于改配置切讯飞分支git status 干净;validate-harness 通过tech-debt-tracker.md 第 6 条harness/docs/exec-plans/tech-debt-tracker.md、本文件buildQuestionWithAttachments(question, fileUrls)(shared.ts):
原文 + 换行 + 每条 URL 一行;不加「附件:」之类自定义措辞(不凭空发明后端可能不认的写法);
无有效 URL 时原样返回(不能凭空多换行,否则影响所有普通提问)sendMessage 拆开两个变量(关键):
questionForBackend = 拼了附件地址 → 只发给后端text = 用户原文 → 本地消息内容、会话标题、DMS 记录
这样用户气泡里不会出现一长串 URL,DMS 记录也保持可读BusinessAssistantMobileInputShell.vue 里原先整块注释):
上传图片 / 上传附件。拖拽与粘贴本来就是通的,不是本轮才恢复OSS_SIGNATURE_URL 改为可配置:新增 env VITE_UPLOAD_SIGN_URL(四份 env 都写,
值仍是厂商地址)→ 将来换自建签名服务只改配置、不改代码POST //qingpu-data-api.metamaker.cn/common/qp_signed_url body {ext} → 返回
file_url(https://prod.heijingai.com/qingpu/<uuid>.<ext>)、host
(https://heijing-products.oss-cn-hangzhou.aliyuncs.com)、key、policy 等heijing-products)里,不是我们的 ——
政务场景下这点值得评估(数据在第三方)harness/tools/verify-attachment-question.mjs —— 10/10 通过:
无附件原样返回(undefined/null/空数组/全空串)、有附件时原文在前且 URL 各一行、
不添加自定义措辞、脏数据过滤、URL 去空格npm run build 通过verify-attachment-question.mjs;签名接口实测响应src/components/business-assistant/shared.ts、
src/components/business-assistant/useBusinessAssistantChat.ts、
src/components/business-assistant/useBusinessAssistantUpload.ts、
src/components/BusinessAssistantMobileInputShell.vue、四份 .env.*、
harness/tools/{_entry-attachments.ts,verify-attachment-question.mjs}(新增)、
根 README.md、harness/feature_list.json、本文件.env.development 里 4 个变量定义多次VITE_STREAM_SERVER、VITE_STREAM_TOKEN(含两个凭据)、
VITE_HUMAN_IDLE_MP4_URL、VITE_PLUGIN_URL、VITE_PLUGIN_ASR
—— 都是数字人/插件那条从未接入本项目前端的链路的遗留.env.development 的重复定义:VITE_ASR、VITE_XF_ASR 各定义两次,合并成一份/stream/ 代理(无代码使用);/asr/ 代理保留(讯飞退路的一部分)public/images、public/pencil_exportsstats.html(968K,rollup visualizer 的分析产物,npm run build 会重新生成).env 的语义是后定义的生效 —— 于是 VITE_ASR 被改成了原本的死值
(wss://human-screen-v3...),会弄坏语音识别。核对 diff 时发现,已把保留的那两行
改回正确的生效值(https://qingpu-data-api.metamaker.cn 与 /asr)。
教训:合并重复配置前要确认哪一份生效(.env 是后者覆盖前者)。src/three-libs/asr/ —— 语音识别,在用(用户明确「先用原来的」)VITE_XF_ASR + vite /asr/ 代理 —— 切引擎的退路(与 ASR 那个决定配套)src/components/stream-message-coordinator.ts —— 架构文档明确「保留未用,勿改」PolicyListUpdatedMatch.vue —— 老会话历史里可能仍有 <policy-list-updated> 块dist/(3.1M)—— 是部署产物,不是垃圾;要删另说npm run build 通过VITE_ASR 生效值正确、VITE_XF_ASR=/asr 与代理保留、
上传签名地址在、政策库 json 在vite.config.ts 里那 3 处是我写的说明注释)/chat-api 打真实后端 200(env 缩减后没坏)validate-harness 通过.env.*、vite.config.ts、根 README.md、本文件
(删除:stats.html、public/images/、public/pencil_exports/)VITE_STREAM_TOKEN)仍在 git 历史里;它们是数字人流的 token,
现无代码使用,但若安全要求彻底清除需重写历史dist/ 未删(部署产物),要腾空间可自行删harness/tools/probe-answer-latency.mjs:打真实后端,记录每个 SSE 事件的时间戳,
并把前端打字机的展开时间按 TextContent.vue 的真实参数模拟出来,拆成两段| 问题 | 后端耗时 | 回答字数 | 前端打字机 | 用户感知 |
|---|---|---|---|---|
| 「你好」 | 1.3s | 81 | 0.5s | 1.7s |
| 「高新技术企业能享受什么优惠」 | 49.8s | 767 | 1.9s | 51.7s |
| 「奶茶店有什么扶持政策」 | 65.0s | 896 | 2.1s | 67.1s |
summary(正文)在 48.4s 整段到达,
在此之前界面上没有任何正文,只有「思考中」卡片 + 心跳提示"还在为您准备答复"recommend 阶段(26.5 秒,期间只有 heartbeat);
30.8s 才出 source + summary第一版脚本只统计 answer 事件的字数,而这个后端把正文放在 summary 里
(result.response 是同样的快照)→ 一开始得出「回答总字数 0」,误以为是"后端零输出"。
已改为统计 answer + summary(不含 result 快照,因为适配层也只展示一次)后重测,
上面的表是修正后的数据。
即便再调快,收益是秒级;而后端是几十秒级 → 优先治后端
运行过的验证:三个问题各实测一次(原始事件时间戳为证);validate-harness 通过
已记录证据:本文件 Session 051;harness/tools/probe-answer-latency.mjs(可重跑)
更新过的文件或工件:harness/tools/probe-answer-latency.mjs(新增)、本文件
已知风险或未解决问题:
recommend 阶段内部发生了什么,前端看不到)下一步最佳动作:把这份数据给后端;最有效的一步是让后端改成流式(边生成边推
answer/summary),用户等待感会立刻不同;其次是查 recommend 阶段为何要 26 秒
PolicyMatch.vue 的 rebuildCardList():数据源改回本组件自己的卡片
(props.policyData / props.policyContent,与 syncPolicies 同源),
不再 inject('collectSessionPolicyCards') 往上层取会话级聚合useBusinessAssistantChat.ts:删掉 provide('collectSessionPolicyCards', …) 与对应的 importpolicy-match-utils.ts:删掉 collectPolicyCardsFromMessages() 及其
parsePolicyTableContent import(该函数是它唯一的使用者)harness/tools/_entry-policy-cards.ts:去掉该导出verify-policy-card-list.mjs:删掉第 1 节(跨消息聚合的断言)、
第 6 节改用直接传入卡片数组;头部两段说明也改了(有一段还写着
「侧边栏要的是整个会话的聚合」,与新行为自相矛盾,留着会误导)dedupePolicyItems、排序
sortPolicyCardsByMatchScore、关键词过滤 matchesPolicyCardKeyword
—— 同一回答里若出现重复条目,仍会去重verify-policy-card-list.mjs 24/24 通过(原 31 项中去掉 7 项聚合断言)npm run build 通过collectSessionPolicyCards / collectPolicyCardsFromMessages
在源码与入口里零命中(只剩我写的"已移除"说明注释)validate-harness 通过verify-policy-card-list.mjssrc/components/Chat/PolicyMatch.vue、
src/components/Chat/policy-match-utils.ts、
src/components/business-assistant/useBusinessAssistantChat.ts、
harness/tools/{_entry-policy-cards.ts,verify-policy-card-list.mjs}、
根 README.md、harness/feature_list.json、本文件policy-card-list-sidebar 的 verification 去掉「❌ 未做浏览器实测」,
换成用户的实际确认;evidence 同步更新validate-harness 通过;git status 干净feature_list.json 的 policy-card-list-sidebarharness/feature_list.json、本文件file-upload 仍是 in_progress:它的「浏览器端到端」还没确认过
(本轮用户的「检查无误」是指「更多」侧边栏那条)。上传那条要确认的是:
选文件 → 上传成功 → 提问 → 后端确实收到带 URL 的 questionfile-upload 的 verification 去掉「❌ 未做浏览器端到端」,换成用户确认;
状态 in_progress → passingvalidate-harness 通过(19 项功能,in_progress 0 项);git status 干净feature_list.json 的 file-uploadharness/feature_list.json、本文件heijing-products,杭州),
链接为 prod.heijingai.com——数据在第三方,政务场景下需评估(用户知悉)VITE_UPLOAD_SIGN_URL,
换自建服务时只改配置;替换需要提供的清单已在本会话给出(OSS 凭据 + 一个签名服务 + 文件访问前缀)BusinessAssistantMobile.vue 首页):| 元素 | 改前 | 改后 |
|---|---|---|
| 顶栏 .mobile-header | 64px | 52px |
| logo .overlay-icon(含下边距) | 130×88 + 41 | 96×65 + 20 |
| 大标题 .heading | 28px(13 字放不下会折两行) | 22px + clamp(18px,5.6vw,22px) + nowrap(恒定单行) |
| 欢迎区 gap | 7px | 6px |
| 介绍文案行高 | 1.6 | 1.5 |
| 建议词区 | padding 28 / gap 12 | padding 18 / gap 10 |
clamp 覆盖」的写法:老内核拿到 22px(窄屏也放得下),
新内核按屏宽继续收 —— 目的是保证恒定单行.home-fixed-input 仍是 position: fixed
悬浮、不占文档流,而给它预留的是写死的 padding-bottom: 180px,与输入框实际高度无关
—— 字体一放大、输入框一换行,180px 就不够npm run build 通过;逐项核对新值已落进源码src/components/BusinessAssistantMobile.vue、本文件、技术债清单position: fixed,改成
.ba-mobile-container 的 flex 子项 + .mobile-main { min-height: 0 })——
那样剩余高度是浏览器算出来的,与字体/内核无关,数学上不会再遮BusinessAssistantMobile.vue 首页):
.welcome-section 新增两侧留白 padding: 0 28px(连 .mobile-main 的 8px,
视觉留白约 36px)—— 标题与介绍文案都不再贴屏幕边.heading 字号 22px → 20px(clamp 同步收为 clamp(17px, 5.2vw, 20px)).desc 字号 14px → 13px:加留白后可用宽度变窄,字号同步收一点以避免多折一行npm run build 通过;排版用脚本实算(非目测)src/components/BusinessAssistantMobile.vue、本文件9ccf536 提交里;本次是在其基础上继续调字号与留白BusinessAssistantPC.vue:127 与 BusinessAssistantMobile.vue:39 的标题
你好,有什么我可以帮您的? → 有什么我可以帮您的?(两端一致,只动文字)查过的项与结果(全部为实际执行输出,非推断):
| 检查 | 结果 |
|---|---|
git status / HEAD 与 origin/main |
✅ 干净、一致——没有未提交或未推送的改动 |
最后一次提交之后有无源文件被改(find -newermt,排除忽略项) |
✅ 无(只有我本轮改的两个文件) |
bash harness/init.sh |
✅ exit 0(含 npm install + npm run build) |
node harness/tools/validate-harness.mjs |
✅ 通过(19 项功能,in_progress 0 项) |
| 10 个验证脚本 | ✅ 全绿:33/11/24/8/10/21/30/10/12 + 打真实 DMS 的 43/43 |
exec-plans/ |
✅ active 只剩 dms-api-replacement.md(在办),completed 3 个 |
| dev server 8083 | ✅ 在跑,首页 200 |
→ 上一段会话(Session 056)在提交 f1951af 之后结束,文件夹层面没有半成品。
查到 5 处异常,全是「记录与事实不符」,已当场修掉:
CLAUDE.md 末尾自相矛盾:底部仍写着「harness/ 与 harness/docs/ 按 .gitignore
约定不入库」,而上方「提交约定」写着 2026-09-17 起已入库
(Session 024 声称改过三处,漏了这一处)→ 已改为如实说明。README.md 从未有过 ## 20260918 节:09-18 的改动(DMS 系列、上传恢复、
统计埋点删除、清理…)一直被追加在 ## 20260917 标题下面(从 09-18 第一个提交
061bcfc 起就这样,不是本次才漂的)→ 已在正确位置补上 ## 20260918。dvh 无 vh 兜底、无 text-size-adjust、padding-bottom: 180px 写死)。feature_list.json 的 dms-chat-storage 条目内部打架:同一段 notes 里
同时存在「1887 写入已恢复(43/43)」与「🚨 1887 会话写入当前是坏的」、
已推翻的「一个会话一行」粒度、以及「浏览器人工点验始终没做」
(而同条 verification 里写着用户 09-17 已确认);meta.last_updated 也停在 09-17。
→ 已把这些按当前事实重写成一段(在写的/已停用的/规矩/踩过的坑/已知缺口),
并去掉与 verification 冲突的表述;payload 脚本断言数由 9 更正为 12。npm run build 通过(改动后重跑)bash harness/init.sh exit 0;validate-harness.mjs 通过verify-dms-chat-storage.mjs 43/43,
token 有效期至今晚 21:33)README.md 的 20260918 节;
feature_list.json 的 dms-chat-storage 重写;技术债第 7 条的「当前进度」src/components/BusinessAssistantPC.vue、
src/components/BusinessAssistantMobile.vue、CLAUDE.md、README.md、
harness/feature_list.json、harness/docs/exec-plans/tech-debt-tracker.md、本文件nowrap + 字号没变,去掉 3 个字只会更宽松,理论无风险)feature_list 的 notes
都是"只往上加、不回头改",越积越互相矛盾。下一轮起,改完当场回写——
这次靠人工发现,validate-harness.mjs 目前查不出这类内容矛盾。若要加机械约束,
可考虑"README 的日期节必须覆盖最近提交日期""notes 里出现互相矛盾的结论"这类检查。policy-detail-index-mismatch 仍保留「浏览器未人工点过」的保守标注(Session 028 的决定),
用户若顺手验过「同政策两个申报事项点开内容不同」,即可去掉该标注你好,有什么我可以帮您的?.heading | 32px | 28px |
| 移动端 .heading | 20px + clamp(17px,5.2vw,20px) | 16px + clamp(13px,4.2vw,16px) |@media (max-width: 360px) 里有一条 .heading { font-size: 28px }.heading 基础值就是 28px),
但 2026-09-18 基础值改小后,它在窄屏上反而把标题放大回 28pxwhite-space: nowrap,13 个字 × 28px ≈ 356px,
而 360px 屏可用宽度只有 272px → 标题被顶出屏幕约 84px(正是用户此前抱怨的那类现象).heading 自己的 clampnpm run build 通过.heading 只剩 PC 与移动端两处定义(窄屏覆盖已注释),无其他覆盖源README.md 20260918 节
(上一轮那条「去掉你好」的记录已改写为「一度去掉、随即加回」——不留错误的旧记录)src/components/BusinessAssistantPC.vue、
src/components/BusinessAssistantMobile.vue、README.md、本文件
(feature_list.json 未动:纯样式/文案调整,无新增能力)clamp(13px,4.2vw,16px) → clamp(17px,5.2vw,20px)font-size: 20px,但紧跟着的
font-size: clamp(13px, 4.2vw, 16px) 在后面、会覆盖它(同优先级后者胜)
→ 现代浏览器上实际渲染仍是 ≤16px,手改等于没改。
已在代码注释里写明「两行必须同进同退」,避免下次再踩。npm run build 通过src/components/BusinessAssistantPC.vue、
src/components/BusinessAssistantMobile.vue、README.md、本文件font-size 写两行做降级时,只改基础值不生效;
这类"同值覆盖"在改基础值后会变成有效覆盖(本轮 Session 058 已经踩过一处 28px 的).heading:clamp(17px, 5.2vw, 20px) → clamp(17px, 5.7vw, 22px)
(基础值同步改 22px;两行一起改,见 Session 059 的坑)5.7vw 这个系数是按上限等比推的(原 5.2vw 对应上限 20px):
≥386px 的屏恒为 22px,更窄的屏按比例收缩npm run build 通过src/components/BusinessAssistantMobile.vue、README.md、本文件clamp(17px,5.7vw,22px) 即可nowrap,各屏能容纳的上限是
320px→18.5px / 360px→21.6px / 375px→23.9px(脚本实算)。再往上调就得
先收窄两侧留白或允许折行reference/company-classification.md 与业务需求 ——
对话返回 company_info 非空时调分类接口,把结果同步到 DMS 的 1888(企业基础信息)
与 1886(企业荣誉信息):没有就新增、有不一致就更新;DMS 缺的字段我来加或列清单给他docs/exec-plans/active/company-classify-dms-sync.md
(计划已落库,含 Step-0 实测与字段清单)①DMS 读写复用现有路径(「原来怎么用现在就怎么用」,不走 121.43.55.7:2101/proxy_dms) ②六个角度各存 1888 一个字段 ③1886 一个标签一行 ④1888 对比范围 = 身份三字段 + 6 个标签字段
| 探测 | 结果 |
|---|---|
| DMS 字段 | ✅ 7 个字段已经加好了(POST /dms/model/getModelById,2026-09-18 17:35–17:36 由 DMS 侧更新):1888 增 c_tag_basic/c_tag_honor/c_tag_sector/c_tag_operation/c_tag_industry/c_tag_license(模型 41→47 字段),1886 增 c_tag_name(11→12 字段)。不需要再请人加字段,原计划那份清单作废 |
| 模块 must | 1888 只有 c_credit_code;1886 是 c_id + c_credit_code(都实测自模型定义,不再是猜) |
| 🚨 classify 接口 | 已部署,但对任何带内容的请求体返回 422 invalid_request —— 文档自己的合成示例、纯 ASCII 体(排除编码因素)、company/profile/honors/company_info/Company 各种键名、{"company":{}}、{"company":null} 全部 422;只有 {} 返回 200(六角度全空 + company_info_empty)。响应头有 x-request-id 可查后端日志。属后端问题,需后端排查 |
新增:
src/network/api/dms/classification-sync-utils.ts —— 纯逻辑层(不 import vue、不发网络):
身份抽取(profile.data 优先、逐项回退 company)、标签编解码(JSON 数组字符串)、
buildEnterpriseRow、diffEnterpriseRow、buildHonorTags、partitionHonorRows、diffHonorRows、
extractFailedAnglessrc/network/api/company-classification.ts —— classifyCompany + resolveClassifyEndpoint
(base URL 复用 getChatApiBaseUrl();VITE_CHAT_API 的两种写法都处理)src/network/api/dms/company-info-sync.ts —— 编排:syncCompanyInfoFromChat(入口,fire-and-forget)、
applyCompanyClassification(单独导出,供验证脚本注入假分类结果)、1888 upsert、1886 集合同步改:
api-chat-coordinator.ts 5 处:ChatResultPayload/ApiChatTotalResponsePayload 加
company_info、新增 pendingCompanyInfo 实例字段、case 'result' 由空分支改为暂存、
resetTurnContent() 清空、finishTurn 的 totalResponse 非空才带出useBusinessAssistantChat.ts 1 处:totalResponse 监听器里 saveTurnToDms 之后、
finishTask 之前调 syncCompanyInfoFromChat(PC/Mobile 共用,挂一次两端生效)dms/client.ts:加 1888/2036、1886/2032 常量;把 findRowBy/enqueueWrite 从
chat-sessions-dms.ts 上移为 findDmsRowBy/enqueueDmsWrite 共用(那两处坑不想再踩第二遍)npm run build 通过;npx vue-tsc --noEmit 本次涉及文件无新增类型错误verify-company-classify-sync.mjs —— 53/53 通过(纯逻辑,六节:身份抽取、
编解码、期望行与 diff、1886 集合、partial/保守降级、接口地址拼接)verify-company-classify-dms.mjs —— 23/23 通过(打真实 DMS,注入假分类响应):
首写两栏目内容正确(含富文本字段 c_tag_honor 的逐字往返断言——它 frontType 是
content,我特意单列一条防被清洗)、同数据重跑零新增、改标签后 1888 仍一行且已更新、
1886 收敛到新集合(旧的删、新的增)、资质荣誉空集删光我方行而迁移行不动、
缺信用代码整条跳过、清理后两栏目残留 0 行verify-dms-chat-storage.mjs(真实 DMS)43/43、
verify-dms-payload-fields.mjs 12/12,其余 8 个离线脚本全绿node harness/tools/validate-harness.mjs 通过(20 项功能,in_progress 1 项)
已记录证据:本文件 Session 061;两个新脚本;exec-plans/active/company-classify-dms-sync.md
的 Step-0 表;reference/README.md 与 DMS_COLUMNS.md 的字段更新;feature_list.json 的
company-classify-dms-sync
更新过的文件或工件:新增 classification-sync-utils.ts、company-classification.ts、
company-info-sync.ts、_entry-company-classify.ts、两个验证脚本、
exec-plans/active/company-classify-dms-sync.md;改 api-chat-coordinator.ts、
useBusinessAssistantChat.ts、dms/client.ts、dms/chat-sessions-dms.ts、
reference/README.md、reference/DMS_COLUMNS.md、reference/DMS_MAPPING.md、
tools/README.md、根 README.md、feature_list.json、本文件
(另:reference/company-classification.md 原本 untracked,本次一并入库)
已知风险或未解决问题(需后端配合,用户已知悉):
node harness/tools/verify-company-classify-dms.mjs,并补浏览器实测company_info 的真实载荷未知:api-chat.md 里没有它的契约章节(classify 文档的
引用超前),前端按「全可选 + profile.data 优先回退 company」容错读取。
拿到真实载荷后应把契约回填进 api-chat.mdcompleted + 空数组 = 覆盖清空(会删光我方荣誉行):这是「分类结果即最新事实」的默认语义,
若业务要保留历史荣誉,改成「只增不删」即可(一行)下一步最佳动作:把 422 反馈给后端;修好后跑一次真实分类 + 浏览器端到端,
通过后本条转 passing
用户贴了一份真实的 company_info(上海元以数智科技有限公司)。用它一跑,结论翻了:
{company, profile, honors} 返回 200(7 秒,六角度真实结果:
基本信息=成立年限5-10年/是否内资公司、产业信息=第三产业/软件信息、经营活动=是否在青浦区实际经营)。
→ 上一段把它记成「任何带内容的请求体都 422 / 需后端排查」是不准确的,特此更正;
已写进 api-chat.md 的 company_info 章节ams_user 灌过数据:
c_level 国家级/区级、系统 title 存荣誉名、
c_source 存来源通知名,如「关于公布第三批国家邮政局技术研发中心认定结果的通知」)
→ 所以同步多数时候走的是「更新已存在的行」:只动身份三字段 + 6 个 c_tag_*,
其余工商字段不碰;1886 的所有权标记让它绝不会误删灌入的荣誉行(这条已实测)
→ reference/README.md 里我当轮刚写的「F4 未开始、这两栏目只有前端写的行」已改回事实本轮补充的验证(两个新脚本 + 一次真实探测):
verify-company-info-passthrough.mjs 10/10:用假 SSE 流喂真实协调器,
断言 result 里的 company_info 原样带出到 totalResponse、不混进渲染正文、
无该字段时不带键、null 不传、跨轮不沿用上一轮(resetTurnContent 生效)verify-company-classify-e2e.mjs 16/16:真实 company_info → 真实分类接口 →
真实 DMS 落库;断言六角度正确落位、1886 行数=标签数、再落一次零新增/api/chat:公司类提问的 SSE 里,result.data 的键为
response / company_info / recommendation / errors —— company_info 确实存在且拼写正确
(用户贴的 "compang_info" 是笔误),结构与代码假设逐字段一致过程中修正了三条我自己的错:
[],而库里读回来是「字段不存在」
—— DMS 不落空值;已改成按解码后的集合比较(代码本身一直是对的,diff 判定「无变化零写入」)curl -d 会被 Git Bash 弄成 422(/api/chat 也一样),
要用 node 写成文件 + --data-binary @file —— 这条踩了两次,值得记住数据现状:真实企业「上海元以数智科技有限公司」(91310118MA1JLRL16G)的 1888 行
已被同步更新(新增 c_tag_basic / c_tag_sector / c_tag_operation),这是功能的正常行为、
且幂等;需要删可用 e2e 脚本的 --cleanup。
仍未做:浏览器端到端(自动化证据已齐,只差真人在页面上问一个公司类问题)。
验证脚本清单已更新进 harness/tools/README.md。
用户反馈:测了宇树科技股份有限公司,荣誉出来 2 条,感觉不止两条。查证结论: 同步没写错,是数据源选错了。
| 数量 | |
|---|---|
| 分类接口的「资质荣誉」标签(原实现的数据源) | 2(中国独角兽企业、国家级高新技术企业)——165 个标签里挑的粗标签 |
company_info.honors.records(真实荣誉) |
31 条,含荣誉名/级别/来源/发布机构/发布日期/证书编号 |
| 库里 1886 已灌入的行 | 28 条——同一份企查查荣誉数据的旧快照,字段与 records 一一对应 |
用脚本算出的差集:载荷有、库里没有 4 条;库里有、载荷没有 1 条。
用户拍板:①1886 改存 honors.records,与库里的行全量对齐(补 4、删 1、同键行更新差异)
②荣誉名同时写 title 与 c_tag_name(顺带修掉我上轮 title="null" 的瑕疵)。
改动:
classification-sync-utils.ts:删掉按 c_source 认所有权的旧方案,改为
parseHonorRecords / buildHonorKey(荣誉名+级别+来源)/ readHonorName(c_tag_name → title 回退,
字符串 "null" 不算名字)/ diffHonorRows(toAdd / toUpdate / toDelete,删只在 complete=true 时做)company-info-sync.ts:拆成两个互不依赖的入口——applyCompanyClassification(1888 ← 分类接口)
与 applyCompanyHonors(1886 ← company_info.honors)。分类失败不再牵连荣誉同步c_tag_name
(字段映射漏了荣誉名那一列,只写了 title)——灌入的行本来就只有 title,不补就永远缺这一列跑过的验证:纯逻辑 65/65(新增荣誉解析/键/增删改对齐/complete=false 不删/名字回退等断言); 真实 DMS 23/23(新脚本覆盖:一条荣誉一行、幂等、改字段、删多余、complete=false 不删、 迁移行也参与对齐);e2e 20/20(真实载荷 → 真实分类 → 真实 DMS,1886 = 31 行); 回归全绿(33/11/24/8/10/21/30/10/12 + DMS 43/43)。
顺带修好的真实数据:宇树科技(91330108MA27YJ5H56)的 1886 已从「28 灌入 + 2 标签」
对齐为与后端一致的 31 条(灌入行补上了 c_tag_name,缺的 4 条由 user_gtx 写入)。
c_honor 存荣誉名 → 名字列改用新字段用户指示:「现在没有存具体的荣誉名称,我新建了 c_honor 字段,在这个字段里存荣誉名称」。
探测确认:1886 模型已扩到 13 字段(18:17 更新),新增 c_honor(别名「荣誉名称」,text),
同时 c_tag_name 的别名被改成「荣誉标签」——两个字段的语义分开了。
改动:
c_honor + 系统字段 title(title 供 DMS 列表显示,灌入行本来就有)c_tag_name 不再当名字用,只在读取名字时作为中间回退项
(回退链 c_honor → c_tag_name → title,字符串 "null" 仍不算名字)——
早期同步写进去的值还在,靠它仍能把已存在的行匹配上,且首次对齐会给这些行补上 c_honorHONOR_NAME_FIELD = "c_honor";HONOR_TAG_FIELD 保留并注明只作兼容跑过的验证:纯逻辑 66/66(新增「早期只有 c_tag_name 的行也会补上 c_honor」等断言);
真实 DMS 23/23;e2e 20/20;npm run build 通过;回归抽查(33/24/10/12)全绿。
真实数据:宇树科技的 31 行全部补上了 c_honor(复查「缺 c_honor 的行 = 0」,
键去重后 31 = 行数,无重复行)。
仍未做:浏览器端到端(用户实测后改了两次数据源/字段,尚未再走一遍浏览器)。
c_tag_name 不承担语义(用户 2026-09-18)用户问「c_tag_name 存分类了吗,或者说有分类吗」。全表核查(1886 共约 1986 行)结论:
c_tag_* 字段(宇树:c_tag_honor = ["中国独角兽企业","国家级高新技术企业"])c_tag_name 只有宇树 20 行有值,且值与 c_honor 完全相同
—— 是中间版本(荣誉名写在 c_tag_name 的那版)留下的残留;
author_name=ams_user 是建行的人、不是最后改的人(DMS 的 author 创建即定死)用户拍板:不用它、也不用清理(「不用吧」)。因此:
c_tag_name 既不再写、也不清理;读取荣誉名时仍保留它作为中间回退项
(c_honor → c_tag_name → title)——留着比删掉更稳,万一还有别的行是中间版本写的DMS_COLUMNS.md 的 1886 字段说明,免得下次有人再问「这列怎么没人写」feature_list.json 的 company-classify-dms-sync:in_progress → passingc_honor 全有值、无重复行)company-classify-dms-sync.md 从 active/ 归档到 completed/,
补上状态头(结果、验证、过程中两次修正的索引)active/ 里只剩 dms-api-replacement.md(企业信息 1888 那条仍在办)node harness/tools/validate-harness.mjs 通过(20 项功能,in_progress 0 项)completed/company-classify-dms-sync.md 的状态头;
feature_list.json 的 evidenceharness/feature_list.json、
harness/docs/exec-plans/{active→completed}/company-classify-dms-sync.md、本文件honors.complete=true 时库里多出来的行会被删;若某天后端数据退化,前端会跟着删。
要改成只增不删,改 diffHonorRows 的 allowDelete 一处即可company_info 里的字段是否全部存到 DMS 了?分别列出已存的匹配关系与还没存的」harness/tools/audit-company-info-coverage.mjs(可复用):
三边对齐——① 真实载荷里实际有哪些字段 ② 实时拉 DMS 模型字段(getModelById)
③ 代码实际写哪些列(脚本里显式列出,与 src 对齐)。改字段后重跑即可DMS_COLUMNS.md §2.6(含两个栏目的覆盖表)| | 已写 | 未写 |
|---|---|---|
| 1888(47 列) | 10 列:身份三字段 + c_created_at + 六个 c_tag_* | 33 个 profile.data 字段DMS 有现成列但没写(Status/Address/Scope/注册资本/成立日期/经营范围…);另有 4 列载荷里没有数据源(c_scope_brief/c_phone_number/c_email/c_industry)、2 个载荷字段 DMS 无列(KeyNo/TeamEnd)、7 个 profile.source.* 属元信息不写 |
| 1886(13 列) | 12 列(含 c_honor) | 1 列:c_tag_name(用户已确认不用、不清理)。荣誉明细侧无缺口:records[].data 的 8 个字段全部落库 |
⚠️ 关键区分:「没写」≠「库里没有」——1888 的工商字段是 DMS 侧 09-17 灌进去的
(作者 ams_user),前端同步只补身份与标签;只有「库里没有、由前端新建」的企业行
才会出现「只有 10 列有值」。
c_milvus_chunk_ids 的知识库模型)——
已改用 DMS_MODEL_*(2036/2032),并加了模型别名校验(对不上就抛错),防下次再错.env.development.local,该文件被 git 忽略)DMS_COLUMNS.md §2.6 与两处更正;
harness/tools/audit-company-info-coverage.mjsharness/tools/audit-company-info-coverage.mjs(新增)、
harness/docs/reference/DMS_COLUMNS.md、本文件diffEnterpriseRow 的字段级 diff 可直接复用c_phone_number/c_email/c_scope_brief/c_industry 载荷里没有数据源,
要存得先让后端补(前两个企查查 410 接口没返回)profile.data 的工商信息也写进 1888classification-sync-utils.ts 新增:PROFILE_FIELD_MAP(36 项映射)、buildProfileFields、
readProfileDataFromCompanyInfo、stableJson(递归排序键),并让 diffEnterpriseRow
对 JSON 列按语义比较company-info-sync.ts 新增入口 applyCompanyProfile(1888 ← company_info.profile.data):
不依赖分类接口,与荣誉一样在分类之前跑;三个入口共用同一串行链键,不会并发写同一行Object.values(PROFILE_FIELD_MAP)),避免清单与 src 漂移DMS_COLUMNS.md §2.6 的覆盖表重写(含 36 项映射对照表)null/空串/空数组/空对象)——不清存量:库里灌进来的值
不会被「本次没带」的字段抹掉c_area / c_original_name 等是 Python/jsonb 序列化的
(带空格、键按字母序),与 JS 的 JSON.stringify 输出字节不同 —— 直接比字符串会每轮都误判
成「有差异」而反复写。用 stableJson(递归排序键)比,语义相同就不动它company.KeyNo、profile.data.TeamEnd)DMS 没有列,忽略npm run build 通过DMS_MODEL_ENTERPRISE 在入口里重复导出了一次,
esbuild 直接报错 → 已修(这类批量改文件的操作,改完要立刻编译验证)DMS_COLUMNS.md §2.6;
audit-company-info-coverage.mjs 的 43/47 与 12/13src/network/api/dms/classification-sync-utils.ts、
src/network/api/dms/company-info-sync.ts、harness/tools/{_entry-company-classify.ts,
verify-company-classify-sync.mjs, verify-company-classify-dms.mjs, verify-company-classify-e2e.mjs,
audit-company-info-coverage.mjs}、harness/docs/reference/DMS_COLUMNS.md、
harness/feature_list.json、本文件applyCompanyProfile 从
runCompanyInfoSync 里摘掉(一行)c_scope(经营范围)单值可达 1000+ 字符,DMS text 列可存(1889 实测 1105 字)✓用户回复「无误」——浏览器端到端通过,含本轮新增的工商信息同步。
company-classify-dms-sync)的两次能力扩充都已人工验证:
①2026-09-18 分类标签 + 荣誉明细 ②2026-09-20 工商信息 36 字段c_scope_brief/c_phone_number/c_email/c_industry)
要补得先让后端在 410 接口返回它们.record-meta-info).record-meta-info 元素只有 520px 宽,而面板容器与下方两张卡片
(.detail-body-grid)都是整行宽 → 该行右侧空出一大块.meta-name { max-width: 462px }、
部门列 .meta-no { width: 110px };且后者是写死的窄列,导致部门写全称时
(recordMode 的「政策所属部门」,如「青浦区人民政府办公室 区人社局 区委人才工作
领导小组办公室会同区人力资源和社会保障局」)被挤成三行PolicyMatch.vue 的三条规则):
.record-meta-info 加 width: 100%(容器 .record-header-section 本来就是 100%).meta-name:max-width: 462px → flex: 1 1 auto; min-width: 0(占满剩余空间).meta-no:width: 110px → flex: 0 1 340px; min-width: 110px(可伸展、下限不变).record-meta-info 是 display: none,
走的是另一个元素 .mobile-record-meta-infonpm run build 通过;改动后的三条规则逐条读回源码核对README.md 的 20260920 节src/components/Chat/PolicyMatch.vue、根 README.md、本文件title —— 库里灌入的行本来就有
(09-17 那批),但前端新建的行会因为没传而被 DMS 落成字符串 "null",
在 DMS 列表里看不出是哪家企业。这与 1886 当初踩的是同一个坑
(title 只在创建那一刻初始化,之后改 c_* 字段不带动它)classification-sync-utils.ts 新增纯函数 withSystemTitle(row):取 c_name 作为 title;
值相同则原样返回(不产生无谓写入)、c_name 为空则不动 title(不清存量)company-info-sync.ts 的 upsertEnterpriseRow 一处调用它 —— 1888 的两条入口
(工商信息 / 分类标签)共用这一处,所以两条路径都覆盖diffEnterpriseRow 的普通文本比较,更新路径也会给历史遗留的
title="null" 补上企业名称npm run build 通过"null" 被修正 / c_name 空不动 / diff 能捕获)"null" 后
再同步 → 被修正)DMS_COLUMNS.md §2.4 的 title 说明;feature_list.json 的对应条目src/network/api/dms/{classification-sync-utils,company-info-sync}.ts、
harness/tools/{_entry-company-classify.ts, verify-company-classify-sync.mjs,
verify-company-classify-dms.mjs, verify-company-classify-e2e.mjs}、
harness/docs/reference/DMS_COLUMNS.md、harness/feature_list.json、本文件"null" 的行 = 0 —— 灌入行本来就有标题,前端此前也只建过测试行(已清理)。
也就是说这个坑只在「前端新建企业行」时才会触发,本轮补上后不会再产生用户要一份「company_info 字段 ↔ DMS 企业两栏目字段」的对应关系 md 文档。
已放 harness/docs/reference/company-info-dms-mapping.md,并登记进
reference/README.md 的索引表与 tools/README.md 的脚本清单。
关键做法:文档由脚本生成,不手写 —— harness/tools/gen-company-info-mapping-doc.mjs:
读代码里的映射表(PROFILE_FIELD_MAP / ANGLE_TO_FIELD / HONOR_FIELD_MAP)
文档含:总览(哪段落到哪个栏目/粒度/匹配键)、1888 的 36 项工商映射 + 六角度标签 + 系统字段 title、 1886 的荣誉字段映射与「全量对齐」语义、不落库的字段及原因、三条影响对应关系的写入规则、 当前覆盖率(1888 写 43/47、1886 写 12/13)与复核命令。
生成时踩了一处并修掉:荣誉字段显示的是内部归一化名(level)而非载荷原始键(Level)——
文档是给人对着载荷看的,已改为显示原始键(RAW_HONOR_KEYS 映射)。
result.data.cipa(布尔)——问 CIPA / 海外服务平台 / 出海服务时为 true,
前端在 true 时展示 src/assets/qiyeweixin.png(企业微信二维码)ChatResultPayload 加 cipa;case 'result' 严格判定 === true 存入 pendingCipa;
resetTurnContent() 每轮清空;flushContent() 在参考资料之后追加图片块buildCipaQrcodeContent()(与其它标记生成函数并列)<image-scope> 块 → ImageContent 行类型(点击可放大),
所以只按它的约定把图片地址放进去即可BusinessRecord 的复制剥离规则补了 <image-scope>,免得把图片地址复制进剪贴板npm run build 通过;图片确实进产物(dist/assets/qiyeweixin-Bgo9IiEe.png),
标记字符串在 bundle 里verify-company-info-passthrough.mjs 扩到 19/19,用假 SSE 流喂真实协调器):
true → 追加块且含非空地址、块在最末尾、正文不被污染;false/缺字段/"true" 字符串 → 都不加;
跨轮不串(第一轮 true、第二轮缺省 → 第二轮没有)cipa 字段。用两种问法
(「CIPA是什么?出海服务怎么申请」「我想了解青浦区CIPA海外服务平台,出海服务有哪些支持」)
各打一次真实 /api/chat,result.data 的键仍是
response / company_info / recommendation / errors;SSE 全文里的 "CIPA" 全是正文里的字样
(grep -i 把大写也算上了,一度造成干扰)。→ 判断是后端尚未部署到 192.168.2.23:8000。
前端对缺字段是安全的(不显示图片),后端部署后即生效,无需再改前端.png,node 侧 esbuild 打包必须带
--loader:.png=dataurl,否则直接报错(已写进 tools/README.md 与 _entry-coordinator.ts 头部);
另外我用脚本插 helper 时把 join("\n\n") 写成了跨行字符串(语法错误),已修reference/api-chat.md 新增 cipa 字段说明(含「当前未下发」的实测);
feature_list.json 的 cipa-qrcodesrc/components/api-chat-coordinator.ts、src/components/Chat/BusinessRecord.vue、
harness/tools/{_entry-coordinator.ts, verify-company-info-passthrough.mjs, README.md}、
harness/docs/reference/api-chat.md、harness/feature_list.json、根 README.md、本文件cipa-qrcode 保持 in_progress:自动化验证齐备,但真实链路没被走过
(后端未部署该字段)→ 部署后在浏览器确认一次再转 passingsrc/assets/(随构建产物发布);若将来要换图,替换文件即可(文件名不用改)cipa → 浏览器实测 → 转 passing用户要求「二维码放在参考资料之前」(原先放在整条回答最末尾)。
flushContent() 里把二维码块的 push 移到 refLinks 之前(顺序变为 正文 → 卡片/综合说明 → 二维码 → 参考资料)二维码下标 < 参考资料下标、二维码在正文之后、参考资料仍在最末尾)——
适配层脚本 19 → 22/22 通过api-chat.md 的 cipa 表、feature_list.json、根 README.md 的 20260920 节用户发了浏览器实测截图:公司类问题触发 company_need 补问时,二维码被贴在
「输入框 + 跳过公司查询 + 提交」下面 —— 那一轮界面还没给出正文回答,贴二维码既突兀也没意义。
两个信息:
cipa(截图里二维码确实渲染出来了)——此前记录的「尚未下发」已过期,已改cipa === true,没看「本轮是不是补问轮」修法:追加条件 && !this.pendingInterrupt —— 有 interrupt 就视为「还没给出正文回答」,不展示。
断言:新增 3 项(company_need 与 company_selection 两类 interrupt 带 cipa=true 都不出图,
且选项卡本身仍正常出现)——适配层脚本 22 → 25/25 通过。
顺带观察:探测真实后端时又遇到一次 HTTP 500(0 字节)(当日第二次,先前分类接口也 500 过一次)——
前端按流中断处理、不重试;已记入 api-chat.md 的 cipa 节。
文档同步:api-chat.md 的 cipa 表补了「true + 本轮有 interrupt → 不展示」一行、
feature_list.json 与根 README.md。
修复补问轮误出图后,用户刷新实测确认:正文回答轮出图(在参考资料之前)、补问轮不出图。
cipa-qrcode:in_progress → passing;verification 追加人工实测条目,evidence 记录
「先据截图发现补问轮误出图 → 修复 → 再确认」的完整经过harness/docs/reference/token-streaming.md(F073 正文真实增量流式)
出方案 → 用户确认可行 → 实现docs/exec-plans/active/answer-streaming.md
(已落库;两个关键设计决策写在里面)summary;一轮 delta 235~746 片、平均 1.7 字/片;
concat(deltas) === answer_end.text 成立;answer/summary/result.response 的文本关系见下answer.text 是正文开头的压缩版(134 字 vs 正文 2199 字,
截断在词中间:正文接着 ###、概述结尾是半个词「部分候」);
result.response 在有卡片轮 = 该概述、无卡片轮 = 完整正文src/components/answer-stream.ts(纯函数):事件解析 / 流状态机(状态+效果为数据)/
增量 markdown 规整器 / 应用层对齐函数;normalizeChatMarkdown 搬入(协调器 re-export)progress/heartbeat 流开始后停发 scope;flushContent 流式轮跳过正文、
totalResponse 仍拼最终全文(修了一个坑:原实现 parts 为空就 return,纯正文流式轮会丢正文);
discardActiveStream 挂 error / failTurn / EOF 三条路径contentStreaming 标记
(start 置位、只在 finishTask 清除)+ close 的 DMS 补写判据改 hasVisibleMessageContent
saveHistory 按 500ms 节流(流式期每帧全量序列化整个 chatHistory 太贵)shouldDriveTypewriter 加 && !record.contentStreaming(TextContent 零改动)npm run build 通过verify-answer-stream.mjs —— 78/78 通过:纯逻辑 51 项(500 轮种子化模糊验证规整器
不变式、逐字/整段等价、状态机的缺口/重复序号/过期流、应用层对齐)+ 协调器级假流 27 项
(正文只出现一份、流中 heartbeat 不发 scope、abort 先于 error、旧路径回归、双实例不串台)probe-answer-stream.mjs —— 打真实后端通过:505 片 delta / 876 字,
concat(deltas) === answer_end.text 为真、归整后以增量为前缀、正文只出现一次、
totalResponse 以最终正文开头且含卡片与参考资料\n 当"行尾"发出,而正确语义是"行与行之间的分隔符"——末行会多一个换行,
与 normalizeChatMarkdown 不一致。重写后不变式才成立\n 后不含换行」totalResponse 监听、且用 '\n\n' 拼 message 通道
(真实链路是 content += msg 无分隔符)→ 一度误判"totalResponse 丢了"applyAnswerStreamEnd/Abort 抽成纯函数并在纯逻辑层断言本文件 Session 068;docs/exec-plans/active/answer-streaming.md;
harness/tools/{verify-answer-stream.mjs,probe-answer-stream.mjs};feature_list.json 的 answer-streaming;
docs/reference/api-chat.md(新增 F073 事件行 + 「实测补充」小节);根 README.md
src/components/answer-stream.ts(新增)、src/components/api-chat-coordinator.ts、
src/components/business-assistant/{useBusinessAssistantChat.ts,shared.ts}、
src/components/Chat/BusinessRecord.vue、harness/tools/{_entry-coordinator.ts,verify-answer-stream.mjs,
probe-answer-stream.mjs,README.md}、harness/docs/{reference/api-chat.md,reference/README.md,
exec-plans/active/answer-streaming.md}、harness/feature_list.json、根 README.md、本文件
contentStreaming 会随消息持久化:流式中途关页可能残留 true,但 loadHistory 强制
history: true → canAnimateAiText 为假 → 无害marked.parse 整行重渲染:长回答(876 字 / 505 片)暂未见问题,若真机卡顿
可隔帧 flush 或限单帧字节summary.notices 实测会出现
「本轮检索或逐条分析未完整完成」这类提示,前端照常展示浏览器验收(上面 9 点,重点是 mock 按钮回归与 500+ 片的流畅度)→ 通过后把 answer-streaming 转 passing
用户反馈:「卡片应该在回答顶部最先渲染,现在是在正文后渲染,现在的不合适」。
根因:改造前的版式其实是「短开场(134 字)→ 卡片 → 长综合说明(2199 字)」; F073 把整篇综合说明当正文流式铺满后,卡片照旧排在正文之后 → 用户要先滚过整篇长文才看到卡片。 这是我实现流式时漏掉的一处版式回归(当时只盯着"正文不要出现两份")。
改法(协调器 + 应用层,渲染层零改动):
flushContent 把输出拆成 leading(政策卡片)/ trailing(notices、补问卡片、二维码、参考资料) 两块answerStreamCompose {streamId, leading, trailing} 让应用层
按 基线 + leading + 正文 + trailing 重写一次内容 → 卡片落在正文之上composeStreamedContent(顺带剥掉基线里的 <scope>,避免卡片闪现重播)验证:verify-answer-stream.mjs 78 → 87/87 通过(新增:三块组装顺序、剥 scope、
空块跳过、流式轮 totalResponse 里卡片在正文之前、compose 事件的 leading/trailing 内容、
旧路径顺序回归);npm run build 通过。
文档同步:api-chat.md 的 F073 实测补充加「版式」一句、exec-plans/active/answer-streaming.md
加「修正」小节、feature_list.json 的 answer-streaming 更新。
用户给出文案:「如果您还有其他疑问,欢迎随时向我咨询,也可以联系CIPA海外服务平台获取支持。」
buildCipaQrcodeContent() 改为上文下码:引导语(普通文本)+ 空行 + <image-scope> 图片块
—— 渲染层按空行切成「文本行 + 图片行」,无需新增组件CIPA_QRCODE_TIP(要改文案只动一处)api-chat.md 的 cipa 节、feature_list.json 的 cipa-qrcode用户报告:界面上直接显示出一大段参考资料 JSON({"title":"食品经营许可…","publisher":"",…})。
根因(两处叠加,都是我引入的):
BusinessRecord 的内容 watch 只有增量路径:diffText = val.slice(count.value)
—— 它假设 content 只会往后追加。而我在流式收尾用 ai.content = '' + 再赋值做整块重写
(卡片重排/answer_end 重建/撤草稿),切出来的正是新内容的中段 → 中段被当正文渲染。修法(渲染层补能力 + 调用方简化):
BusinessRecord 的内容 watch 增加非纯追加分支:!val.startsWith(originContent) →
resetDisplayContent() + xmlFilter.reset()(解析器也有状态:buffer/未闭合标签),再全量解析。
纯追加(流式 delta、answer_end 尾缀补齐)仍走增量路径,不重置、不闪。resetDisplayContent() 顺带清 scope 退场补偿态(scopeExitHeight/Width、recordHeightLock
与定时器)—— isScopeExitCompensating 为真时正文行是隐藏的,残留会让重写后的正文整段不显示
(这是同一类潜在 bug)顺带修好的:answer_end 的整块重建、撤草稿两条路径此前依赖同一个失效的清空动作
(草稿行不会被撤掉)——现在都走新的重置分支了。
验证:新增 1 项契约断言(compose 后的内容不是正文的纯追加 → 渲染层必须支持整块重写);
适配层 27/27、流式 88/88、聊天任务 33/33、判空口径 30/30 全绿;npm run build 通过。
⚠️ 渲染层行为只能在浏览器确认(harness 不跑 Vue 组件)——请刷新后再看一次。
用户症状:正文先流出一部分,随后突然消失、变成另一段内容;最终内容正常,但过程不合适。
已排除:真后端三份载荷实测 concat(deltas) === answer_end.text(草稿没被后端替换)、
归整后以增量拼接为前缀 ✓ —— 所以不是"后端改稿"这一条。
本轮做的加固(都是能证明的脆弱点):
stream.body,在 message
监听里按 stream.active 累计),三个改写路径(end 对齐 / 卡片重排 / 撤稿)一律做后缀匹配,
不匹配时原样不动或直接把最终正文接上(宁可多留也不切坏、绝不丢字)content='' 再赋值」改为单次整块赋值(此前那两步会被 Vue 的 pre-flush watcher
合并,等于没写;上一轮我报告"已改"其实脚本替换静默失败了,已纠正)[answer-stream] start/end/compose/abort(带长度、是否整块重建)与
[record] 内容被整块重写,已重置并全量重解析(带前后长度)——
这类"内容被换掉"的问题只能靠"哪条路径改写了内容、改了多少"来定位验证:verify-answer-stream.mjs 90/90 通过(新增兜底分支断言:对不上时不切、
abort 对不上时不动);npm run build 通过。
⚠️ 仍未定论:真实触发原因需要用户复现时看控制台日志(哪几条 [answer-stream] / [record]
出现、顺序与长度)。待用户提供后即可定位并改正(若确为 answer_abort(regenerating),
那是契约规定的"撤草稿等重生成",届时需要与用户确认观感怎么处理)。
用户报告:回答内容出现重复,并要求版式为 卡片 → 正文 → 参考资料。
用户提供:/api/chat 的原始 SSE 抓包(802 个事件,含 771 片 delta)。
定位(用抓包回放整条链路,不靠猜):
answer_end.text(1610 字)比 concat(deltas)(1309 字)多 301 字——
尾部是追问建议。这推翻了续 4 里「三份载荷实测 concat(deltas) === answer_end.text」的结论:
那个等式不是后端保证的,第四份载荷就不成立(教训:拿样本归纳出的等式要写明样本量)stream.body 停在 1309 字 → 到 answerStreamCompose 重排时,上屏内容
(… + 1610 字全文)不再以它为后缀 → 落兜底分支(matched=false)→
基线取整段上屏内容、正文再插一份 → 正文上屏两份,卡片被挤到两份正文中间verify-answer-stream-payload.mjs 加断言前先跑):
正文中段片段出现 2 次、卡片@1617 正文@0、compose(matched=false)[record] 走全量重置路径修法(纯逻辑层 + 应用层各一处,渲染层零改动):
answer-stream.ts 新增 resolveStreamedBodyAfterEnd():answer_end 之后把
「应用侧记录的流式正文」同步成最终全文。三种收尾(尾缀追加/整块重建/对不上兜底)
的结果都以 finalText 结尾,所以无条件取它;为空才保留原值(防清空丢正文)。
hook 的 answerStreamEnd 监听里调一次 → 重排回到正常路径composeAroundStreamBody() 抽成纯函数并加固兜底(原来这段逻辑写在 hook 里、没法单测):
后缀匹配失败时不再把正文再插一份——正文多半已在上屏内容里(answer_end 追加尾巴后就长这样),
改为以它的最后一次出现为锚点切开重排(锚点之前当基线、锚点之后夹在正文与参考资料之间);
确实找不到才补插(不丢字优先)。调用方按 matched 告警验证(证据见 feature_list.json 的 answer-streaming):
matched=false)→
修后 13/13 通过,上屏结构 @5 政策卡片 → @4687 正文(1610字) → @6299 参考资料verify-answer-stream.mjs:103/103 通过(新增【3c】纯函数断言 + 【12】协调器假流 + 应用层组装,
用合成载荷钉住这条回归;已实测把修复「阉掉」时【12】会失败,是真守卫)harness/tools/verify-answer-stream-payload.mjs + 合成夹具
harness/tools/fixtures/answer-stream-tail.payload.md:回放真抓包用(真抓包 600KB 不入库,
用户已确认删除;合成夹具同形态、小到可入库,让回归随时可回放)npm run build 通过;node harness/tools/validate-harness.mjs 通过(exit 0)⚠️ 待浏览器确认:修后一轮完整问答应为「卡片 → 正文 → 参考资料」,正文只出现一次。
若仍看到重复,请把控制台的 [answer-stream] compose 一行发来(matched 是否为 true、三个长度)。
用户症状:正文先开始流式渲染,过了一会儿从头重渲一遍,这次包含 「卡片 → 正文 → 参考资料」。第二次是对的,但第一次根本不该存在。
根因(不是猜,契约里写着):docs/reference/token-streaming.md 的
「卡片先于正文(F079,2026-09-20)」一节(本轮才发现它已更新)明确要求:
有入选政策时,后端先把完整 source / item 发完才开始 answer_start / answer_delta;
前端收到 item 即可展示卡片,不要等 summary / result。
而我这边的实现一直在 done 时才发卡片(flushContent),与已经流出来的正文顺序冲突,
只能靠 answerStreamCompose 整块重写把卡片插到正文之上 —— 渲染层遇到非纯追加会
resetDisplayContent() 全量重解析,用户看到的就是「渲一遍 → 从头再渲一遍」。
修法(协调器 + 两个纯函数,渲染层零改动):
emitCardsBeforeStream():在 answer_start 时先把卡片块发上屏(此刻卡片集合完整,
因为契约保证 item 已发完)。卡片因此排在正文增量之前,收尾不再需要重排。
时机取 answer_start 而不是「收到 item 就发」:无法知道哪条 item 是最后一条,
提前发会有「先发一半、后面再补」的替换问题;而契约保证的距离只有首个 token 的延迟(约 0.6s)。flushContent 拆两种情况:卡片没提前上屏 → 仍走 answerStreamCompose 整块重排(兜底);
卡片已上屏 → 尾部(参考资料等)纯追加,不发 compose → 界面不重写、不闪。
totalResponse 仍按上屏顺序拼(开流前已发的卡片 + 其余 leading + 正文 + 尾部)。streaming:撤稿/断流后 answerStream 会被重置,
streaming 变回 false,那时卡片其实已上屏 → 会被当新内容再发一份(界面上两份卡片)。
断流路径会走 flushContent,这条能被测到(【17】实测:加回 streaming 判据即失败)。answerStreamCompose 增加 replacedLeading,应用层重排前先把旧块抹掉。运行过的验证:
verify-answer-stream.mjs 131/131 通过。新增 6 节:
【13】F079 顺序(item 先于 answer_start)下卡片排在正文之前、不发 compose;
【14】item 晚于 answer_start 时仍走 compose 兜底;
【15】应用层最终版式(卡片/正文/参考资料各出现一次、无重排);
【16】开流后卡片集合变大 → 无新旧两份;【17】断流撤稿卡片保留;【18】error 撤稿卡片保留。
都实测过「把修复阉掉即失败」(【13】6 项失败、【15】无 compose 断言失败、【17】卡片变 2 份)。verify-answer-stream-payload.mjs 全绿:合成夹具已改为 F079 顺序(source/item 在 answer_start 前),
上屏结构 @0 <scope> → @53 卡片 → @440 正文 → @564 参考资料,没有 answerStreamCompose。npm run build 通过;validate-harness exit 0。StreamXMLFilter 探了一次「scope 与卡片同批送达」是否会被当纯文本渲染:
不会(processRecordSections 会在 <!-- POLICY_TABLE 处切开,两批/同批结果一致),
所以开流前同步发卡片不需要额外让帧。⚠️ 待浏览器确认:带卡片的问答应当是「思考中 → 卡片 → 正文逐字流出 → 参考资料」,
中间不再有整块重渲。若仍看到重渲,请把控制台 [answer-stream] compose 一行发来
(有这行 = 走了兜底重排,说明卡片没能提前上屏)。
用户拍板的两个方案:A 免重启 + 独立 token 文件(本轮做完);C 脚本自动登录(待拿到登录接口)。
现状查明(顺带发现一个真问题):token 是外部 OAuth 签发的 JWT,约 24 小时过期
(当前这个 exp = 2026-09-21 21:40)。而 .env.development.local 里当时是:
DMS_TOKEN=
eyJ0eXAi... ← token 在**下一行**
loadEnv 读出来是空串(实测 DMS_TOKEN 长度 = 0)—— 也就是说这个文件里的 token
从来没生效过;用户可能一直靠 dev server 启动时读到的旧值在跑,
一旦重启就会静默降级。这解释了「为什么总觉得 token 填了没用」。
已完成(方案 A):
harness/tools/dms-token.mjs —— 唯一的 token 读取口径:
显式参数 → DMS_TOKEN 环境变量 → 本机文件 .dms-token(JWT 状态描述也在这里)harness/tools/set-dms-token.mjs + npm run dms:token:
无参数时读剪贴板(浏览器复制完直接跑),--check 只看当前状态,非 JWT 一律拒绝写入vite.config.ts 的 /dms-api/ 代理改为 configure + proxyReq 每请求现读 token
(原来是启动时读一次的静态 headers),并加了 [dms] 告警:没 token / 已过期 / 2 小时内将过期
都会在 dev server 控制台明确说出来(以前是静默失败).dms-token 加入 .gitignore;.env.development.local 里那两行坏掉的 DMS_TOKEN= 已清理,
换成指向新机制的注释dms-step0-verify / probe-dms-title / dms-delete-probe)
不再强制手填 token,也走同一口径运行过的验证:
.dms-token → 经 dev server 代理打 DMS 返回
212 无效token;换回真 token(全程没重启 dev server)→ 200 成功 ✅npm run dms:token → 文件内容确为剪贴板那个 ✅.dms-token 另起一个 dev server(8099,测完已关)→ 控制台出现
「没有 DMS token,DMS 读写会失败…跑 npm run dms:token」且只打一次 ✅verify-dms-chat-storage.mjs 经代理跑真实 DMS 43/43 通过(清理后残留 0 行)✅npm run build 通过;validate-harness exit 0;流式 131/131(未受影响)待办(方案 C):需要登录接口才能脚本化取 token。已确认:
DMS 主机(121.43.55.7:10081)是纯 API、根路径 404,没有登录页;
两个仓库的文档里都没写登录端点(DMS 项目的 RUNBOOK 也标着「尚未实测」)。
需要用户提供登录那个网站的地址,或(最快)在登录页 F12 → Network → 复制登录请求为 cURL。
/oauth/user/login)接口来源:用户给出路径片段后,在隔壁项目 F:\yysk\AI_zhaoshang\zhaoshang_admin_ui-master 找到了
已经实测通过的实现(src/services/auth.ts + harness/product-specs/auth.md 的 auth-ams-001):
http://121.43.55.7:10086(DMS 是 10081,另一个端口——此前两个仓库的文档里都没有它)POST /oauth/api/user/login,form userName / password / clientId=0,无验证码{code:200, message:<token>, content:{id,userName,failureTime}}
—— ⚠️ token 在 message 里(不在 content),照抄实现时才注意到{"code":202,"message":"用户不存在"}已完成:
harness/tools/dms-login.mjs:账号密码登录 → 写 .dms-token(与方案 A 同一份文件)
DMS_USERNAME+DMS_PASSWORD / 本机文件 .dms-account(gitignore)--if-needed:当前 token 还剩 >20 分钟就跳过(npm run dev 的 predev 用它自动续期)npm run dev 拦下来code=202 用户不存在);返回的不是 JWT 就拒绝写入package.json 增 dms:login 与 predev;.gitignore 增 /.dms-account.dms-account 模板(gitignore,空的 username/password + 注释说明两种方式)运行过的验证(成功路径需要用户真实密码,其余都实测了):
| 验的是 | 结果 |
|---|---|
| --if-needed 且 token 还有效 | 打印「token 仍有效…跳过自动登录」,退出 0 ✅ |
| 未配账号 | 退出 1 并列出三种给法 ✅ |
| 账号错误 | 登录失败:code=202 message=用户不存在,退出 1 ✅ |
| 失败时不写坏文件 | cmp 确认 .dms-token 未被改动 ✅ |
| 从 .dms-account 读凭据 | 造假的账号文件 → 确实按它发起登录 ✅ |
| npm run predev | 走通、退出 0 ✅ |
待用户做:把账号密码填进 .dms-account(或直接告诉我不用管、继续用剪贴板方式)。
填完 npm run dms:login 一次即可;之后 npm run dev 会自动续。
⚠️ 成功登录那一步我没有实测(没有密码),第一次跑请看一眼输出。
用户报告:大面积重渲没了,但偶尔还会有一小段重新渲染。
已排除的(代码级论证,不是猜):
activeTypewriter=false → TextContent 走 renderInstantly(),
把 typedText 直接设成全文;finishTask 只把 contentStreaming 置回 false,
内容没变,而 TextContent 只 watch content/paused,不会因此重新打字。shouldRenderBusinessRecordBody 恒为 true,从不切换。changeFlag 只是 200ms 动画回调的守卫,不触发渲染。剩下能让「已经显示过的内容」重画的,只有三类(纯追加不会重渲任何已有内容):
[record] 内容被整块重写 日志isScopeExitCompensating 为真时所有正文行都不渲染
(shouldRenderTextContentRow),补偿结束再挂回来 —— 本轮补上日志(带「当时有几个正文行」)本轮改动(只加诊断,不改行为;均仅非生产环境):
debugRowMutation():行被删除 / 内容被回缩时打一行(带 rowId、类型、前后长度、内容开头)closeTextRow():把三处「关闭行时去尾空白」收敛成一个函数,只在真的变短时打点beginScopeExit() 里打一行「scope 退场补偿开始」,带 exitHeight 与当时的正文行数/卡片行数运行过的验证:npm run build 通过;verify-answer-stream 131/131;
verify-answer-stream-payload 全绿;validate-harness exit 0。
下一步:请用户复现一次「一小段重渲」,把控制台里下列关键字任一行发来 ——
[answer-stream](内容层改写)、[record](重置/行改动/补偿)、[TextContent][typewriter]。
哪一类出现,答案就在哪一层。
新工具 harness/tools/probe-answer-tail-timing.mjs —— 增量读 SSE(不是攒完再解析),
给每个事件打到达时刻,专测「正文定稿 → 参考资料出现」这段等待。
两次实测(真实后端,经 dev server 代理):
| 第一次 | 第二次 | |
|---|---|---|
最后一片 source(参考资料的数据) |
— | 49.09s |
answer_start(正文开流) |
— | 49.51s |
最后一片 answer_delta |
35.36s | 53.15s |
answer_end(正文定稿) |
35.85s | 53.50s |
summary |
35.85s(+0.00s) | 53.51s(+0.00s) |
result |
38.92s(+3.07s) | 63.60s(+10.09s) |
done |
38.92s(+0.00s) | 63.60s(+0.00s) |
result 载荷 |
390,162 字符 | 375,024 字符 |
结论(有测量支撑):
summary → result 这一段(3.07s / 10.09s),done 紧贴 result。source)在正文开流前 0.4 秒就到齐了(49.09s vs 49.51s)——
第二次实测里「数据到齐」到「界面显示」差了 14.5 秒。等的是事件,不是数据。done 才 flushContent,所以尾部(notices + 参考资料)被压到最后。result 里只消费两个字段(代码证据:api-chat-coordinator.ts 的 case 'result'):
company_info(走 totalResponse 给 DMS 同步,不在消息里渲染)与 cipa(企业微信二维码,
渲染时必须排在参考资料之前)—— 除了 cipa,尾部其余部分在 summary 到达时就已就绪。用户选的两个方案:① 前端先做(尾部提前上屏);② 把 cipa 提前的需求提给后端。
用户的硬约束:CIPA 二维码的位置不能改(必须仍在参考资料之前)。
实测依据(probe-answer-tail-timing.mjs,三次真实后端):
answer_end(正文定稿)→ result 的间隔是 0.31s / 3.07s / 10.09s,
而 result 是 117KB~390KB 的完整快照、done 紧贴它;参考资料的数据(source 事件)
在正文开流前 0.4~0.85 秒就发完了。等的是事件,不是数据。
实现(渲染层零改动):
notices → interrupt → cipa → refs),buildTrailingParts()flushTailEarly():正文定稿(answer_end)且 summary 已到时,把
notices + 参考资料立即上屏,不等 done。触发点挂在 answer_end 与 summary 两处(幂等)
summary:它带 notices,而 notices 排在参考资料之前;契约保证它在 answer_end 之后必到(实测 +0.00s)answer 概述要保留(这两种 done 时要整块重排,提前发反而要抹回来)result.cipa,只能等 done;那时若需要,走整块重排把它插到参考资料之前 ——
answerStreamCompose 增加 replacedTrailing,应用层重排前先把已上屏的旧尾部抹掉(与既有的 replacedLeading 对称)totalResponse 仍按最终顺序拼(与发放时机无关),落库/DMS 内容不变运行过的验证:
verify-answer-stream.mjs 150/150。新增 4 节:19 尾部提前(带 600ms 真实延迟)、
20 二维码插到参考资料之前(无新旧两份)、21 无 cipa 时零重排、22 非流式轮行为不变。flushTailEarly 立刻失败(参考@607ms = done@607ms,白等整整 600ms)npm run build 与 validate-harness 通过仍要给后端提的需求(方案 ②):把 cipa 放进 summary(或 answer_end)——
那样连 CIPA 类问题也能一次按正确顺序发完,不再需要那次整块重排。
用户要求(附截图):把输入框右侧那排测试按钮(资料 / 问卷 / 流测 / 回放 / 打字 / Scope / 测试输入)注释掉。
做法(按项目约定:原代码注释掉,不直接删):
src/components/BusinessAssistantMobileInputShell.vue 的 mock-stream-test-actions 整块用
<!-- --> 包住。这是 PC 与移动端共用的组件(BusinessAssistantComposer 传 variant),
一处即覆盖两端。注释里写清了怎么恢复。
保留未动:开关 showTestActions(= MODE === 'development')、7 个事件、
以及 PC/Mobile 里的处理函数(sendChatStreamReplayTestMessage / sendTypewriterTestMessage /
sendScopeMergeStreamTestMessage / sendDocumentLineStreamTestMessage /
sendRefLinksStreamTestMessage / sendQuestionCardTestMessage)—— 恢复时只需取消注释。
运行过的验证:
npm run build 通过流测 / 回放 / 测试输入 出现次数 0(对照:参考资料 2 次、
说出您的需求 1 次)—— 按钮确实不再进产物verify-answer-stream 150/150、validate-harness 通过顺带发现(未处理):
showMockStreamTestButton这个 prop 一路从 PC/Mobile 传到本组件, 但模板里从未使用 —— 是历史上「单个 mock 流按钮」留下的死链路,可择机清理。
需求拆解失败… 出现 3 次、参考资料 出现 2 次)先取证,不猜:从 DMS 1889 按问题搜到那一轮(我是上海信男教育科技有限公司,有什么鬼泣政策),
把 c_answer 落盘看清结构 —— 存储的内容是干净的(<ref_links> ×1、概述在正文前、
notices 带 - 前缀),所以重复发生在上屏那条链路,不在落库。
根因(用合成载荷在 verify-answer-stream 里 1:1 复现,确认后再改):
契约里 answer 事件排在 answer_end 之后(实测就是 answer_end → summary → answer → result)。
于是这一轮发生了:
summary 到达时概述还没来,flushTailEarly() 的「概述要保留就不抢跑」这道守卫看不到它
→ 尾部(notices + 参考资料)提前上屏了;answer 随后到达,且它不是正文的前缀(内容是「需求拆解失败,本次仅按原问题检索。」)
→ done 时 leadingText 非空 → 整块重排;replacedTrailing 只挂在 needsReorder(二维码要插队)上,这里是 false
→ 没抹掉已上屏的旧尾部 → 正文后面出现了两份「notices + 参考资料」。改了两处:
replacedTrailing: this.earlyTailText —— 只要提前发过尾部就一定要抹,不能挂在 needsReorder 上。
(漏掉这条就复现出用户看到的 2 份参考资料 / 3 次那句话)isOverviewAlreadyShown(overview, body, notices):后端的 answer.text 有时就是
summary.notices 里的一句(这一轮两边都是「需求拆解失败,本次仅按原问题检索。」)。
两者都展示 → 同一句出现两次,而且白搭一次整块重排(正文重渲)。
判据只有两条(都已展示、不丢内容):与某条 notice 归一化后相同 / 正文里已含这句。
去掉它之后这一轮不再触发重排,用户看到的「从中间某处开始重渲」也一并消失。运行过的验证:
verify-answer-stream.mjs 165/165。新增【23】(复现用户那一轮的事件序,断言参考资料×1、
那句话×1、零重排、顺序不变)、【24】(概述确实是另一段内容时仍保留)、【25】(去重判据 5 例)replacedTrailing → 【24】参考资料 ×2 失败;
阉掉去重 → 【23】那句话 ×2 且 compose 触发 失败npm run build 与 validate-harness 通过用户描述:① 卡片渲染很快,页面一下子滚动到卡片底部;② 文字快渲染完时,又瞬间返回回答顶部。
② 已定位并修复(代码链路三处,都能指认):
F073 之后正文是即时渲染的(流式期间打字机关着),但最后一条文本行的
textRowTypingFinished 一直停在 false(只有 TextContent 的打字机播完才会置 true,
流式期间根本不会播)。于是收尾时:
hasUnfinishedRevealRows 恒为真 → shouldDriveTypewriter 在 finishTask 那一刻翻真visibleInlineDisplayRecordRows 走「阻塞」分支:最后一条未完成文本行之后的行
(正文之后就是 notices / 参考资料)被从 DOM 里拿掉 → 容器变矮 → 滚动位置被钳制watch-initial → startTypewriter(0)
把内容重打一遍 → 上报「打字中」→ isLastAiRowTyping() 为真 →
stopAutoScrollAtLastAiRowTopIfNeeded() 触发 scrollToPositionInstant(行顶 - 8)
—— 这就是「瞬间返回回答顶部」(全仓库只有这一处会把视图滚到回答顶部)修法:新增 markStreamedRowsAsTypingFinished(),在 record.contentStreaming 由真变假时,
把已在屏幕上的文本行补记为「打字完成」(它们在流式期间本来就已完整显示)。
这样 hasUnfinishedRevealRows 为假 → 打字机不再被启用、后面的行不再被藏、也不会上报打字中。
运行过的验证:npm run build 通过;verify-answer-stream 165/165;validate-harness 通过。
⚠️ 观感只能由浏览器确认(harness 不渲染 Vue 组件)——请刷新后看收尾时是否还会跳。
① 待用户拍板:卡片的跳是自动滚动贴底造成的 ——
handleBusinessRecordRefreshScroll 在「高度增长 > 80px(BUSINESS_RECORD_LARGE_HEIGHT_GROWTH)
或本来就在贴底距离内」时会 scrollToBottom,卡片是一整块到达(300~900px),
于是一步滚到它的底部,用户看不到刚拿到的卡片。
改这个是换手感的决定(不是修 bug),方案见对话。
用户描述:卡片渲染很快,页面一下子滚动到卡片底部 —— 刚拿到卡片却看不到卡片。
机制:handleBusinessRecordRefreshScroll 在「高度增长 > 80px 或本来就在贴底距离内」时
scrollToBottom;卡片块是一次插入的整块(实测 300~900px),一步就滚到了它的底部。
用户选定方案 A:大块内容到达时把块自己的顶部对到视口顶部。 实现(PC 与 Mobile 各一处,口径一致):
BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240(超过就按块处理)、
BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8anchorLargeBlockTop(heightGrowth):块的起点 = 行顶 + (长高之前的行高),
用 scrollToPositionInstant 对顶;不调用 syncAutoScrollFollowState(),
于是自动贴底保持关闭、正文继续在下方增长,用户滚回底部附近时自动恢复跟随heightGrowth 未达阈值或拿不到元素时照旧走贴底逻辑取值依据:正文流式增长是逐片累积的(单次 ResizeObserver 回调几十 px), 240px 明显高于它、又低于卡片块,正好只拦「一整块」。
运行过的验证:
npm run build 通过;verify-answer-stream 165/165;validate-harness 通过npx vue-tsc --noEmit --types node,jest):本次改动0 个新增错误
(两文件的 3 条报错都在既有代码里)。顺带发现:vue-tsc 直接跑会因为
tsconfig.json 的 types 里写着 webpack-env 而 @types/webpack-env 没装直接失败,
且全项目有 65 条既有类型错误 —— 见「已知欠债」⚠️ 过程中踩的坑(记录):第一次用 node 脚本批量替换 Mobile 文件时,多行匹配因 CRLF 失败,
只有单行那处生效 → 结果 anchorLargeBlockTop 引用了未定义的常量,
而 npm run build 照样通过(Vite 不做类型检查)。改用 Edit 工具重做,并用
vue-tsc 复核。教训:多行替换必须核对生效处数,且构建通过 ≠ 代码正确。
用户描述:卡片下方先渲染出一大段正文,随后整段消失,变成另一段。
取证(不猜):
c_answer 搜到那一轮并落盘:存储内容 = 卡片 + 降级答复
(「已查到相关资料,但这次未能完成答复整理…」)+ notices + 参考资料,与你看到的一致;
你贴的「3. …?- 需求拆解失败…」看着粘连,DMS 里其实是正常 \n\n 分隔(复制时丢的);
第 2、3 条内容完全一样也是存储里就是这样(后端给的),不是重复渲染。answer_end.status = completed,但
concat(answer_delta) 不是 answer_end.text 的前缀 —— 公共前缀只有 175 / 1494 = 11.7%。
差异内容:最终版给标题补了序号(### 高新技术企业认定… → ### 一、…)、
把纯文本渠道换成 markdown 链接(- 国家高新技术企业认定管理工作网… →
- [查看办事指南](https://zwdt.sh.gov.cn/…))。answer_end 无论 status 是 completed 还是 fallback,均用 text 覆盖已有内容……
不能只停止光标而保留草稿」。结论:草稿被替换是后端设计 + 契约要求,不是渲染缺陷。 用户选择 A:接受现状,不改代码。
记录位置:harness/docs/exec-plans/tech-debt-tracker.md 新增「已知且已接受的行为」
一节(A1),写清了现象、契约依据、实测数据与「若要根治得后端改」,避免以后重复排查。
本轮无代码改动,只更新 harness 记录。
需求(用户原话拆解):
company_info 非空时,把对应的值写进 DMS 1887 助手会话栏目c_ent_image_url(企业图片);恢复会话时间;c_title 不写;
c_session_id 要写且与 1889 保持一致;「对话带出的公司代码」单开一列(需后端加)实现:
chat-sessions-dms.ts 重写 upsertDmsSession,规则写死在函数注释里:
c_ent_name / c_credit_code / c_is_login="1")→ 无条件写(覆盖)c_ent_name / 新列 / c_ent_image_url)→ 只填空(未登录不修改)c_credit_code:登录=企业代码,未登录=访客_<id>(未登录只填空,不顶掉已有)c_title 不写(标题由运营侧生成);DMS 系统字段 title 只在创建那一刻带会话标题
(不写会落成字符串 "null",1889 踩过)summary/qpyszx/qyzt/rzqpyx)只在创建时带空值:库里实测那些是
运营分析系统回填的 JSON,更新时绝不能碰totalResponse),那时才知道本轮有没有 company_info。
代码里另两处 [已停用] 的旧调用保留了注释并写明「别在这里补一行,否则同一次会话写两次」_entry-dms.ts 导出 enterpriseInfo(验证脚本要模拟登录/未登录)🚨 过程中撞到一个 DMS 协议坑(已写进 DMS_API.md 的告警框):
| 端点 | payload 含模型里不存在的列 |
|---|---|
| addContent | 忽略该列、其余照写 ✅ |
| updateContent | 整个 patch 静默丢弃,返回仍是 {"code":200,"message":"成功"} 🚨 |
实测:{c_ent_name:'A'} → 读回 A ✅;再 {c_ent_name:'B', c_不存在:'x'} → 返回 200 成功、
读回仍是 A ✗。后果:新列没加好就发它,会把「企业名称/归属/是否登录」一起搞失效且看不出来。
→ 加开关 DMS_SESSION_CHAT_CREDIT_CODE_ENABLED = false 挡住,DMS 侧加列后改 true 即可。
运行过的验证:verify-dms-chat-storage.mjs(打真实 DMS)55/55 通过,新增【8】节 11 项:
未登录落值 / 未登录第二轮不改 / 登录覆盖名称与归属 / 登录时对话值不顶掉登录值 / 退出后不被改回 /
c_title 从不写 / 系统 title 只在创建时初始化。
npm run build 与 validate-harness 通过。
给后端的需求:见 docs/exec-plans/tech-debt-tracker.md 条目 8 ——
在模型 2034(栏目 1887)加 c_chat_credit_code(varchar,别名建议「对话企业信用代码」)。
用户要求:
访客_xxx 之类的内容改存 c_ip;c_credit_code 只存真实信用代码company_info 值与原来的不一致也要更新(不再只是"填空")实现(chat-sessions-dms.ts 的 upsertDmsSession,规则写进函数注释):
resolveVisitorLabel() = 访客_<id> → 写进 c_ip(且只填空:已有值可能是后端回填的真实 IP)c_credit_code:登录 → 企业代码;未登录 → 对话带出的真实公司代码;
resolveDmsCreditCode()(仍返回 访客_<id>)保留给 1889 —— 1889 没有 c_ip 列c_ent_name/c_credit_code/c_ent_image_url):
与库里值不同就更新(isSameRowValue 判等);但登录态已写死的两个字段不许被顶掉c_is_login 仍只填空(空值形态 = 这一行还没被登录态认领过)title 必须有(sessionTitle || '未命名会话')——
这条是测试里发现的:参数改名后测试还在传旧名 title,导致系统 title 没写,
库里会落成字符串 "null"。已补兜底 ✓c_chat_credit_code(真实代码直接进 c_credit_code),
相应开关与入口导出已删除;tech-debt-tracker 条目 8 改写成"读侧暴露待拍板"运行过的验证:
verify-dms-chat-storage.mjs(真实 DMS)59/59:新增/改写 14 项覆盖新口径
(真实代码进 c_credit_code、访客标识进 c_ip、值不同就更新、值相同不写、
登录值不被对话值顶掉、退出后按新值更新…)verify-dms-payload-fields.mjs 17/17:断言新字段集合(含 c_ip/c_is_login、
不含 c_title、创建带系统 title)、更新不带标题与运营位、值没变就不发 update;
另加源码级断言「1887 只在 totalResponse 处写一次」npm run build 与 validate-harness 通过⚠️ 未处理(需用户拍板):访客行现在带真实公司代码,而 fetchDmsSessions 按
c_credit_code 查「我的会话」且只在登录后调用 → 那家公司登录后会看到访客关于它的会话
(改造前匹配不上)。挡住只需一行(只认 c_is_login="1")。已写进 tech-debt 条目 8。
⚠️ 待确认:1889 仍用 访客_<id> 当 c_credit_code(它没有 c_ip 列)。要不要统一口径,
得 DMS 侧先给 1889 加列。
用户决定:不该让企业看到陌生访客的会话 → 加护栏。并问: 1889 靠 sessionId 是不是还是能显示过往对话?有别的风险就告诉他。
① 护栏(已加,双向断言):
fetchDmsSessions 增加过滤:排除「明确是访客」的行(c_is_login === "0")。
只排除显式访客行(c_is_login 缺失的历史行照旧返回)→ 可见范围与改造前完全一致。
② 过程中发现并修掉一个更隐蔽的问题(差点自己造出串台):
按上一轮"值不同就更新"的规则,登录认领过的行(c_is_login === "1")在用户退登后
问到另一家公司时,c_credit_code 会被对话值改写,而 c_is_login 仍是 "1"
—— 这行既被改到别家公司名下、又会被读侧当"登录行"返回出去。
修法:chatOwnsCompany = !logged && !claimedByLogin ——
登录态认领过的行,公司字段只属于那家企业,对话值一律不碰(图片另有 chatOwnsImage 判定)。
③ 用户第二个问题的答案(已当面回复):是的,靠 sessionId 就能读回 ——
fetchDmsSessionRecords(sessionId) 与 deleteDmsSession(sessionId) 都只按
c_session_id(+ c_record_id)查,完全不看 c_credit_code;
所以 1889 的 c_credit_code 是纯归属/统计字段,不参与任何访问判定 → 不统一口径不影响功能。
但由此发现一个部署层面的暴露(与 1889 无关):
DMS 代理注入全权限 token 且不区分调用者 → 任何能打开站点的人用 devtools 就能读 DMS 全部数据
(1887/1888/1889,含别家企业工商信息)。生产按现方案接上就等于公开。
已记为 tech-debt-tracker 条目 8,并给出「必须选后端转发」的理由。
运行过的验证:
verify-dms-chat-storage.mjs(真实 DMS)65/65:新增「企业登录后看不到访客的会话」
「登录态造的会话能查到」「登录认领过的行不被对话值改写」「纯访客行值不同就更新」verify-dms-payload-fields.mjs 19/19:新增【2c】断言「登录认领过的行不写公司字段」npm run build 与 validate-harness 通过顺带修正:测试里两处假通过的断言(第一版查错了公司代码 → 断言恒真)已改为查 「该行实际带着的代码」,并注明原因,避免以后再犯。
用户要求:前端取本机 IP,IP 存 c_ip;游客用「访客」+ IP 拼接来判定。
接口:http://121.43.55.7:10011/proxy/verify/getIPByBrowser,直接调用即可。
实测接口:GET 返回纯文本 IP(116.232.248.128,虽然 Content-Type 标的是 json);
带 Access-Control-Allow-Origin: *(跨域没问题)。
⚠️ 但它是 HTTP、页面是 HTTPS → 混合内容会被浏览器拦掉,所以不能"直接调用",
走了同源代理(与聊天后端同一类问题)。
实现:
src/utils/client-ip.ts:ensureClientIp()(缓存 + 并发去重 + 3s 超时 +
失败返回空串不抛异常)、getCachedClientIp();runtime-config.ts 加 getClientIpApiBaseUrl()vite.config.ts 加 /ip-api/ 代理 → http://121.43.55.7:10011(去掉前缀);
.env.* 四份都加 VITE_IP_API(dev 是代理前缀,生产/青浦/test 是同源路径 + 注释说明需 nginx 转发);
index.html 注入 globalThis.VITE_IP_API;main.ts 启动预热一次(不等待)chat-sessions-dms.ts:
c_ip 存真实 IP(只填空;拿不到 IP 就不写 —— 不拿访客标签冒充 IP)resolveVisitorLabel() = 访客_<IP>,取不到 IP 退回 访客_<uuid>c_credit_code 用它(那个栏目没有 c_ip 列);upsertDmsSession /
upsertDmsRecord 进入时都 await ensureClientIp()运行过的验证:
curl -k https://localhost:8083/ip-api/proxy/verify/getIPByBrowser → 返回本机 IP ✓c_ip = 桩 IP、resolveDmsCreditCode() = 访客_<桩 IP>resolveVisitorLabel() 返回 访客_<uuid>,
不抛异常、不影响写入 ✓verify-dms-payload-fields 19/19(字段集合仍含 c_ip);回归全绿
(企业分类 84/84、适配层 27/27、答案流式 165/165、判空 30/30、聊天任务 33/33、政策卡 24/24);
npm run build 与 validate-harness 通过⚠️ 两点必须知道:
203.0.113.9(TEST-NET-3 段),真实服务另用 curl 验过 ✓。/ip-api/ → http://121.43.55.7:10011/(去掉前缀)。
不配也能跑:拿不到 IP 就退回随机 id,只是访客归属不稳定。用户反馈:复制出来的内容还带着 ###、**、[文字](链接) 这些 md 符号语法。
(用户先贴了那一段回答,我起初以为是"### 三、融资类产品 没有正文"的空标题问题,
挖到 DMS 存储内容核对后才澄清:用户指的是复制带符号。顺带确认那个空标题是后端给的:
存储内容与界面一致,### 三、… 后面直接就是 ### 四、…。)
实现:
src/components/Chat/markdown-plain-text.ts 的 markdownToPlainText():
走 marked 的 token 树(不是渲染成 HTML 再取 textContent —— 那样会丢掉链接地址),
去掉标题 #、**/*、`、[文字](链接) 的括号语法、无序列表 -、引用、分隔线、HTML 标签;
保留全部可见文字、有序列表序号(顺序是内容)、链接地址(写成 文字(地址))BusinessRecord 的 copyableContent:先剥协议标记(原有逻辑不变),再走 markdownToPlainText()运行过的验证:
harness/tools/verify-markdown-plain-text.mjs + _entry-plain-text.ts,19/19 通过:
① 用户那段真实结构(标题/加粗/无序/有序/链接)逐项断言,含「不含任何 md 符号」的总断言;
② 边界 9 例(空串/纯文本/行内代码/斜体/引用/分隔线/data URL 图片/链接文字=地址/多余空行);
③ 拿真实回答跑(那一轮的 c_answer 落盘到 _real-answer.txt,已 gitignore)——
无残留符号、文字没被吃掉npm run build 通过复制出来的实际样子(节选真实回答):标题只剩「一、市级通用查询与申兑」,
列表各项单独成行、有序列表保留 1. 2.,链接变成 查看产品详情(https://…)。
用户要求:「针对多模态输入,增加两个字段:pictures 以及 files,两个参数的内容都是数组,
支持多个图片和文件」。这正是技术债里「文件上传待定」那条(此前请求体只收 {thread_id, question},
多一个字段 422,附件只能把 OSS 地址拼进 question)。
先实测契约(不猜),打真实后端四组对照:
| 请求体 | 结果 |
|---|---|
| 只有 thread_id+question | 200 |
| pictures: [] / files: [] | 200 接受 |
| pictures: ['url'] / files: ['url'](字符串数组) | 200 接受 |
| [{url, name}](对象数组) | 422 invalid_request |
再验「是不是真的生效」:同一句话,带 pictures:[CIPA 二维码图] 时模型描述了图的内容
("企业微信添加页…二维码下方标注'上海CIPA海外服务平台'"),不带则答不出 —— 说明后端自己去取图
喂给视觉模型,所以 URL 必须公网可达 ✓
实现:
shared.ts 新增 toChatAttachmentUrls(transmission):按 attachments[].isImage 拆成
pictures / files,过滤空 URL;只有 files(没 attachments)时全落 files(地址不丢)buildQuestionWithAttachments(方案②)按约定注释保留 ✓generateAnswer(text, attachments?) + 私有 buildRequestBody() ——
空列表不发字段(没有附件时请求体与从前逐字节一致 ✓,后端若回滚只影响带附件的轮次)sendMessage 改传 attachmentsForBackend,question 就是用户原文 ✓运行过的验证:
verify-attachment-question.mjs → 19/19:拆分纯逻辑 7 项、
「没附件时只有两个字段」3 项、「带附件时」7 项(含 question 是原文、元素是字符串、
多图多文件、空 URL 被清)、「旧函数无活跃调用点」源码级断言 1 项npm run build 通过;vue-tsc 对本次改动文件 0 个新增类型错误validate-harness 通过文档:reference/api-chat.md 的请求体表补上两个字段 + 新增「多模态字段的实测契约」一节;
tech-debt-tracker 条目 3(文件上传待定)移到已还;README 加 20260923 节。
注:上一轮「删掉 CIPA 图片 + 正文图片链接渲染」的活儿被这条需求打断,尚未做。
用户指示(2026-09-23):上一条需求(删掉 CIPA 图片及相关功能、把正文里的图片链接渲染出来) 暂时放着,先不做。
处理:不留在日志里就算了 —— 已登记进 harness/feature_list.json(id chat-body-images,
状态 not_started),并把调查结论一并写进该条目的 notes,接手的人不必重查:
<image-scope>
<URL>
</image-scope>(渲染层已支持),
新形态是裸 URL 写在句子里(当前会被 marked 渲染成普通链接,所以看不到图)result.cipa=true → buildCipaQrcodeContent()(文案 + image-scope 包
企业微信二维码),涉及协调器的 pendingCipa / CIPA_QRCODE_TIP / buildCipaQrcodeContent 与
verify-answer-stream.mjs 的【20】text-content-utils.ts 的 normalizeBareLinks()(已在包裸链接)里按扩展名判图,
包成 ,再给 TextContent 补 img 样式(现在没有任何 img 样式)本轮无代码改动,只登记;validate-harness 通过(功能 23 项,in_progress 1 项)。
用户要求:「图片和文件调用接口存储,路径是 http://121.43.55.7:10011,具体参照图片, 然后文件存放的相对路径放到对应的URL里。」
先探清接口(不猜)——过程中有几处与用户描述不同,都实测纠正了:
10081(/dms/file/uploadFile),10011 上同一路径是 404multipart/form-data,五个字段全必填:file / type / columnId / contentId / paraName
——少任何一个都返回 {"code":-1,"message":"缺少必传字段"}(我第一次只传 file 就卡在这里)type 决定存储目录:0 图 → /static/photo/…;1音/2视/3 文件 → /static/file/…{"code":200,"content":"/static/photo/1889-c_question-<uuid>.png"} —— 相对路径/dms + 相对路径 = 200 ✓
(http://121.43.55.7:10081/dms/static/photo/….png)—— 而 /dms-api 代理补的正是 /dms ✓columnId=1889、paraName=c_question、contentId=0实现:
src/network/api/dms/file-upload.ts:uploadDmsAttachment(file,{isImage,signal})
(五个字段全发、type 按是否图片取 0/3、失败抛错并带后端说法)
buildDmsFileDisplayUrl(relativePath)(拼代理前缀得到可显示地址)useBusinessAssistantUpload.ts:上传改调 DMS;两个字段分工——
fileUrl = 相对路径(发给后端)· url = 拼了前缀的显示地址(预览用,刷新后仍有效);
厂商 OSS 的签名与直传整段块注释停用(含 VITE_UPLOAD_SIGN_URL)pictures / files 对接:toChatAttachmentUrls() 取的就是 fileUrl ✓运行过的验证:新增 verify-dms-file-upload.mjs 21/21 ——
① 请求形态(五个字段齐全、type=0/3、file 正确)② 失败要抛错(code≠200 / HTTP 5xx)
③ 真上传一次(走代理打真实服务:图片落 /static/photo/、文件落 /static/file/)
④ 展示地址端到端:相对路径直接取 404、拼前缀后 200 ✓
回归:附件 19/19、复制纯文本 19/19、答案流式 165/165、DMS 65/65、载荷字段 19/19、
企业分类 84/84、判空 30/30、聊天任务 33/33、政策卡 24/24;build 与 validate-harness 通过。
⚠️ 过程中踩的坑(记录):批量注释时把钩子函数签名一起吃进注释了(接口定义在钩子之前),
npm run build 直接报 Unexpected "}"。是分块注释时按行号切的锅 —— 教训:块注释要按语义单元切,
并用类型检查复核(vue-tsc 报的行号直接指向了问题)。
用户要求(附产品提示截图):「这是支持的格式,第一行是文件(Excel/DOCX/文字PDF), 第二行是图片(JPEG、PNG、GIF、WebP),不支持的格式要 toast 提示且不要放入输入框」。
先查现状,发现三处对不上:
jpg/jpeg/png/webp —— 漏了 GIF(提示里写了支持)pdf/doc/docx/txt/md —— 缺 Excel,还多了提示里没有的 doc/txt/mdwindow.alert(阻塞式弹窗),不是 toast实现:
shared.ts:白名单改成 ['jpg','jpeg','png','gif','webp'] / ['xls','xlsx','docx','pdf'],
并加 SUPPORTED_IMAGE_LABEL / SUPPORTED_ATTACHMENT_LABEL 常量(提示文案与白名单同一口径),
注释里写明改白名单要同时改三处(白名单 / <input accept> / 提示文案)<input accept> 同步(图片加 image/gif、文件改 .xls,.xlsx,.docx,.pdf)notify 回调(用界面 toast,不传时退回 console.warn),
两处 window.alert 改为 notify(...),文案带上文件名与格式要求useBusinessAssistantChat(提供 showToast)在上传模块之后创建,
且它依赖上传模块的 consumeSuccessfulFileTransmission,没法调换 →
用可变回调 notifyUpload 先占位、拿到 showToast 后接通;顺带把 showToast 从 chat hook 暴露出来handleFiles 统一校验,逐个 toast🐛 顺带修掉一个真 bug(测试抓到的):isSupportedImageFile 里写的是
file.type.startsWith('image/') || 扩展名在白名单里 —— 等于任何 image/* 都放行:
SVG / BMP / TIFF / HEIC 全被当图片收下,与白名单和提示都矛盾。
改为按扩展名判(没有扩展名时才退回看 MIME,仍需落在白名单)。
运行过的验证:verify-attachment-question.mjs 扩到 39/39:
accept 与白名单一致(三处口径不一致正是这次问题的成因)⚠️ 未处理(等你决定):语音输入那几处错误提示仍是 window.alert(PC 1 处 + Mobile 3 处),
属于同一类观感问题,但不在这次要求范围内,没有擅自改动。
用户要求(附设计稿截图):「现在的 toast 太丑了,我希望是图片中的类型,
从顶部弹出,过一段时间消失」——截图是 AntD message.error 那种粉底红感叹号小条。
现状:项目自绘的 <Toast> 是全屏黑遮罩(rgba(0,0,0,.4))+ 居中白框 ——
本质是模态框观感,还挡住整页、必须等它消失或点一下 ✗(用户说的"丑"就是这个)。
实现:
src/utils/app-message.ts:showAppMessage(text, type='error')(顶部弹出、3 秒自动消失)
clearAppMessages()。用 Ant Design Vue 的 message(本项目已依赖 4.x,CSS-in-JS 自带样式)
—— 就是设计稿那种小条,不用自己实现useBusinessAssistantChat 的 showToast 改为调它(调用方签名不变);
toastMessage 状态与壳组件的 <Toast :message> 一并停用(按约定注释保留)BusinessRecord.vue 里另有一份 <Toast> + showToast + provide("toastControl")
—— 查证:没有任何调用点,也没有任何子组件 inject(全仓库只有那一行 provide)→ 死代码,一并停用运行过的验证:
npm run build 通过;vue-tsc 相关文件无新增类型错误message 的 error/warning/success/info + destroy 都是函数 ✓、
时长为 3 ✓、空文案不抛且不弹 ✓、非空文案确实走到 antd ✓
(node 无 DOM 会失败 → 已加 try/catch + .catch,不会变成 unhandled rejection ✓)validate-harness 通过⚠️ 外观只能由用户在浏览器确认(harness 不渲染 UI):顶部居中、3 秒消失、错误为粉底红图标。
→ ✅ 用户 2026-09-23 确认无误(顶部弹出、自动消失的小条就是要的样式;
时长此后由用户调成 2 秒,见续 27 末尾「一并提交」那条)。
注:语音那几处(续 27)走的是同一条通道、外观相同,但那条路径没单独触发过
(要看到它得拒绝麦克风权限),所以严格说只确认了「通道的外观」,不是每一条文案都看过。
⚠️ 旧的 Toast.vue 组件本身保留未删(已无人使用),按约定留作参考。
⚠️ 仍未动:语音输入那几处 window.alert(PC 1 处 + Mobile 3 处)。
用户要求:「另外那几处语音的 window.alert 一同更换」(衔接续 26 —— 上一轮把全局提示换成 AntD message 时,语音这几处按「不在要求范围内」留着没动,并写进了待办)。
改了什么(4 处,全是 window.alert(...) → showToast(...)):
BusinessAssistantPC.vue 1 处:toggleVoiceInput 的 catch(error.message 兜底「语音输入初始化失败」)BusinessAssistantMobile.vue 3 处:麦克风权限失败 ×2(openMicroPhone 抛错路径 + 返回 false 路径)、
初始化失败 ×1(「语音输入初始化失败,请稍后重试」)releaseVoiceInputInstance() / 复位 isVoiceInputMode);
没有新增 UI,只是换了提示通道 —— showToast 两个壳组件本来就已解构(续 25 接通上传提示时暴露的)运行过的验证:新增 harness/tools/verify-toast-channel.mjs —— 27/27:
alert((注释里提「不要用 window.alert」不算:先剥注释再查)toggleVoiceInput 函数体内:PC 1 处、Mobile 3 处 showToast(数量卡死,漏一处就红)、
函数体内 0 处 alert、麦克风失败后仍释放实例showToast 确实由 useBusinessAssistantChat 解构且在 toggleVoiceInput 之前
(否则运行时会 ReferenceError);hook 的 showToast → showAppMessage;app-message 仍是 antd 的
message、四类型齐、时长 ≤5s、空文案早返回、Promise/异常都被吞window.confirm 保留(模态确认 ≠ 轻提示)openMicroPhone 返回 false 时仍是静默 return,
Mobile 同样情况会弹「未获取到麦克风权限」。用户只要求替换 alert,未擅自补,已报告等决定🐛 变异测试抓到我自己写的哑弹(重要):脚本第一版 ALERT_RE = /(^|[^.\w$])alert\s*\(/,
本意排除 onAlert(/myAlert(,结果连 window.alert( 一起排掉了(点号也在排除集里)——
「语音段里没有 alert」这条断言永远为真。把 4 处改回 window.alert 做变异测试才发现:
第一次注入只报 3 项失败(全是 showToast 数量类),alert 类断言全绿。修正为
/window\s*\.\s*alert\s*\(|(^|[^.\w$])alert\s*\(/ 后重跑变异:9 项失败(含 4 条 alert 断言,
且报出了注入的 4 个行号),还原后 27/27。
教训:「断言恒真」比「断言漏写」更难发现 —— 每条新断言都要靠变异测试证明它会红。
回归:npm run build 通过;verify-attachment-question.mjs 39/39(它静态读两个壳组件,
正好覆盖本次改动的文件);validate-harness 通过(功能 23 项,in_progress 1 项)。
未跑需要真实后端的脚本(DMS 上传 / 答案流式等)—— 本次改动只碰两个壳组件的提示调用点,
与它们的被测面不相交。
顺带:BusinessAssistantPC.vue / BusinessAssistantMobile.vue 里各有一行
(currentSessionGenerating,)在 HEAD 里是 \r\r\n(双 CR,历史遗留)—— 编辑后规范成 \r\n,
属无害清理,也是 git diff 显示「整文件重写」的一部分原因(这两个文件本来就以 CRLF 入库)。
未提交的既有改动一并带上:APP_MESSAGE_DURATION 3 → 2(工作区里已有的手改)。
用户反馈(附截图:气泡里只有「这是什么」,没有图):「提交之后看不到图片了」。
先复现,不猜(dev server 8083 正开着,用户就是在开发环境测的):
/static/photo/1889-c_question-<uuid>.pnghttps://localhost:8083/static/photo/…(相对路径)→ 200 但是 text/html(Vite 的 SPA 兜底吐了
index.html)→ <img> 解不出来 → 图不显示https://localhost:8083/dms-api/static/photo/…(拼了代理前缀)→ 200 image/png ✓根因(git log -S 确认):consumeSuccessfulFileTransmission 里一直写的是
url: file.fileUrl —— 在 OSS 时代没问题(那时 fileUrl 是 https://prod.heijingai.com/... 绝对地址,
能直接当 src)。续 24 改成 DMS 后 fileUrl 变成相对路径,上传模块自己新增了 url(展示地址)
与 fileUrl(发后端)的分工,但这里没跟着改,于是消息附件的 url 成了相对路径 → 气泡不显示。
📌
file-upload.ts的注释当时甚至已经写好了"存进消息里的就是这个地址……发给后端的仍是相对路径 (见toChatAttachmentUrls用的fileUrl字段),两者不要混"——文档写了、代码没实现。 教训:注释里描述的设计不会自己成立,要么写进代码要么写进断言。
修法(把注释里那套契约真正实现):
shared.ts:BusinessAssistantMessageAttachment 补 fileUrl?(=相对路径,发后端)、
url? 改为可选并注明它是可显示地址;toChatAttachmentUrls 的 pick 改取
item.fileUrl ?? item.url(后端拿到的仍是相对路径,没变)useBusinessAssistantUpload.ts:消息附件按上传模块同款分工填两个字段;
url 按相对路径重新拼(不沿用 file.url:那条路径可能还是已被 revoke 的 blob)url = undefined(气泡走「文件条」分支,只显示名字/大小),fileUrl 照旧运行过的验证:verify-attachment-question.mjs 由 39 → 51/51,新增【7】12 项钉死这个契约:
两个字段各自的值、两者不相等、请求体里是相对路径且展示地址没混进去、非图片没有展示地址、
未配 DMS 前缀时上传直接失败且不产生坏附件。
变异测试两个方向都做了:①把上传模块 + pick 一起改回旧写法 → 3 项失败(显示方向);
②只改回 pick(模拟"修了显示、忘了请求体")→ 3 项失败(请求体方向)。还原后 51/51。
另:npm run build 通过、vue-tsc 改动文件无类型错误、verify-toast-channel.mjs 27/27、
validate-harness 通过。
⚠️ 外观仍需用户在浏览器确认:再传一张图提问,气泡里应能看到图。 → ✅ 用户 2026-09-23 确认无误(与续 29 的 c_image、续 30 的 1887 建行一起确认)。
🔴 同轮发现的部署缺口(已登记为技术债 #9,未处理):.env.production / .env.qingpu /
.env.test 都没有 VITE_DMS_API(只有 .env.development 有)。构建产物可自证:
dist/index.html 里 globalThis.VITE_DMS_API = "%VITE_DMS_API%" 原样留着。
后果是这两个环境里附件根本传不上去(直接抛「未配置 DMS 接口地址」)+ DMS 落库静默跳过。
要接生产得先拍板两件事:DMS 从部署环境是否可达、token 由谁注入
(开发环境是 vite 代理转发时加 token 头,生产得 nginx 做同样的事)。
用户要求:「现在图片和文件路径并没有存到 DMS 里面,我已经在 DMS 助手问答记录新建了两个字段 c_image 和 c_file,后续也要将对应的路径存到对应的字段里。」
动手前先验前提(这一步不能省):DMS_API.md 记着一条实测陷阱 ——
payload 里含模型里不存在的列时,addContent 会忽略该列照写,而 updateContent 会
静默丢弃整个 patch(返回仍是 code 200「成功」)。所以先拉模型定义确认列真的在:
POST /dms/model/getModelById?modelId=2038 → fieldList 里 c_image(别名「图片」)与
c_file(别名「文件」)都在,frontType: text ✓(模型字段数 13 → 15)。
存什么值:存相对路径(/static/photo/…),也就是发给后端 pictures/files 的同一个值
—— 库里记的和后端收到的是同一份,出问题好对账;不存 /dms-api 展示地址(那是环境相关的拼接结果,
塞进库里就等于把部署环境写死)。多张图/多个文件用逗号分隔(路径本身不含逗号)。
实现:
chat-sessions-dms.ts:upsertDmsRecord 新增 images?: string[] / files?: string[],
经 joinDmsPathList(过滤空串/纯空白 → 逗号拼)写进 c_image / c_file;
没有附件就不写这两列(与「没附件时请求体不多发字段」同口径);
更新路径只在「有值且该列还空着」时才进 patch —— 万一哪天这两列在 DMS 侧被删,
最坏也只影响带附件的轮次,不会连累补 answer 这条主链路(那正是陷阱的杀伤方式)useBusinessAssistantChat.ts:saveTurnToDms 透传两个参数;sendMessage 里
直接用 attachmentsForBackend.pictures / .files(不再另算一套地址)fetchDmsSessionRecords 仍只取 question/answer(历史消息渲染附件是另一件事,未要求)运行过的验证:
verify-dms-chat-storage.mjs 65 → 78/78(打真实 DMS,新增【8】13 项):
①先断言模型里有这两列(守住陷阱的前提)②两张图 + 一个文件真写入 → 读回是逗号拼的相对路径
③补 answer 时附件列不被覆盖、且 answer 本身写进去了(证明没连累主链路)
④无附件时两列不写 ⑤脏值(空串/纯空格)被过滤、files 不传时不写 c_fileif (images) content.c_image = images; → 3 项失败;还原后 78/78verify-attachment-question.mjs 51 → 57/57:新增【8】6 项源码级接线断言
(hook 有没有把路径递下去 —— 这段是 Vue hook 里的事,node 跑不起来)npm run build 通过;vue-tsc 改动文件无类型错误;verify-toast-channel.mjs 27/27;
validate-harness 通过DMS_COLUMNS.md 的 1889 字段表补两列、速查表字段数 13 → 15(该文件自己要求
「改字段后重新核对本表」),并写清存的是相对路径 + 逗号分隔 + 陷阱约束⚠️ 仍需用户在浏览器确认:带图提问一次,然后在 DMS 里看那条记录的 c_image 是否有值。
→ ✅ 用户 2026-09-23 确认无误(带附件那轮的 c_image 有值)。
用户要求(承接上一条「为什么没写入助手会话栏目」的答复):
「只要对话就要在助手会话栏目建行,只是未登录 && 本轮没带回 company_info 时传 IP、登录状态、
以及其他可填的字段,先列出可传字段,我判断可行后就修改」→ 列完清单后用户拍板:
c_credit_code 留空;建行时机保持现状(回答完成时)。
先查清楚再动手(用户要求先出清单):
must:c_id / c_credit_code / summary /
qpyszx / qyzt / rzqpyx 标着必填;summary、c_description、c_record_type、c_focus_points、
c_core_needs、c_contact_clues、c_follow_up_departments、c_settlement_intent_level、jygjbm
是运营分析系统(author=ams_user)回填的,前端碰了会把值冲掉c_credit_code 虽 must=true,但库里 200 行样本里 86 行是空的
(probe-batch-* 那些行就是 addContent 建的)→ 未登录无企业信息建行在 DMS 侧可行isLoggedIn 才 fetchAndMergeRemoteSessions),
查的是真实信用代码;且 fetchDmsSessions 本来就排除 c_is_login="0" 的访客行
→ 新建的访客行不会串进任何企业的会话列表c_credit_code 会命中所有"空代码"行
(实测返回 20 行)—— 今天不会发生(读侧只在登录后跑),但哪天放开访客读远端会话就会串号实现(改动只有一处逻辑):删掉 chat-sessions-dms.ts 里那道「访客且无企业信息 → 不建行」的
早返回(原 279-281 行);其余创建逻辑原样复用 —— 那种情况下行里就是
c_ip(真实 IP,取不到不写)+ c_is_login="0" + c_created_at/c_updated_at + 系统 title
summary=""、三个布尔 false,见 buildRequiredSessionFields);
c_credit_code 留空、企业名称/图片留空。函数头注释与
docs/reference/current-api-call-sites.md 里那条「2026-09-18 起 1887 不再写入」的旧说法一并改正。运行过的验证:verify-dms-chat-storage.mjs 78 → 89/89(打真实 DMS,新增【9】11 项):
未登录无企业信息确实建了行、c_is_login="0"、c_ip 是真 IP、c_credit_code 留空、
企业名称/图片留空、系统 title 正确、四个必填位是空值兜底、不碰运营分析回填列、
重复 upsert 仍只有一行、后续轮次带回企业信息时这个"纯访客行"能正常被填上。
变异测试:注入"新建行一律不建"(模拟旧行为)→ 大量断言失败;还原后 89/89。
另跑:verify-dms-payload-fields.mjs 19/19(第 4 节正是「1887 只在 totalResponse 写一次」的
源码级断言 —— 与用户「时机保持现状」的决定一致)、verify-attachment-question.mjs 57/57、
verify-toast-channel.mjs 27/27、npm run build 通过、vue-tsc 改动文件无类型错误、
validate-harness 通过。
⚠️ 仍未做(等用户决定):中断/报错的轮次(停止生成、409、断流)仍不会在 1887 建行 —— 这是用户选定的「建行时机保持现状」,不是漏做。
→ ✅ 用户 2026-09-23 确认无误:随便问一轮(未登录、未提公司)在 DMS「助手会话」里确实建了行。
用户要求:「接下来我们来实现语音识别功能的更换,一步一步来」,给了阿里云 NLS 的
CreateToken 接口与凭据,并明确要求:凭据的具体值不要记录。
第 1 步 · 能不能换到 token:能。CreateToken 是阿里云 RPC 签名接口,需要
AccessKeySecret(第一次只给了 AK,实测返回 MissingSignature;缺 Secret 签不出来)。
用 AK/SK 现签后拿到了 token,并顺手验证它真能用于识别:
拿 token + appkey 调一句话识别 /stream/v1/asr → {"status":20000000,"message":"SUCCESS"}
(探针音频是自己生成的 440Hz 正弦,所以 result 为空 —— 验的是凭据链路,不是识别质量)。
用户拍板:① dev 期走 vite 侧换 token(前端零凭据);② 凭据放
.env.development.local(仓库已 gitignore,git check-ignore 确认过)。
第 2 步 · 实现(前端这一侧还没接,见「下一步」):
harness/tools/nls-token.mjs:NLS token 的唯一获取口径(签名算法 v1.0/HMAC-SHA1、
percentEncode 的三处特例、UTC 时间戳、错误里带阿里云原始 Code/Message),
外加 createNlsTokenProvider() —— 带 5 分钟提前刷新与并发去重(token 24 小时过期,
不缓存每次都要打阿里云)harness/tools/get-nls-token.mjs 改成命令行壳子(复用上面那份,新增 --check);
凭据只从环境变量读,AK 在日志里打码vite.config.ts 新增 dev 端点 GET /nls-token/(apply: 'serve')→
{ token, expireAt, appkey };缺凭据时 503 + 明确提示(照 [dms] 那套 warn-once 的写法)。
secret 只在 node 侧:前端只拿 token(和 appkey —— 它要出现在识别请求里,
但没必要打进 bundle,从端点拿就能免重新打包).env.development.local(gitignored,值不进仓库任何文件):运行过的验证:
curl -sk https://localhost:8083/nls-token/ → 200,返回 {token, expireAt, appkey} ✓
(vite 检测到配置变化自动重启后生效)status:20000000 SUCCESS ✓(端到端通了)npm run build 通过;产物里搜三个凭据值 → 0 个文件命中 ✓("secret 不进前端"这条有实证)git check-ignore .env.development.local 命中 .gitignore:21:.env.*.local ✓;
git status 里看不到该文件 ✓⚠️ 下一步(未做):NLS 实时识别 WebSocket(wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…,
StartTranscription JSON 帧 + 二进制音频帧 + TranscriptionResultChanged/SentenceEnd 事件),
以及前端取 token 的小模块。改造原则:保持现有 ASR 类的对外契约
(openMicroPhone / startRecord / openSocket / transform() / close / clear +
transform 事件),这样两个壳组件一行都不用改。
⚠️ 生产必须另做:/nls-token/ 是 dev-only(apply:'serve')。生产要由后端/网关提供
同样形态的端点(前端仍是同源 GET /nls-token/,secret 仍在服务端)。已登记为技术债 #10。
先摸协议,且不凭记忆:官方文档在 help.aliyun.com(WebFetch 被策略挡),
所以按本项目的老规矩 —— 直接打真实网关读原始帧(新增
harness/tools/probe-nls-realtime.mjs,把上下行每一帧原样打出来)。
实测踩到的两条硬约束(都已写进代码注释 + 断言):
appkey —— 只有 StartTranscription 带时,StopTranscription 回
TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!task_id 不能是空串 —— 回 Gateway:MESSAGE_INVALID:Invalid task id ''!TranscriptionStarted → TranscriptionResultChanged(中间结果,会被后续覆盖)
→ SentenceEnd(整句定稿,实测 "青浦区高新技术企业奖励是多少钱?")→ TranscriptionCompleted。
拼接全文只能用 SentenceEnd,中间结果只用于实时显示。实现(前端):
src/three-libs/asr/nls-protocol.ts:纯函数协议层(帧构建 / 事件折叠 / 16k PCM 编码)。
抽出来的理由:这是唯一"错了就静默不工作"的部分,纯函数才能被 harness 直接测src/three-libs/asr/nls.ts:NlsAsr 类,对外契约与旧 SDK 一致
(openMicroPhone/startRecord/openSocket/transform/close/clear + transform/error 事件 +
暴露 audioContext/source 供 PC 画波形)→ 两个壳组件切过来只改 importsrc/network/api/nls-token.ts:取 token(缓存到过期前 5 分钟 + 并发去重);
src/utils/runtime-config.ts 加 getNlsTokenApiBaseUrl()(同源 /nls-token,可用 env 覆盖)audioContext.sampleRate 算(旧实现写死 44100,
48k 设备上会送出"慢 8.8% 的 16k",见 asr-realtime.md §9.1)验证:新增 harness/tools/verify-nls-realtime.mjs —— 30/30:
Microsoft Huihui Desktop)→ 按 100ms 推流给真实网关
→ 断言"识别期间有中间结果 + 定稿文本包含关键内容"。实测识别结果与所说话一致:
"青浦区高新技术企业奖励是多少钱?" ✓这条测试立刻抓到两个真 bug(都是我这次写的):① buildStartTranscription 不传 task_id 时
发的是空串 → 网关 40000002(已改成自动生成 32 位十六进制);② 我自己的断言索引写错
(实际事件序列是 mid,mid,fin,mid)。变异测试:把 task_id 改回空串 → 6 项失败(含网关真实拒绝)。
⚠️ 变异测试时我的还原命令走岔过一次(备份路径写成 harness/tools/_p.bak),
文件一度停在坏状态 —— 靠第二次核对发现并还原;教训:变异测试的还原要当场 grep 确认。
另跑:npm run build 通过、vue-tsc 新文件无类型错误、validate-harness 通过。
文档:新增 harness/docs/reference/asr-nls.md(现行实现:连接/指令/事件/音频/契约/怎么验),
reference/README.md 索引表与 architecture.md 都指向它,旧的 asr-realtime.md 顶部标注"这是换之前那套"。
⚠️ 下一步(未做,等用户确认):两个壳组件还没切过来 —— 仍 import ASR from '@/three-libs/asr/index'
(旧厂商实现)。切换只改 import(契约一致),但必须由用户在浏览器点一次麦克风实测才能算完成。
旧实现保留,切回去也只改 import。
改了什么(两处、每处两行):
BusinessAssistantPC.vue / BusinessAssistantMobile.vue:
import ASR from '@/three-libs/asr/index' → '@/three-libs/asr/nls'new ASR(undefined, {audio:true,video:false}))——
NLS 的 appkey 由端点下发,前端不再内置任何云凭据openMicroPhone / startRecord / openSocket / transform /
close / clear + transform/error 事件 + audioContext/source 供 PC 画波形)验证:verify-nls-realtime.mjs 30 → 39/39(新增【4】9 项源码级断言:
两个壳确实 import 的是 nls、不再 import 旧 SDK、不再 new ASR("…") 硬编码、六个调用点齐全、
旧实现仍在仓库里作退路)。npm run build 通过,vue-tsc 无新错误。
产物核对:三个云凭据 + 旧厂商 appKey 在 dist/ 里全部 0 命中(连旧的也清掉了),
而 nls-gateway-cn-shanghai 已进 main bundle(新实现确实被打进去)。
⚠️ 仍算未完成的部分:浏览器实测没做(harness 里没有浏览器自动化,麦克风也只能真人说)。 请用户点一次麦克风:① 边说边出字(中间结果)② 说话停顿后那句定稿 ③ 再点一次停止文本保留 ④ 桌面端波形在动。在用户确认之前,功能状态不能算 passing。
用户反馈(切到 NLS 后第一次浏览器实测):「出字延迟高」。
先量化,不猜(给探针加了「感知延迟 = 麦克风已说多少 − 网关处理到第几毫秒」+ 可调断句阈值
| 项 | 实测 |
|---|---|
| 网关中间结果节奏 | 恒 ~480ms 一次 —— max_sentence_silence 300/800 都一样,开 |
enable_semantic_sentence_detection 也一样 → 引擎固有,前端改不了 |
|
| 首个中间结果 | 音频开始后 ~480~720ms |
定稿 SentenceEnd |
阈值 300 比 800 早到约 300ms |
| 客户端落后(按 1× 排空) | 永久 ~1.4s ← 这就是"延迟高"的主因 |
根因:连上网关前(取 token + 握手 0.2~1s)麦克风已在采集,这段音频先攒着;若之后只按 1× 实时排空,积压永远排不掉 ⇒ 恒定慢半拍。旧厂商 SDK 是按 2× 排空的,所以它能追上 ——这是我这次切换引入的退化(不是 NLS 的问题)。
改法 + A/B 实测(模拟 1s 握手积压、同一段语音):
| 排空策略 | 感知延迟(稳定期) | 定稿时 |
|---|---|---|
| 1× 实时(原实现) | 1377~1434ms(不收敛) | 2087ms |
4× 追赶(现在:NLS_FLUSH_TICK_MS=50 + 每 tick 最多 2 帧) |
145~183ms | 683ms |
另外两处同方向调整:停止时余量按 ~5× 发(用户正等最后一段文字)、断句阈值默认 800 → 500(网关文档默认 800;取中,实测 300 会早 300ms,500 是稳妥折中)。
验证:verify-nls-realtime.mjs 39 → 42/42,新增 3 条把这套延迟策略钉住的断言
(排空速率必须 >1× 且 ≤8×、断句阈值在 200~2000 且不高于 800)。
⚠️ 过程中我第一版追赶上限写成 8 帧/50ms = 16×,被自己那条「≤8×」断言拦下 → 收敛到 4×。
⚠️ 仍需用户复测:刷新页面后再点一次麦克风,看是否"说出去几乎同时出字"。 剩下的 ~150ms 是链路、~480ms 刷新节奏是引擎的固有节奏(见 asr-nls.md §4.5)。
用户要求:「获取临时token修改为调用 http://121.43.55.7:10011/proxy/verify/getALYToken,
对应的 appkey 为 3jRt1atwVuD9dh12」。
先探端点:GET 返回 {"code":200,"message":"成功","content":"<32 位 token>"}(带不带 appkey
参数都能拿到,每次新签一个)。先用它 + 新 appkey 打了一次真实识别 → 结果正确
("青浦区高新技术企业奖励是多少钱?"),确认这对 token/appkey 可用。
改法(前端不再需要 AccessKeySecret):
vite.config.ts:新增 /aly-api/ 代理 → http://121.43.55.7:10011(与 /ip-api/
同一台机器、同样的混合内容原因:端点只有 HTTP、页面是 HTTPS,必须走同源路径);
删掉先前那段"自己签 token"的 /nls-token/ 中间件(前端侧不再需要 AK/SK)src/network/api/nls-token.ts:改为调网关,解析 {code, content};网关不返回过期时间,
按官方文档的 24 小时估(TOKEN_LIFETIME_MS),缓存到过期前 5 分钟、并发去重照旧src/utils/runtime-config.ts:getNlsTokenApiUrl()(默认 /aly-api/proxy/verify/getALYToken,
可用 VITE_NLS_TOKEN_API 覆盖)+ getNlsAppkey()(VITE_NLS_APPKEY)VITE_NLS_TOKEN_API 写进 .env.development(同源路径,不是凭据,可入库);
appkey 写进 .env.development.local(gitignored,按"不记录具体值"的口径)nls-token.mjs 新增 resolveNlsToken() —— 显式 token → 网关 → AK/SK 自签(退路),
探针与验证脚本都改走它(所以现在的验证走的是生产同一条路)运行过的验证:
curl https://localhost:8083/aly-api/proxy/verify/getALYToken → 200 + 网关原文 ✓(代理生效)/nls-token/ 已停用(返回 SPA 兜底的 HTML,不再是端点)✓verify-nls-realtime.mjs 42/42,真实识别那一段打印 (token 来源:gateway) 且识别正确 ✓VITE_NLS_APPKEY 注入到前端模块 ✓(curl /src/utils/runtime-config.ts 可见)npm run build 通过;vue-tsc 无新错误用户指示:把我列出的 B 类(7 项死代码 / 已停用接口)全部删掉。
删了什么(都在 §0 清单里,删完把那节改成「已删」):
| # | 删掉的东西 |
|---|---|
| 14 | 厂商 OSS 上传:useBusinessAssistantUpload.ts 里两个大的 [已停用] 注释块 + 四份 env 的 VITE_UPLOAD_SIGN_URL |
| 15 | 旧厂商 ASR:src/three-libs/asr/index.ts、VITE_ASR / VITE_XF_ASR(四份 env)、vite 的 /asr/ 代理、index.html 的 globalThis.isXF |
| 16 | stream-message-coordinator.ts 整个类(~1100 行)+ 随之成孤儿的 src/utils/scope-stream-boundary.ts;MessageState 枚举搬到 src/types/record.ts(它是活的,取值一个没动) |
| 17 | updateSessionInfo()(chat-sessions.ts) |
| 18 | getModelServerUrl() / isKnowledgeApi()(runtime-config.ts) |
| 19 | 三个空目录:three-libs/metamaker、global、cache、assets |
| 20 | syncSessionTitleToServer 的整段注释 + useBusinessAssistantChat 里那条注释掉的旧协调器 import |
| — | 顺带:qs 与 @microsoft/fetch-event-source 两个 npm 依赖因此变成孤儿 → npm uninstall |
踩了一次坑(记录):我把 src/components/utils.ts 也当成"只有死协调器用"删了 ——
判断方法错了:我只 grep 了 import 路径(components/utils'),没 grep 它导出的符号名;
实际上 ScrollList.vue / BusinessRecord.vue 直接 from '../utils' 用它的
sleep / getQueryObj / computedOriginContentPosition。是 npm run build 报
Could not resolve "../utils" 才暴露(删完就该立刻构建,这次正是这么抓到的)→
git restore 恢复,重新构建通过。
教训:删文件前要按"导出的符号名"反查引用,不能只查路径;删完立刻构建。
运行过的验证:
npm run build 通过(失败过一次,见上)verify-nls-realtime.mjs 43/43(把「旧厂商 SDK 仍在」那条断言反过来成
「已删除」+ 断言旧 env 也清了 —— 免得哪天被误加回来)validate-harness 通过npx vue-tsc --noEmit:只剩一条既有错误 Cannot find type definition file for 'webpack-env'
(tsconfig.json 里列了 webpack-env 但没装 @types/webpack-env,自初始提交起就这样,
不是这次改动引入的;它会让全量类型检查跑不起来,属独立的小清理项)npx jest:0 个用例(仓库本来就没有 jest 测试文件,npm run test 是空跑)⚠️ 又一次"只 grep src/ 会误判":VITE_USE_KNOWLEDGE_API 我先前写成"只有死代码在用",
其实 index.html 第 321 行在用它切「政策解读」卡片 → 没删,并在清单里更正。
(同上次 VITE_APP_KEY 的教训 —— index.html 里的内联脚本必须一起 grep。)
用户要求:「环境只保留开发和生产环境」→ 删掉 qingpu 与 test 两个构建模式。
动手前先查牵连(这次没踩坑,因为先 diff 了):
.env.qingpu 里有两项 production 没有的配置:VITE_DOWNLOAD_URL="/api/download/"
(生产是绝对地址)和 VITE_REDIRECT_MAP(production 完全没有)。
⚠️ 若直接删文件,redirect.html 里的 %VITE_REDIRECT_MAP% 会原样留在产物里 →
var redirectMap = %VITE_REDIRECT_MAP%; 直接语法错误(顺带说明:以前 npm run build
打出来的生产包,那个页面就是坏的,只有 qingpu 构建是对的 —— 这次合并顺带修掉了).env.test 独有的是测试主机(human-card-mini.metamaker.cn)与 VITE_USE_KNOWLEDGE_API=false
→ 无保留价值做法:
.env.production(都加了说明注释:VITE_DOWNLOAD_URL 走同源
/api/download/,需部署侧 nginx 转发;VITE_REDIRECT_MAP 不能缺),然后删 .env.qingpu / .env.testpackage.json:删 build:qingpu / build:test;保留 zip(青浦部署包),
新增 deploy:qingpu = npm run build && npm run zip(原来一条命令出包的习惯不变)index.html:删掉两处本来就是死的 qingpu 分支 —— globalThis.isQingpuEnv = "MODE" === "qingpu"
(写的是字面量,永远 false;全仓库无人读它)与 let isLanMode = globalThis.isQingpuEnv
(赋值后从未使用);["development","test"].includes(mode) → mode === "development"运行过的验证:
npm run build 通过;产物核对:dist/redirect.html 的 redirectMap 有值 ✓、
dist/index.html 的 downloadURL = "/api/download/" + … ✓(合并确实生效)validate-harness 通过⚠️ 新发现的待清理项(只报告,未动):index.html 的 initToken()
(POST {modelServer}/open/openid_to_token,用 appKey=VITE_APP_KEY)挂在
if (isKnowledgeApi) … else initToken() 的 else 上,而 VITE_USE_KNOWLEDGE_API 在
剩下两个 env 里都是 true(唯一为 false 的 .env.test 已删)→ 这条路径现在两个环境都走不到,
initToken() / VITE_APP_KEY / openid 引导成为可清理项。删它之前要先理清它与
initCardInfo() 拉回的卡片之间的关系(isKnowledgeApi 分支里是写死的「政策解读」卡片)。
已记进 docs/reference/current-api-call-sites.md §6。
用户要求:①「判断删除后是否会对现用功能造成影响,没有影响就删掉」(指 initToken() /
openid_to_token / VITE_APP_KEY);②「将 ASR 文件夹移出来,然后删除 three-libs 文件夹」。
① 影响评估(先查再删):
index.html 的结构是 if (globalThis.isKnowledgeApi) { 写死「政策解读」cardInfo … } else { initToken(); }
—— 两个环境(dev / production)都是 VITE_USE_KNOWLEDGE_API=true,else 永远走不到initToken() 的产物 globalThis.token、initCardInfo() 的产物 globalThis.cardInfo
全仓库没有任何读者(只 grep 到赋值);hasSequence 与 dataLoaded/sequenceLoaded
两个事件在 if 分支里同样会发initCardInfo() 里调用 globalThis.changeLoadingText(...) /
globalThis.showReloadView(...),而这两个函数从未挂到 globalThis → 一旦走到就会
TypeError(也就是"失败时才走的那几个分支"从来没生效过)删了什么:initToken()、initCardInfo()、else { initToken(); } 分支,以及随之孤儿的
randomString() / changeLoadingText() / showReloadView();两份 env 里的 VITE_APP_KEY
(index.html 里的 app_key 占位也随之消失 —— 产物核对:app_key / openid_to_token 0 命中)。
② asr 搬家:git mv src/three-libs/asr src/asr → 删 src/three-libs/(目录名有误导性,
且清理后只剩 asr 一个子目录)。同步改:两个壳组件的 import(@/asr/nls)、
nls-protocol.ts 注释里的自引用、harness/tools/_entry-nls.ts 的入口路径、
验证脚本的断言(新增两条:three-libs 已删、asr 现在在 src/asr;旧的「不再 import 旧 SDK」
断言也覆盖了新路径)。
运行过的验证:
npm run build 通过;产物核对:dist/index.html 里 app_key / openid_to_token 已无,
dist/redirect.html 的 redirectMap 仍有值(上一轮的成果没被碰坏)verify-nls-realtime.mjs 43 → 44/44(含新增的目录位置断言)validate-harness 通过architecture.md(目录树/ASR 段落 + 搬家沿革)、asr-nls.md(代码位置表、
切换说明)、current-api-call-sites.md(§0 A 表第 6 行、§三 的旧路径标注为已删)、
asr-realtime.md(历史文档里指向已删文件的链接改成纯文本,避免 404 链接)用户反馈(附截图:作答后卡片缩水成一个只有「确认公司」+ 一个选项的小框): 「现在选择完变成这样了,这不对,问卷长什么样选完后还是长什么样,样式不能变」。
根因:只读态走的是另一套模板(v-if="isReadonly && contentAnswers.length > 0" 那个分支)
—— 那套标记比交互态简陋得多:没有 (单选) 标签、没有完整选项列表、没有分隔线与提交按钮,
只按 answers 渲染「已选项/已填文本」。再加上续 44 的写回把整块替换成只有 answers 的 JSON,
连渲染问卷本体的 questions 都没了,于是"缩水"得更彻底。
修法(两处):
question-cards-answers.ts):
applyQuestionCardsAnswers 改成把 answers 合并进原有 JSON(保留 questions),
不再整块替换QuestionCard.vue):
question-card-shell:class="{ submitted: submitted || isReadonly, readonly: isReadonly }"
(submitted 的样式本来就是"选项不再有 hover 反馈",正合只读)hydrateFromAnswers:按选项文字反查下标填 selectedOptions、
填 freeformTexts)→ 渲染出来就是"刚才那份问卷",选中项照样高亮|| isReadonly、canSubmit 加 isReadonly(提交按钮保持可见但禁用,
.qc-submit-btn:disabled 的淡紫样式与截图里那个按钮一致)、两处输入框加 :readonly="isReadonly"运行过的验证:verify-question-cards 29 → 36/36:新增 7 条源码级断言钉住"只读复用同一模板"
(没有单独的只读分支、只有一处 question-card-shell、只读开关在同一模板上、hydration 存在、
守卫含 isReadonly、提交按钮禁用、输入框 readonly);并把往返断言改成
「questions 与 answers 都在 → 既是一样的样式、又是只读」。
变异测试:把写回改回"只有 answers"(复现缩水)→ 3 项失败(含"只读态能渲染出问卷本体");
还原后 36/36。回归:内联脚本 6/6、NLS 44/44、附件 57/57、toast 27/27、政策库 29/29、
build 通过、vue-tsc 仅既有 webpack-env 报错。
⚠️ 仍需用户在浏览器确认:作答后卡片应与作答前长得一模一样(同样的 (单选)、选项、提交按钮),
只是不能改、选中项保持高亮。
→ ✅ 用户 2026-09-24 确认「没问题了」(问卷只读态、选项高亮两项均通过)。
用户反馈:「提交完问卷直接看不见了」——即作答后整张卡片消失。
根因(我上一版引入):写回后卡片内容变成 {answers:[…]},但渲染层交给组件的 content 是带标记的
—— 切行器 StreamXMLFilter.handleBehavior 对 block 行产出的是 openTag + inner + closeTag:
<question-cards>{"answers":[…]}</question-cards>
而 QuestionCard 里两个解析函数一个剥标记、一个不剥:parseQuestions 剥、
parseAnswersFromContent 不剥 → 作答后只读分支解析失败、交互分支也解析不出 questions
→ 模板两个分支都不成立 → 卡片渲染成空。
我的测试为什么没抓到(关键教训):续 43 的 [1] 节里,我手工把标记切掉再断言 JSON 合法 —— 把渲染层给的是带标记内容这个事实自己抹平了,于是测试绿着、页面上卡片是空的。 教训:组件解析类的验证必须走真实管线(真实切行器的输出),不能自己构造理想输入。
修法:
src/utils/question-cards-content.ts:
unwrapQuestionCardsContent(统一解包)+ parseQuestionsFromContent / parseAnswersFromContent,
两个解析走同一个解包逻辑;QuestionCard.vue 改为引用它(本地那两份实现删掉)QuestionCardAnswer 也收到该模块(原来是 answers 模块自己定义一份)运行过的验证:verify-question-cards 22 → 29/29(新增 6 项往返 + 1 项兼容);
变异测试:把解包改成直接返回原文(=复现原 bug)→ 6 项失败,其中就有那条「至少有一个分支能渲染」;
还原后 29/29。回归:内联脚本 6/6、NLS 44/44、附件 57/57、toast 27/27、政策库 29/29、
DMS 字段契约 19/19、build 通过、vue-tsc 无新错、validate-harness 通过。
⚠️ 仍需用户在浏览器确认:提交问卷后卡片应变成只读(显示所选/所填),而不是消失。
用户反馈(附截图): ①「如果下方已经新对话了,上方的问卷应该是只读状态」 ②「在问卷中选择具体项时,边框有点问题……左边的边框有可能一直是选中的样式, 我点击旁边的选项依旧不变,一次应该只有一个选项是选中样式」
① 的根因:QuestionCard.vue 本来就有只读渲染分支(isReadonly = 解析 content 得到 answers
→ 渲染成"已选项/已填文本"),但此前没有任何代码把答案写回 content ——
所以那个分支永远走不到,提交过的问卷在下一轮出现后仍可点可填。
修法:新增纯函数模块 src/utils/question-cards-answers.ts,
在 submitQuestionAnswers 里把答案写回最后一条带 <question-cards> 的 AI 消息
(applyQuestionCardsAnswers 只替换最后一个问卷块的内部,其余内容一字不动)。
写回后:卡片立刻变只读(内容变了 → BusinessRecord 的 content watcher 会整体重解析),
saveHistory() 之后刷新也仍是已作答态。
② 的根因:isFreeformSelected() 写的是 常驻输入框 || 被点开 ——
常驻输入框(freeformInputOnTop,就是截图里排在最上的 A 输入框)恒为 true,
于是那一行永远带着选中边框,点旁边的选项也不会变。
修法:改成按"答案出自谁"判定 —— 常驻输入框填了内容、且(单选时)没有选中别的选项才算选中;
多选保持"选项与自由输入可同时成立";普通「其他」输入框行为不变。
运行过的验证:新增 harness/tools/verify-question-cards.mjs —— 18/18:
answers、形状与只读渲染一致(header/selected/freeText)、
问卷外的内容没被动、作答前后 hasAnsweredQuestionCards 判定正确<question-cards> 的 AI 消息";
QuestionCard 的只读判定仍在;常驻输入框不再恒判为选中(防回退)回归:verify-inline-scripts 6/6、verify-nls-realtime 44/44、附件 57/57、toast 27/27、
verify-policy-library 29/29、npm run build 通过、vue-tsc 无新错、validate-harness 通过。
⚠️ 仍需用户在浏览器确认:提交问卷后卡片应变成只读(显示所选/所填),且点别的选项时边框只跟一个走。
用户说明:「我更换了本地数据,在 merchant-agent 文件夹下」——
public/merchant-agent/ 里 v1 已删、换成 policies_public.v2.json(392KB / 170 条)。
先体检(不猜):v2 是另一套 schema(键全是中文),与前端读的字段对不上:
| 前端字段(normalizePolicy 的别名链) | v2 里的字段 | 适配前 |
|---|---|---|
title ← name \|\| title \|\| 政策名称 |
政策名称 |
✅ 已能认 |
source(部门) ← department \|\| 部门 \|\| source |
政策所属部门 |
❌ 认不到 → 部门空、筛选失效 |
declaration_item ← declaration_item \|\| 申报事项 |
部门上报事项 / 大模型拆解申报事项(170 条两者全一致) |
❌ 认不到 → 显示"暂无申报项" |
date / end_date ← declaration_* |
开始时间 / 结束时间(结束时间可能是"未标明") |
❌ 认不到 |
apply_link ← apply_link \|\| 政策原文地址 \|\| buildPolicyDetailUrl |
政策原文地址(170 条全有) |
✅ 已能认 |
支持对象 / 支持方式和标准 / 申报条件 / 企业收益 |
同名中文键 | ✅ 已能认 |
data.市级政策id |
没有 | —— 详情链接改由「政策原文地址」提供 |
改了什么:
index.html 的 downloadURL 与 localFallbackURL(两处)、
check-policy-sources.mjs(路径与注释)PolicyMatch.vue):source 补 政策所属部门、declaration_item 补
部门上报事项/大模型拆解申报事项、date/end_date 补 开始时间/结束时间buildLibraryNameSet(policy-library-utils.ts):它只读 p.name,
v2 没有这个键 → 名称集合变空 → isPolicyLibraryItem 的"标题能在库里找到"这条回退路径
永远为假(实测:库里前 3 条全被判成"非惠企政策")。改为 政策名称 ?? name。
这个 bug 是验证脚本抓到的(不是肉眼看出来的)verify-policy-library.mjs:改为读 v2 + 名称兼容两版键 + 新增第 0 节"数据形态"断言
(条数>0、政策名/部门/申报事项/原文地址都取得到、v2 无 市级政策id 属预期);
第 6 节的覆盖率断言从「261/262」改为「= 0(v2 无该字段,链接走政策原文地址)」运行过的验证(全绿):verify-policy-library 22 → 29/29(含新的数据形态断言)、
verify-policy-card-list 24/24、verify-policy-card-fields 8/8、verify-policy-detail-index 10/10、
npm run build 通过、verify-inline-scripts 6/6、vue-tsc 只剩既有的 webpack-env 报错。
dev server 能取到 /merchant-agent/policies_public.v2.json(200)。
⚠️ 三个要用户拍板的点(已报告,未擅自改):
{VITE_DOWNLOAD_URL}merchant-agent/policies_public.v2.json → 404
(check-policy-sources.mjs 实测)。生产是「远端优先」,会 404 后退回打包进 dist 的本地 v2 ✓
能用;但要么远端补发,要么明确依赖本地副本用户反馈:切到 NLS 后浏览器里「进了录音状态但是不出字」。 诊断日志(用户在 Console 里看到的原文):
[nls] 开始采集:AudioContext=running 采样率=48000Hz processor 缓冲=2048
[nls] 音轨:label="麦克风 (Steam Streaming Microphone)" muted=false enabled=true state=live
[nls] 采集电平:峰值=0.0000 RMS=0.0000 ← 连续 20 秒全 0
[nls] ← TranscriptionStarted
[nls] 已推音频 2000ms(20 帧)… 34000ms ← 音频确实在推
根因:Chrome 抓到的是 Steam Streaming Microphone(装 Steam 时装的虚拟输入设备, 永远输出静音)。用户对着真麦说话,浏览器在听那个虚拟设备 → 音频"推出去了"但全是 0 → 网关一条结果都不回、也不报错(格式没错,所以没有 TaskFailed)。 换回真实麦克风后正常(用户 2026-09-24 确认)。
排除过程(都留了证据):
harness/tools/verify-nls-browser-path.mjs:
同一份 encodePcm16kMono、同样 2048 样本分块、同样 42.7ms 入队 + 50ms 拼帧)
→ 识别正确,证明组帧/重采样/协议都没问题label="麦克风 (Steam Streaming Microphone)" 一击命中用户问「为什么之前没这个问题」——答:大概率是"之前没试过这条路",证据两条:
git show 6783c69^:src/three-libs/asr/index.ts 对比):
旧的也是 getUserMedia({audio:true,video:false}) + createMediaStreamSource →
createScriptProcessor(0,1,1) → connect(destination) → source.connect(processor)。
选哪台设备由 Chrome/Windows 决定,两版代码在这里没有任何差别 ——
旧实现遇到同一台虚拟麦,表现会完全一样。代码侧的改进(把这次排查固化下来):
mute / unmute 事件[nls] 前缀):第一块音频的输入输出尺寸、推流进度、
每 2s 电平、网关事件名与结果、TaskFailed 原文、以及"录着音却一帧没发出去"的兜底告警用户反馈(截图:整页模糊 + 中间一行「正在初始化,请稍候...」):「现在界面有问题」。
根因:续 39 删 initToken / else 分支时,index.html 的内联脚本里留下一个不配对的 }
(第 83 行)→ 内联脚本整体语法错误 → 脚本一加载就抛 → globalThis.hasSequence 没置位、
sequenceLoaded 事件没派发 → BusinessAssistant.vue 的 initSequence() 一直等事件 →
加载遮罩永远不隐藏(截图上那层模糊就是 #loading-cover)。
为什么我没抓到(关键教训):npm run build 不解析 index.html 里那种普通 <script>
(Vite 只处理 type="module" 的,其余原样拷贝)→ 语法错误的页面构建照样绿。
续 39 我跑的是 build + 各条脚本(它们都不看 index.html 的脚本),于是"验证通过"是假的。
修法:删掉那个多余的 };随之新增守护脚本
harness/tools/verify-inline-scripts.mjs——
抽 dist/*.html 里的内联脚本用 new vm.Script() 编译(只编译不执行),语法错就 FAIL;
顺带把「产物里残留未替换的 %VITE_*% 占位符」也报出来(VITE_DMS_API 属已知项)。
并把它写进 sops/verification.md:改了 index.html / redirect.html
就必须跑它。
运行过的验证:
node --check ✓)npm run build + verify-inline-scripts.mjs 6/6;
变异测试:往产物里注入一个不配对的 } → 立刻 FAIL … Unexpected token '}',还原后 6/6
(证明它不是哑弹)⚠️ 请用户刷新页面确认:应能正常进入界面(遮罩消失)。 → ✅ 用户 2026-09-24 确认「页面已恢复正常」。
🔴 部署必配(已写进技术债 #10 与 asr-nls.md):
① nginx 要把 /aly-api/ 转发到 http://121.43.55.7:10011/(去掉前缀);
② .env.production / .env.qingpu / .env.test 都没有 VITE_NLS_APPKEY ——
实测生产构建的包里 appkey 为空,上线会因空 appkey 失败。需用户决定:由部署侧注入,
还是把 appkey 直接写进那三份 env(它本身不是密钥)。
用户要求:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。
产出:新建 harness/docs/reference/asr-realtime.md(275 行),
并在 docs/reference/README.md 的索引表与 docs/architecture.md 的 ASR 那段加上指路。
内容全部来自源码核对(不引厂商文档、不凭记忆):SDK 就在仓库里
(src/three-libs/asr/index.ts,578 行),所以每条都能指到行。
文档覆盖:能力边界(只产文本、不参与提问)|代码位置一览|构造参数(appKey / MediaStreamConstraints /
asrServerOptions 及其默认值)|方法表(openMicroPhone / startRecord / openSocket / transform /
close / clear + 事件 API)|事件契约(transform 的 {type, current, transformText, command}、
error)|底层 WebSocket 协议(URL 拼法与 query 参数、首帧、二进制音频帧、结束帧、
服务端 {command,text,is_sentence,is_quit} → mid/fin/breath 的映射表)|音频编码与发送节奏
(16k/16bit/mono、线性插值重采样、1280×4 字节/80ms 一轮)|两种用法(实时流 vs 整段录音,
后者当前未用)|env 现状表|6 条已知坑|两端生命周期对照表|手动自查清单。
整理过程中核实出的 6 条「已知坑」(都写进文档 §9,都未改代码):
to16kHZ),AudioContext 实际常是 48000 → 送出的"16k"比真实语速慢约 8.8%permissions.query().then() 里 throw,
不会让 openMicroPhone() reject;用户看到的是通用的「获取麦克风流失败…」openMicroPhone() 只会 throw 或返回 true → 两端 if (!success) 分支(含 PC 那个静默 return)
都是不可达代码 ← 顺带修正了我 09-23 上午报给用户的"PC 静默分支"那一条:它不是活缺口,是死代码socket.close()(clear() 里关连接那行有 isXunFei &&),依赖服务端自己关iss: human-large-screen),而当前分支根本不使用它
—— 既是死代码又是风险面clear() 会清空所有事件监听(allClear)→ 停止再开必须重新 addEventListener(两端都符合)运行过的验证:node harness/tools/validate-harness.mjs 通过(功能 23 项,in_progress 1 项)。
文档类改动按 sops/verification.md 只需要构建通过 —— 但本文档没有碰任何代码,
所以额外做的是逐条回源码核对:构造/方法/事件/URL 参数/帧格式/事件映射/两端调用点
(is-voice-input-starting 的取值、实例是复用还是新建、onBeforeUnmount 的差异)都对着
src/three-libs/asr/index.ts 与两个壳组件复核过。
⚠️ 未验证的部分(文档里已注明):服务端侧行为(asr_hub 的 project=qingpu /
engine=aliyun_dashscope 是否仍有效、vad_eos 等参数在非讯飞分支是否真的不需要)——
这些只能打真实服务测,本次没有做(没连麦克风、没连厂商服务)。
harness 里也没有 ASR 的自动化脚本(依赖麦克风与厂商服务,node 里无法稳定复现),
所以文档给的是一条手动自查清单。