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