Преглед изворни кода

feat(chat): 复制回答时去掉 markdown 语法符号

用户反馈:复制出来的内容还带着 ###、**、[文字](链接) 这些 md 符号语法。

实现:
- 新增纯函数 src/components/Chat/markdown-plain-text.ts 的 markdownToPlainText():
  走 marked 的 **token 树**(不是渲染成 HTML 再取 textContent —— 那样会丢掉链接地址),
  去掉标题 #、**/*、反引号、[文字](链接) 的括号语法、无序列表 - 、引用、分隔线、HTML 标签;
  保留全部可见文字、有序列表序号(顺序是内容)、链接地址(写成 文字(地址))。
  含兜底:marked 抛错时退回一组保守的正则剥离(宁可留符号,也不能复制不出东西)。
- BusinessRecord 的 copyableContent:先剥协议标记(原逻辑不变),再走 markdownToPlainText()

验证:新增 verify-markdown-plain-text.mjs(19/19)——用户那段真实结构逐项断言 + 边界 9 例
+ 拿那一轮真实回答(从 DMS 落盘)跑一遍;回归全绿(DMS 65/65、载荷字段 19/19、
答案流式 165/165、企业分类 84/84、判空 30/30、聊天任务 33/33);npm run build 通过。

顺带确认(非本次改动):那段回答里「### 三、融资类产品」下面确实没有正文 ——
DMS 存储内容与界面一致,是后端给的文本如此,不是渲染丢的。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 1 месец
родитељ
комит
765207ee82

+ 27 - 0
harness/progress.md

@@ -3628,3 +3628,30 @@ DMS 代理**注入全权限 token 且不区分调用者** → 任何能打开站
    现在桩成 `203.0.113.9`(TEST-NET-3 段),真实服务另用 curl 验过 ✓。
 3. **生产要配 nginx**:`/ip-api/` → `http://121.43.55.7:10011/`(去掉前缀)。
    不配也能跑:拿不到 IP 就退回随机 id,只是访客归属不稳定。
+
+### 续 21:复制回答时去掉 markdown 语法符号(用户要求)
+
+**用户反馈**:复制出来的内容还带着 `###`、`**`、`[文字](链接)` 这些 md 符号语法。
+(用户先贴了那一段回答,我起初以为是"### 三、融资类产品 没有正文"的空标题问题,
+挖到 DMS 存储内容核对后才澄清:**用户指的是复制带符号**。顺带确认那个空标题是后端给的:
+存储内容与界面一致,`### 三、…` 后面直接就是 `### 四、…`。)
+
+**实现**:
+- 新增纯函数 `src/components/Chat/markdown-plain-text.ts` 的 `markdownToPlainText()`:
+  **走 marked 的 token 树**(不是渲染成 HTML 再取 textContent —— 那样会**丢掉链接地址**),
+  去掉标题 `#`、`**`/`*`、`` ` ``、`[文字](链接)` 的括号语法、无序列表 `- `、引用、分隔线、HTML 标签;
+  **保留**全部可见文字、**有序列表序号**(顺序是内容)、链接地址(写成 `文字(地址)`)
+- `BusinessRecord` 的 `copyableContent`:先剥协议标记(原有逻辑不变),再走 `markdownToPlainText()`
+- 兜底:marked 抛错时退回一组保守的正则剥离(宁可留符号,也不能复制不出东西)
+
+**运行过的验证**:
+- 新增 `harness/tools/verify-markdown-plain-text.mjs` + `_entry-plain-text.ts`,**19/19 通过**:
+  ① 用户那段真实结构(标题/加粗/无序/有序/链接)逐项断言,含「不含任何 md 符号」的总断言;
+  ② 边界 9 例(空串/纯文本/行内代码/斜体/引用/分隔线/data URL 图片/链接文字=地址/多余空行);
+  ③ **拿真实回答跑**(那一轮的 `c_answer` 落盘到 `_real-answer.txt`,已 gitignore)——
+  无残留符号、文字没被吃掉
+- 回归:DMS 端到端 65/65、载荷字段 19/19、答案流式 165/165、企业分类 84/84、判空 30/30、聊天任务 33/33;
+  `npm run build` 通过
+
+**复制出来的实际样子**(节选真实回答):标题只剩「一、市级通用查询与申兑」,
+列表各项单独成行、有序列表保留 `1. 2.`,链接变成 `查看产品详情(https://…)`。

+ 5 - 0
harness/tools/README.md

@@ -53,6 +53,11 @@ npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
 npx esbuild harness/tools/_entry-empty-content.ts --bundle --format=esm \
   --outfile=harness/tools/_empty-content.mjs
 
+# 复制用的「markdown → 纯文本」(入口 _entry-plain-text.ts)
+npx esbuild harness/tools/_entry-plain-text.ts --bundle --format=esm \
+  --outfile=harness/tools/_plain-text.mjs --alias:@=./src --define:import.meta.env='{}'
+
+
 # 政策库判定与置顶(入口 _entry-policy-library.ts)
 npx esbuild harness/tools/_entry-policy-library.ts --bundle --format=esm \
   --outfile=harness/tools/_policy-library-utils.mjs

+ 2 - 0
harness/tools/_entry-plain-text.ts

@@ -0,0 +1,2 @@
+/** esbuild 入口:暴露「markdown → 纯文本」(复制按钮用的那份实现) */
+export { markdownToPlainText } from '../../src/components/Chat/markdown-plain-text';

+ 103 - 0
harness/tools/verify-markdown-plain-text.mjs

@@ -0,0 +1,103 @@
+/**
+ * 验证「markdown → 纯文本」(回答的复制按钮用的那份实现)。
+ *
+ * 用户 2026-09-22 反馈:复制出来的内容还带着 `###`、`**`、`[文字](链接)` 这些语法符号。
+ *
+ * 怎么跑(先打包):
+ *   npx esbuild harness/tools/_entry-plain-text.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_plain-text.mjs --alias:@=./src --define:import.meta.env='{}'
+ *   node harness/tools/verify-markdown-plain-text.mjs
+ */
+
+import { readFileSync } from 'node:fs';
+
+const { markdownToPlainText } = await import('./_plain-text.mjs');
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, extra = '') => {
+  if (ok) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+/** markdown 语法符号:这些出现在复制结果里就是没剥干净 */
+const MD_SYNTAX = [/^#{1,6}\s/m, /\*\*[^*]+\*\*/, /\[[^\]]+\]\([^)]+\)/, /^\s*[-*+]\s/m, /`[^`]+`/];
+
+const hasMdSyntax = (text) => MD_SYNTAX.find((re) => re.test(text));
+
+console.log('\n【1】用户实际复制到的那份内容的形态');
+{
+  const md = `### 一、市级通用查询与申兑
+
+**办理渠道**
+
+- “随申兑”惠企政策平台:https://zwdt.sh.gov.cn/qykj
+- “上海企业登记在线”的“中小企业公共资助项目”栏目
+
+**办理流程**
+
+1. 登录上述平台,按行业、规模等条件匹配可申报项目。
+2. 按项目要求在线提交申请。
+
+### 四、ESG惠利贷(江苏银行上海青浦支行)
+
+- 额度与期限:0-10000万元,6-12个月。
+
+[查看产品详情](https://csjsfqeqr.yddeltazwh.org.cn/financeProduct_detail?id=317)`;
+
+  const text = markdownToPlainText(md);
+  if (process.env.DBG) console.log(JSON.stringify(text));
+  check('不含任何 markdown 语法符号', !hasMdSyntax(text), JSON.stringify(text).slice(0, 160));
+  check('标题只留文字(去掉 ###)', text.includes('一、市级通用查询与申兑') && !text.includes('###'));
+  check('加粗只留文字(去掉 **)', text.includes('办理渠道') && !text.includes('**'));
+  check('无序列表去掉 `- `,各项各占一行', text.includes('\n“随申兑”惠企政策平台:https://zwdt.sh.gov.cn/qykj\n'), JSON.stringify(text.slice(0, 120)));
+  check('**有序列表保留序号**(顺序是内容,不是装饰)', text.includes('1. 登录上述平台') && text.includes('2. 按项目要求在线提交申请'));
+  check(
+    '链接写成「文字(地址)」——**地址不能丢**',
+    text.includes('查看产品详情(https://csjsfqeqr.yddeltazwh.org.cn/financeProduct_detail?id=317)'),
+    JSON.stringify(text.slice(-120))
+  );
+  check('纯文字链接保持原样', text.includes('“随申兑”惠企政策平台:https://zwdt.sh.gov.cn/qykj'));
+  check('段落之间空一行', text.includes('一、市级通用查询与申兑\n\n办理渠道'));
+}
+
+console.log('\n【2】边界');
+{
+  check('空串 → 空串', markdownToPlainText('') === '' && markdownToPlainText('   ') === '');
+  check('纯文本原样返回', markdownToPlainText('就是一句话。') === '就是一句话。');
+  check('行内代码去掉反引号', markdownToPlainText('用 `npm run dev` 启动') === '用 npm run dev 启动');
+  check('斜体去掉星号', markdownToPlainText('这是 *重点* 内容') === '这是 重点 内容');
+  check('引用去掉 > 符号', markdownToPlainText('> 引用一句') === '引用一句');
+  check('分隔线不留下垃圾', markdownToPlainText('甲\n\n---\n\n乙') === '甲\n\n乙', JSON.stringify(markdownToPlainText('甲\n\n---\n\n乙')));
+  check('data URL 图片不复制出去', !markdownToPlainText('看图 ![x](data:image/png;base64,AAAA)').includes('base64'));
+  check('链接文字与地址相同时不重复', markdownToPlainText('[https://a.cn](https://a.cn)') === 'https://a.cn');
+  check('多余空行被收敛', markdownToPlainText('甲\n\n\n\n乙') === '甲\n\n乙');
+}
+
+console.log('\n【3】拿真实回答跑一遍(DMS 里存的那一份)');
+{
+  // 真实内容:前面几轮从 DMS 读回来过的那类长回答(含标题/加粗/列表/链接)
+  const file = 'harness/tools/_real-answer.txt';
+  let md = '';
+  try {
+    md = readFileSync(file, 'utf8');
+  } catch {
+    /* 没有就跳过 */
+  }
+  if (!md.trim()) {
+    console.log(`  ⏭  没有 ${file}(把 DMS 里的 c_answer 落盘到这里即可跑这条)`);
+  } else {
+    const text = markdownToPlainText(md);
+    const hit = hasMdSyntax(text);
+    check('真实回答里没有残留的 md 语法符号', !hit, hit ? String(hit) : '');
+    check('真实回答的文字没被吃掉(长度接近)', text.length > md.length * 0.5, `${md.length} → ${text.length}`);
+  }
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 10 - 7
src/components/Chat/BusinessRecord.vue

@@ -410,6 +410,8 @@ import {
   shouldForcePendingScope,
 } from "@/utils/scope-record-rows";
 import { createModalMutex, MODAL_MUTEX_KEY } from "../business-assistant/modal-mutex";
+// 复制按钮用:把 markdown 语法符号转成纯文本(用户 2026-09-22:「复制别带 md 的符号语法」)
+import { markdownToPlainText } from "./markdown-plain-text";
 
 enum RecordRowStates {
   Pending = "pending",
@@ -588,25 +590,26 @@ enum SchedulerTaskState {
 }
 const getRefLinkItems = (content: string) => parseRefLinksContent(content);
 
-// 复制按钮只应复制可读正文:去掉 <scope>、<!-- POLICY_TABLE -->、<ref_links>、
-// <question-cards>、<image-scope>(企业微信二维码)等协议标记,
-// 避免复制出一大段 JSON 或图片地址。
+// 复制按钮只应复制可读正文:
+// ① 去掉 <scope>、<!-- POLICY_TABLE -->、<ref_links>、<question-cards>、<image-scope>
+//    (企业微信二维码)等协议标记,避免复制出一大段 JSON 或图片地址;
+// ② 再把 markdown **语法符号**转成纯文本(`###`、`**`、`[文字](链接)` 等)——
+//    用户 2026-09-22 反馈「复制出来的内容还带着 md 符号」。转换见 markdown-plain-text.ts。
 //
 // 注意不能用 getStreamPlainText():它对整段内容做一次性解析,而过滤器在遇到
 // <!-- POLICY_TABLE 时会把同批 buffer 里位于它之前的内容直接当纯文本输出,
 // 导致 scope 标记被当成正文保留下来。这里直接剥离标记,结果与内容分片方式无关。
 const copyableContent = computed(() => {
   const raw = String(record.value.content || props.content || "");
-  return raw
+  const withoutMarkers = raw
     .replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, "")
     .replace(/<ref_links\b[^>]*>[\s\S]*?<\/ref_links>\s*/gi, "")
     .replace(/<question-cards\b[^>]*>[\s\S]*?<\/question-cards>\s*/gi, "")
     .replace(/<image-scope\b[^>]*>[\s\S]*?<\/image-scope>\s*/gi, "")
     .replace(/<!--\s*POLICY_TABLE[\s\S]*?POLICY_TABLE\s*-->\s*/gi, "")
     .replace(/<\/?silence\b[^>]*>/gi, "")
-    .replace(/<\/?scope\b[^>]*>/gi, "")
-    .replace(/\n{3,}/g, "\n\n")
-    .trim();
+    .replace(/<\/?scope\b[^>]*>/gi, "");
+  return markdownToPlainText(withoutMarkers);
 });
 
 // 参考资料卡片的元信息行:发布机构 + 发布/更新时间。

+ 128 - 0
src/components/Chat/markdown-plain-text.ts

@@ -0,0 +1,128 @@
+/**
+ * markdown → **纯文本**(回答的「复制」按钮用)。
+ *
+ * 用户 2026-09-22 反馈:复制出来的内容还带着 `###`、`**`、`[文字](链接)` 这些语法符号,
+ * 粘到别处很难看。这里把它们去掉。
+ *
+ * **为什么走 marked 的 token 树、而不是渲染成 HTML 再取文本**:
+ * 取 textContent 会把**链接地址丢掉**(`[查看产品详情](https://…)` 只剩「查看产品详情」),
+ * 而复制场景里地址恰恰是要留的 —— 所以取 token 树,把链接写成 `文字(地址)`。
+ *
+ * 去掉:标题的 `#`、`**加粗**`、`*斜体*`、`` `代码` ``、`[文字](链接)` 的括号语法、
+ * 无序列表的 `- `、引用符号、分隔线、HTML 标签。
+ * 保留:全部可见文字、**有序列表的序号**(它承载步骤顺序,不是纯装饰)、
+ * 链接地址(写成 `文字(地址)`)。
+ */
+
+import { marked, type Token } from "marked";
+
+/** 行内 token → 文本 */
+const inlineToText = (tokens?: Token[]): string =>
+  (tokens || []).map(inlineTokenToText).join("");
+
+const inlineTokenToText = (token: Token): string => {
+  const t = token as Record<string, any>;
+  switch (token.type) {
+    case "text":
+      return t.tokens ? inlineToText(t.tokens) : String(t.text ?? "");
+    case "escape":
+    case "codespan":
+      return String(t.text ?? "");
+    case "strong":
+    case "em":
+    case "del":
+      return inlineToText(t.tokens) || String(t.text ?? "");
+    case "br":
+      return "\n";
+    case "link": {
+      const label = inlineToText(t.tokens) || String(t.text ?? "");
+      const href = String(t.href ?? "");
+      if (!href) return label;
+      if (!label || label === href) return href;
+      return `${label}(${href})`;
+    }
+    case "image": {
+      const href = String(t.href ?? "");
+      if (/^data:/i.test(href)) return ""; // data URL 复制出去没意义
+      return String(t.text ?? "") || href;
+    }
+    case "html":
+      return String(t.text ?? "").replace(/<[^>]*>/g, "");
+    default:
+      return String(t.text ?? "");
+  }
+};
+
+/** 块级 token → 文本(段落之间空一行) */
+const blockToText = (token: Token): string => {
+  const t = token as Record<string, any>;
+  switch (token.type) {
+    case "space":
+    case "hr":
+      return "";
+    case "heading":
+    case "paragraph":
+      return `${inlineToText(t.tokens)}\n\n`;
+    case "text":
+      return t.tokens ? `${inlineToText(t.tokens)}\n\n` : `${String(t.text ?? "")}\n\n`;
+    case "blockquote":
+      return `${(t.tokens || []).map((inner: Token) => blockToText(inner)).join("")}\n`;
+    case "list": {
+      const start = Number(t.start) || 1;
+      const lines = (t.items || []).map((item: Record<string, any>, index: number) => {
+        // 有序列表保留序号(顺序是内容);无序列表去掉 `- ` 这类装饰
+        const prefix = t.ordered ? `${start + index}. ` : "";
+        const body = (item.tokens || [])
+          .map((inner: Token) => blockToText(inner).trim())
+          .filter(Boolean)
+          .join("\n");
+        return `${prefix}${body}`;
+      });
+      return `${lines.join("\n")}\n\n`;
+    }
+    case "code":
+      return `${String(t.text ?? "")}\n\n`;
+    case "table": {
+      const rows = [t.header, ...(t.rows || [])] as Record<string, any>[][];
+      return (
+        rows
+          .map((row) => (row || []).map((cell) => inlineToText(cell?.tokens)).join("  "))
+          .join("\n") + "\n\n"
+      );
+    }
+    case "html":
+      return `${String(t.text ?? "").replace(/<[^>]*>/g, "").trim()}\n\n`;
+    default:
+      return `${String(t.text ?? "")}\n\n`;
+  }
+};
+
+/** 兜底:marked 万一抛错,至少把最常见的语法符号去掉(宁可留符号,也不能复制不出东西) */
+const fallbackStrip = (markdown: string): string =>
+  markdown
+    .replace(/^#{1,6}\s+/gm, "")
+    .replace(/\*\*([^*]+)\*\*/g, "$1")
+    .replace(/`([^`]+)`/g, "$1")
+    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1($2)")
+    .replace(/^\s*[-*+]\s+/gm, "");
+
+export const markdownToPlainText = (markdown: string): string => {
+  const source = String(markdown ?? "");
+  if (!source.trim()) return "";
+
+  let text: string;
+  try {
+    text = marked
+      .lexer(source)
+      .map((token) => blockToText(token))
+      .join("");
+  } catch (err) {
+    console.warn("[markdown-plain-text] 解析失败,退回简单剥离:", (err as Error)?.message || err);
+    text = fallbackStrip(source);
+  }
+
+  return text
+    .replace(/[ \t]+\n/g, "\n") // 行尾空白
+    .replace(/\n{3,}/g, "\n\n") // 多余空行
+    .trim();
+};