- {long && !full ? text.slice(0, TEXT_CAP) : text}
+
+ {/* 模型几乎总是用 Markdown 组织回答。当纯文本显示的话,「1+1 等于 **2**」
+ 就原样带着星号——它的结构全丢了。只有字符串才渲染:JSON.stringify
+ 出来的东西按 Markdown 解会被 * 和 _ 搅乱 */}
+ {typeof value === 'string'
+ ?
+ : (
+
{shown}
+ )}
{long && (
-
)}
diff --git a/frontend/src/run/Markdown.tsx b/frontend/src/run/Markdown.tsx
new file mode 100644
index 00000000..a4ec5b12
--- /dev/null
+++ b/frontend/src/run/Markdown.tsx
@@ -0,0 +1,294 @@
+import { Fragment, type ReactNode } from 'react'
+import clsx from 'clsx'
+
+/**
+ * 模型输出的 Markdown 渲染。
+ *
+ * 在此之前成果区是 whitespace-pre-wrap 的纯文本,于是「1+1 等于 **2**」就
+ * 原样带着星号显示——模型几乎总是用 Markdown 组织回答,不渲染等于把它的
+ * 结构全丢了。
+ *
+ * **手写而不是装库。** 依赖只有七个,全是必需的;react-markdown 那一串
+ * (unified / mdast / micromark)比这个文件大一个数量级,而实际要渲染的
+ * 东西是有限的——统计了库里 252 段真实输出,出现过的构造只有下面这些:
+ *
+ * 有序列表 40% · 无序列表 15% · 粗体 13% · 标题 3% · 行内代码 3%
+ * 表格 3% · 分隔线 2% · 代码块 1% · 引用 1% · 斜体 1% · 链接 0.8%
+ *
+ * 所以这里只做这些,**不声称支持完整 CommonMark**。认不出来的语法原样当
+ * 文本显示——那正是现在的行为,所以下限不会比今天差;能认出来的就渲染对。
+ *
+ * 全程构造 React 元素,一处 dangerouslySetInnerHTML 都没有。模型输出是不
+ * 可信内容,走 innerHTML 就等于给自己开了个 XSS 口子。
+ */
+export function Markdown({ text, dense }: { text: string; dense?: boolean }) {
+ if (!text?.trim()) return null
+ return (
+
+ {parseBlocks(text).map((b, i) => )}
+
+ )
+}
+
+// -------------------------------------------------------------------------
+// 块级
+// -------------------------------------------------------------------------
+
+type Block =
+ | { kind: 'p'; text: string }
+ | { kind: 'h'; level: number; text: string }
+ | { kind: 'code'; lang: string; code: string }
+ | { kind: 'quote'; text: string }
+ | { kind: 'hr' }
+ | { kind: 'list'; ordered: boolean; items: { text: string; depth: number }[] }
+ | { kind: 'table'; head: string[]; rows: string[][] }
+
+const FENCE = /^\s*```(\w*)\s*$/
+const HEADING = /^(#{1,6})\s+(.*)$/
+const HR = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/
+const BULLET = /^(\s*)[-*+]\s+(.*)$/
+const ORDERED = /^(\s*)\d+[.)]\s+(.*)$/
+const QUOTE = /^\s*>\s?(.*)$/
+const TABLE_ROW = /^\s*\|(.+)\|\s*$/
+const TABLE_SEP = /^\s*\|[\s:|-]+\|\s*$/
+
+function parseBlocks(text: string): Block[] {
+ const lines = text.replace(/\r\n?/g, '\n').split('\n')
+ const out: Block[] = []
+ let i = 0
+
+ while (i < lines.length) {
+ const line = lines[i]
+
+ if (!line.trim()) { i += 1; continue }
+
+ // 代码块优先:里面的一切都不当语法看
+ const fence = line.match(FENCE)
+ if (fence) {
+ const body: string[] = []
+ i += 1
+ while (i < lines.length && !FENCE.test(lines[i])) { body.push(lines[i]); i += 1 }
+ i += 1 // 吃掉收尾的 ```
+ out.push({ kind: 'code', lang: fence[1] || '', code: body.join('\n') })
+ continue
+ }
+
+ // 分隔线要在列表之前判:--- 也能匹配 BULLET
+ if (HR.test(line)) { out.push({ kind: 'hr' }); i += 1; continue }
+
+ const heading = line.match(HEADING)
+ if (heading) {
+ out.push({ kind: 'h', level: heading[1].length, text: heading[2] })
+ i += 1
+ continue
+ }
+
+ // 表格:一行 | ... | 后面紧跟一行分隔行,两者都有才算
+ if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
+ const head = splitRow(line)
+ const rows: string[][] = []
+ i += 2
+ while (i < lines.length && TABLE_ROW.test(lines[i])) { rows.push(splitRow(lines[i])); i += 1 }
+ out.push({ kind: 'table', head, rows })
+ continue
+ }
+
+ const quote = line.match(QUOTE)
+ if (quote) {
+ const body = [quote[1]]
+ i += 1
+ while (i < lines.length && QUOTE.test(lines[i])) {
+ body.push(lines[i].match(QUOTE)![1]); i += 1
+ }
+ out.push({ kind: 'quote', text: body.join('\n') })
+ continue
+ }
+
+ const bullet = line.match(BULLET)
+ const ordered = line.match(ORDERED)
+ if (bullet || ordered) {
+ // 一段连续的列表算一块。有序和无序混排时按第一条定性——模型不常这么写,
+ // 真遇上了也比拆成两块好看
+ const isOrdered = !!ordered
+ const items: { text: string; depth: number }[] = []
+ while (i < lines.length) {
+ const m = lines[i].match(BULLET) || lines[i].match(ORDERED)
+ if (m) {
+ items.push({ text: m[2], depth: Math.floor(m[1].replace(/\t/g, ' ').length / 2) })
+ i += 1
+ } else if (lines[i].trim() && !isBlockStart(lines[i]) && items.length) {
+ // 悬挂缩进的续行接到上一条上,不要另起一段
+ items[items.length - 1].text += '\n' + lines[i].trim()
+ i += 1
+ } else break
+ }
+ out.push({ kind: 'list', ordered: isOrdered, items })
+ continue
+ }
+
+ // 普通段落:一直吃到空行或下一个块开头
+ const para = [line]
+ i += 1
+ while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) {
+ para.push(lines[i]); i += 1
+ }
+ out.push({ kind: 'p', text: para.join('\n') })
+ }
+ return out
+}
+
+function isBlockStart(line: string): boolean {
+ return FENCE.test(line) || HR.test(line) || HEADING.test(line)
+ || BULLET.test(line) || ORDERED.test(line) || QUOTE.test(line) || TABLE_ROW.test(line)
+}
+
+function splitRow(line: string): string[] {
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim())
+}
+
+function Block({ block, dense }: { block: Block; dense?: boolean }) {
+ switch (block.kind) {
+ case 'h': {
+ // 侧栏只有 360px 宽,一级标题按 h1 的字号会大得不像话。
+ // 按层级递减但整体压扁,保住层次感就够了
+ const size = ['text-[14px]', 'text-[13px]', 'text-[12.5px]'][Math.min(block.level, 3) - 1]
+ return (
+
+
+
+ )
+ }
+ case 'hr':
+ return
+ case 'code':
+ return (
+
+ {block.code}
+
+ )
+ case 'quote':
+ return (
+
+
+
+ )
+ case 'list': {
+ const Tag = block.ordered ? 'ol' : 'ul'
+ return (
+
+ {block.items.map((it, i) => (
+
+
+
+ ))}
+
+ )
+ }
+ case 'table':
+ return (
+
+
+
+
+ {block.head.map((h, i) => (
+ |
+
+ |
+ ))}
+
+
+
+ {block.rows.map((row, i) => (
+
+ {/* 用表头的列数对齐:模型偶尔会少写一格,少的补空,
+ 多的截掉——否则整张表会错位 */}
+ {block.head.map((_, j) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+ default:
+ return
+ }
+}
+
+// -------------------------------------------------------------------------
+// 行内
+// -------------------------------------------------------------------------
+
+// 顺序重要:行内代码最先,它里面的一切都不再当语法看;然后链接、粗体、
+// 斜体。斜体放最后且要求两侧不是 *,否则 **粗体** 会被它先拆掉。
+// 不带 g:每次用的时候按 source 新建一个带 g 的,避免 lastIndex 被共享
+const INLINE = new RegExp([
+ '(`+)([^`]+?)\\1', // `code`
+ '\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)', // [text](url)
+ '\\*\\*([^*]+?)\\*\\*', // **bold**
+ '__([^_]+?)__', // __bold__
+ '(? last) out.push(text.slice(last, m.index))
+ const [, , code, linkText, href, bold1, bold2, italic, strike] = m
+
+ // 强调的内容要再解析一层。模型很爱写 **`table_name`**(粗体里套代码),
+ // 而粗体在扫描顺序上先命中,不递归的话那对反引号就原样显示出来了。
+ // 深度有限:每层的文本都严格更短,且这里也卡了三层
+ const sub = (t: string) =>
+ depth < 3 ?
: t
+
+ if (code !== undefined) {
+ // 代码内部不再解析——这正是代码的含义
+ out.push(
+
{code},
+ )
+ } else if (linkText !== undefined) {
+ out.push(
)
+ } else if (bold1 !== undefined || bold2 !== undefined) {
+ out.push(
{sub(bold1 ?? bold2)})
+ } else if (italic !== undefined) {
+ out.push(
{sub(italic)})
+ } else if (strike !== undefined) {
+ out.push(
{sub(strike)})
+ }
+ last = m.index + m[0].length
+ }
+ if (last < text.length) out.push(text.slice(last))
+ return <>{out.map((n, i) =>
{n})}>
+}
+
+/**
+ * 链接。
+ *
+ * 只放行 http/https/mailto。模型输出是不可信内容,`javascript:` 开头的
+ * href 点一下就是脚本执行——这里不认的协议就退回纯文本显示,宁可少一个
+ * 可点链接,也不留这个口子。
+ */
+function Link({ href, text }: { href: string; text: string }) {
+ const safe = /^(https?:|mailto:)/i.test(href.trim())
+ if (!safe) return <>{text}>
+ return (
+
+ {text}
+
+ )
+}
diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx
index a7474609..0ce5f98c 100644
--- a/frontend/src/run/RunPanel.tsx
+++ b/frontend/src/run/RunPanel.tsx
@@ -5,6 +5,7 @@ import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs'
import { StatusDot, useToast } from '../components/ui'
+import { Markdown } from './Markdown'
// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
// 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的
@@ -69,8 +70,10 @@ export function ApprovalCard({ approval, onResolved }: {
{payload.message && (
-
- {payload.message}
+ // 待审的往往是一整段模型写的文案,同样是 Markdown。要人家判断"能不能
+ // 发",却让他对着一堆星号看,是在给审批这件事添难度
+
+
)}
diff --git a/frontend/src/run/__tests__/markdown-samples.json b/frontend/src/run/__tests__/markdown-samples.json
new file mode 100644
index 00000000..7ea1b622
--- /dev/null
+++ b/frontend/src/run/__tests__/markdown-samples.json
@@ -0,0 +1,6 @@
+[
+ "已完成查询。下面是结论。\n\n## 一、这个库里大致有哪些类型的数据\n\n这个公共自行车数仓(ANALYTICS schema,共 41 张表/视图)按业务域大致可分为以下几类:\n\n1. **骑行事实类**(`v_trip_fact`、`v_trip_fact_hist`、`v_trip_anomaly`)——单次骑行的起讫站点、时长、里程、计费与异常标记。\n2. **站点与车辆主数据**(`v_station_info`、`v_station_capacity`、`v_bike_master`、`v_lock_status`)——站点坐标、桩位数、车辆型号与锁具状态。\n3. **调度运维类**(`v_dispatch_task`、`v_dispatch_exec`、`v_maint_order`)——调度工单、执行记录、维修派单。\n4. **会员与计费类**(`v_member_profile`、`v_card_bind`、`v_billing_detail`)——会员档案、卡绑定、扣费明细。\n5. **其他基础/运维类**(`etl_audit`、`v_dict_region`、`dq_check_log` 等)。\n\n## 二、最大的一张表:`ANALYTICS.v_trip_fact`\n\n通过逐张 `COUNT(*)` 实测,已确认的最大表是骑行事实表:\n\n- **行数**:约 **1,031,592 行**(103 万+),明显超过第二大的 `v_billing_detail`(约 81 万行)和 `v_dispatch_exec`(约 50 万行)。\n- **视图含义**:单次骑行的明细事实,一行即一次完整骑行。\n\n### 字段结构\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| trip_id | VARCHAR(32) | 骑行流水号(非空) |\n| bike_code | VARCHAR(20) | 车辆编号 |\n| start_station | VARCHAR(100) | 起点站点编码(非空) |\n| end_station | VARCHAR(100) | 终点站点编码 |\n| start_time | DATE | 开锁时间(非空) |\n| duration_min | INTEGER | 骑行时长(分钟) |\n| fee | NUMBER | 计费金额 |\n| load_time | DATE | 加载时间 |\n\n### 这是一个典型的事实表(Fact Table)\n\n- **维度**:车辆、起点站、终点站、时间——四维组合。\n- **度量**:`duration_min`、`fee`。\n- **粒度**:一条记录 = 一次骑行,103 万行正是这些维度组合展开后的明细量级。\n\n### 能支持什么分析\n\n- 骑行量按**站点、时段、车型**多维度的趋势分析;\n- 潮汐分析:起讫站点配对,识别早晚高峰的净流入/净流出站点;\n- 按站点或区域做调度需求测算,支撑车辆投放与调度计划;\n- 与站点主数据(`v_station_info`、`v_station_capacity`)关联,补充坐标与桩位属性做更细的分析。\n\n> 补充说明:`v_billing_detail`(计费明细)的 `COUNT(*)` 查询因超过 30 秒被中断,未能拿到精确行数,从业务上看它也可能很大;但在已成功实测的对象中,**`v_trip_fact`(约 103 万行)是行数最大的表**。",
+ "我看了你提供的“多智能体研究结果”,发现它并不是实际的研究数据或整合后的观点,而是一段**任务接收与流程说明**(包括检索方式、整合方法、输出框架,以及“请把问题发我”的结尾)。\n\n因此,我暂时无法基于这段内容生成一个实质性的、面向用户的最终答案——因为里面没有可供提炼的事实、观点、共识或分歧。\n\n### 我需要你补充以下任一信息,才能继续:\n\n1. **实际研究结果** \n 例如多智能体检索到的关键事实、数据、来源链接、不同观点摘要、争议点等。 \n 把这些内容粘贴给我,我会按你要求的框架整理成最终答案。\n\n2. **明确要回答的问题** \n 如果你希望我直接回答类似「大模型推理成本为何下降这么快?」「2025年哪些行业最适合AI落地?」「如何评估Agent框架的选型?」这类问题,请指定一个或多个,我可以基于已有知识按框架输出,但需要说明这不是基于你提供的“研究结果”。\n\n3. **确认是否粘贴有误** \n 如果这段文字就是你希望我处理的全部内容,可能你原本想粘贴的研究材料没有成功发送。请重新复制实际结果后再发一次。\n\n---\n\n只要你补齐材料或明确需求,我会立刻按「问题拆解 → 核心结论 → 分论点论证 → 争议/不确定点 → 待补充信息」的结构交付完整答案。",
+ "**改写后:**\n\n「今天骑到哪了」今日上线。\n\n有些记录很短,却足以标记一段路程。\n你骑过的街,我们记着;你想回看,随时可查。\n\n在这里,留下你的轨迹,让每一程都有去处。\n\n主要调整:去掉\"运动伙伴\"\"智能陪伴\"等偏功能化、口语化的表达,换用更克制、留白的说法,整体节奏更短,语感更安静。",
+ "这个库里调度运维相关的数据视图(共 12 张,另有对应的 `hist_` 历史视图):\n\n| 视图 | 说明 |\n|---|---|\n| `v_dispatch_plan` | 班次调度计划表 |\n| `v_dispatch_task` | 调度工单 |\n| `v_dispatch_exec` | 调度执行记录 |\n| `v_dispatch_crew` | 调度人员工时明细 |\n| `v_maint_order` | 维修派单 |\n| `v_maint_fault` | 故障明细 |\n| `v_lock_status` | 锁具状态快照 |\n| `v_bike_health` | 车辆健康评分 |\n| `v_region_quota` | 区域投放配额 |\n| `v_dispatch_hist_*` | 上述各表的历史版本 |\n\n## 挑一个有数据的视图:`ANALYTICS.v_maint_fault`(故障明细表)\n\n- **行数:365,633 行**\n- **数据加载时间**:最近一次 `load_time` 为 **2026-09-17 07:23:54**\n- **业务发生时间**:表内最新的故障上报时间 `report_time` 为 **2026-09-17 06:53**\n\n也就是说,这份数据不仅量大(36.5 万+行),而且很新——最新一条故障上报发生在 2026-09-17 上午 06:53,几乎就是数据集成的时点附近。\n\n(对比:另外两张测过的 `v_dispatch_crew` 只有 246 行、`v_dispatch_plan` 只有 43 行,明显数据量少,所以选了 `v_maint_fault` 作为\"有数据\"的代表。)"
+]
diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs
index 457aba45..d11753c5 100644
--- a/scripts/check-stream.mjs
+++ b/scripts/check-stream.mjs
@@ -65,6 +65,56 @@ console.log('\n=== 展开交互 ===')
await page.close()
}
+console.log('\n=== Markdown 渲染 ===')
+{
+ // 用库里导出的**真实输出**,不是编的样本:模型写什么才是要渲染的,
+ // 按 CommonMark 规范挑测例只会测到用不上的角落
+ const page = await browser.newPage({ viewport: { width: 1100, height: 900 } })
+ const errors = []
+ page.on('pageerror', (e) => errors.push(e.message))
+ await page.goto(`${WEB}/preview.html?md=1`, { waitUntil: 'networkidle' })
+ await page.waitForTimeout(600)
+ check('没有运行时报错', errors.length === 0, errors.join(' | '))
+
+ const text = await page.locator('body').innerText()
+ // 「1+1 等于 **2**」原样带着星号显示——这就是改之前的样子
+ const stray = [...text.matchAll(/\*\*[^*\n]{1,40}\*\*|(? m[0])
+ check('没有残留的 markdown 标记', stray.length === 0, stray.slice(0, 4).join(' '))
+
+ const el = await page.evaluate(() => ({
+ strong: document.querySelectorAll('strong').length,
+ code: document.querySelectorAll('code').length,
+ li: document.querySelectorAll('li').length,
+ table: document.querySelectorAll('table').length,
+ quote: document.querySelectorAll('blockquote').length,
+ // 模型很爱写 **`table_name`**。粗体在扫描顺序上先命中,强调内部不再
+ // 解析的话,那对反引号会原样显示出来
+ codeInStrong: document.querySelectorAll('strong code').length,
+ // 模型输出不可信,链接必须挡住 javascript:
+ badHref: [...document.querySelectorAll('a')]
+ .filter((a) => !/^(https?:|mailto:)/i.test(a.getAttribute('href') || '')).length,
+ // 只数 markdown 渲染出来的,不数整页——dev server 自己就注入 HMR 脚本
+ rawHtml: [...document.querySelectorAll('main, .space-y-2')]
+ .reduce((n, el) => n + el.querySelectorAll('script,iframe,object,embed,img').length, 0),
+ goodHref: [...document.querySelectorAll('a')]
+ .filter((a) => /^https?:/i.test(a.getAttribute('href') || '')).length,
+ pwned: window.__pwned === 1,
+ }))
+ check('粗体渲染出来了', el.strong > 0, `${el.strong} 处`)
+ check('行内代码渲染出来了', el.code > 0, `${el.code} 处`)
+ check('列表渲染出来了', el.li > 0, `${el.li} 条`)
+ check('表格渲染出来了', el.table > 0, `${el.table} 张`)
+ check('粗体里的代码也解析', el.codeInStrong > 0, `${el.codeInStrong} 处`)
+ // 样本里混了一段构造的恶意输入(真实输出不会自带攻击,但模型输出是不可信
+ // 内容,渲染层要么天生免疫,要么就是个 XSS 口子)
+ check('javascript: 链接没被渲染成可点的', el.badHref === 0, `${el.badHref} 个`)
+ check('正常链接照常可点', el.goodHref > 0, `${el.goodHref} 个`)
+ check('原始 HTML 没有变成真标签', el.rawHtml === 0, `${el.rawHtml} 个`)
+ check('注入的脚本没有执行', el.pwned === false)
+ check('原始 HTML 当文本显示出来了', text.includes('