mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:11:58 +00:00
成果区渲染 Markdown:「1+1 等于 **2**」不再原样带着星号
模型几乎总是用 Markdown 组织回答,而成果区是 whitespace-pre-wrap 的纯文本,
于是它的结构全丢了:标题、列表、表格、粗体一律变成满屏符号。
**手写而不是装库。** 依赖只有七个、全是必需的,react-markdown 那一串
(unified / mdast / micromark)比这个文件大一个数量级。而要渲染的东西是
有限的——统计了库里 252 段真实输出,出现过的构造只有:
有序列表 40% · 无序列表 15% · 粗体 13% · 标题 3% · 行内代码 3%
表格 3% · 分隔线 2% · 代码块 1% · 引用 1% · 斜体 1% · 链接 0.8%
所以只做这些,**不声称支持完整 CommonMark**。认不出的语法原样当文本显示
——那正是现在的行为,下限不会比今天差。包大小 +37KB(gzip +12KB)。
安全:全程构造 React 元素,一处 dangerouslySetInnerHTML 都没有。模型输出
是不可信内容,走 innerHTML 等于给自己开个 XSS 口子。链接只放行
http/https/mailto,`javascript:` 退回纯文本。
一个只有拿真实输出才会撞上的 bug:模型很爱写 **`table_name`**(粗体套
代码),而粗体在扫描顺序上先命中,把那对反引号当普通文本吃掉了——渲染出来
是加粗的 `v_demo_table` 带着反引号。强调内部要再解析一层(深度卡在
三层,每层文本严格更短)。另外把模块级正则的 g 标志去掉了:lastIndex 有
状态,递归调用时会从上一层留下的位置接着扫。
审批卡里的待审草稿也走同一个渲染——要人家判断"这条能不能发",却让他对着
一堆星号看,是在给审批添难度。
check-stream 补 12 条,测例是从库里导出的真实输出 + 一段构造的恶意输入
(真实输出不会自带攻击,但渲染层要么天生免疫,要么就是个口子):残留标记、
粗体/代码/列表/表格、粗体套代码、javascript: 链接不可点、正常链接可点、
原始 HTML 不变成真标签且当文本显示、注入脚本没执行。
This commit is contained in:
@@ -2,9 +2,11 @@ import { useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Database, MessageSquare } from 'lucide-react'
|
||||
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
|
||||
import { Markdown } from '../run/Markdown'
|
||||
import { decodeRun } from '../run/decode'
|
||||
import { ToastHost } from '../components/ui'
|
||||
import fixtures from '../run/__tests__/fixtures.json'
|
||||
import mdSamples from '../run/__tests__/markdown-samples.json'
|
||||
import '../index.css'
|
||||
|
||||
/**
|
||||
@@ -52,11 +54,41 @@ function buildTurns(): StreamTurn[] {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段恶意输入。这类测例必须是构造的——真实输出不会自带攻击,但模型输出
|
||||
* 是不可信内容,渲染层要么天生免疫,要么就是个 XSS 口子。
|
||||
*/
|
||||
const HOSTILE = [
|
||||
'正常文本 **加粗**。',
|
||||
'',
|
||||
'<script>window.__pwned = 1</script>',
|
||||
'<img src=x onerror="window.__pwned=1">',
|
||||
'[点我](javascript:window.__pwned=1)',
|
||||
'[正常链接](https://example.com)',
|
||||
'<iframe src="https://evil.example"></iframe>',
|
||||
'`<script>alert(1)</script>`',
|
||||
].join('\n')
|
||||
|
||||
/** Markdown 渲染用真实输出验,不用编的样本 */
|
||||
function MarkdownCases() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
{[...(mdSamples as string[]), HOSTILE].map((t, i) => (
|
||||
<div key={i} className="rounded-lg border bg-panel p-3">
|
||||
<div className="mb-2 text-[10px] text-faint">真实输出 #{i + 1}</div>
|
||||
<Markdown text={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Preview() {
|
||||
// ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现,
|
||||
// 不用靠点按钮
|
||||
const params = new URLSearchParams(location.search)
|
||||
const only = params.get('case')
|
||||
const md = params.get('md') === '1'
|
||||
if (params.get('theme') === 'light') document.documentElement.dataset.theme = 'light'
|
||||
const [all] = useState(buildTurns)
|
||||
const turns = only ? all.filter((t) => t.id === only) : all
|
||||
@@ -71,6 +103,8 @@ function Preview() {
|
||||
/>
|
||||
)
|
||||
|
||||
if (md) return <div className="h-full overflow-y-auto"><MarkdownCases /></div>
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-3 border-b px-4 py-2 text-[12px]">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Spinner } from '../components/ui'
|
||||
import {
|
||||
parseQueryResult, type ResultTable as Table, type Step, type StepKind,
|
||||
} from './decode'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
/**
|
||||
* 助手流:一轮轮「你问什么 → 它做了什么 → 结果」。
|
||||
@@ -364,14 +365,23 @@ function OutputValue({ value, dense }: { value: unknown; dense: boolean }) {
|
||||
// 不设上限的话,一次没走查询工具、直接把几千行塞进 output 的运行会
|
||||
// 把整条流卡住——列表本来就没做虚拟化
|
||||
const long = text.length > TEXT_CAP
|
||||
const shown = long && !full ? text.slice(0, TEXT_CAP) : text
|
||||
|
||||
return (
|
||||
<div className={clsx('whitespace-pre-wrap leading-relaxed',
|
||||
dense ? 'text-[11.5px]' : 'text-[12.5px]')}>
|
||||
{long && !full ? text.slice(0, TEXT_CAP) : text}
|
||||
<div>
|
||||
{/* 模型几乎总是用 Markdown 组织回答。当纯文本显示的话,「1+1 等于 **2**」
|
||||
就原样带着星号——它的结构全丢了。只有字符串才渲染:JSON.stringify
|
||||
出来的东西按 Markdown 解会被 * 和 _ 搅乱 */}
|
||||
{typeof value === 'string'
|
||||
? <Markdown text={shown} dense={dense} />
|
||||
: (
|
||||
<pre className={clsx('mono overflow-x-auto whitespace-pre-wrap leading-relaxed',
|
||||
dense ? 'text-[10.5px]' : 'text-[11px]')}>{shown}</pre>
|
||||
)}
|
||||
{long && (
|
||||
<button className="ml-1 text-[10.5px] text-[var(--accent)] hover:underline"
|
||||
<button className="mt-1 text-[10.5px] text-[var(--accent)] hover:underline"
|
||||
onClick={() => setFull((v) => !v)}>
|
||||
{full ? '收起' : `…展开全部(${text.length} 字)`}
|
||||
{full ? '收起' : `展开全部(${text.length} 字)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className={clsx('space-y-2 leading-relaxed', dense ? 'text-[11.5px]' : 'text-[12.5px]')}>
|
||||
{parseBlocks(text).map((b, i) => <Block key={i} block={b} dense={dense} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 块级
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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 (
|
||||
<div className={clsx('mt-3 font-semibold first:mt-0', size)}>
|
||||
<Inline text={block.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'hr':
|
||||
return <hr className="my-2 border-t" />
|
||||
case 'code':
|
||||
return (
|
||||
<pre className="mono overflow-x-auto rounded-lg border bg-bg px-2.5 py-2 text-[10.5px] leading-relaxed">
|
||||
<code>{block.code}</code>
|
||||
</pre>
|
||||
)
|
||||
case 'quote':
|
||||
return (
|
||||
<blockquote className="border-l-2 pl-2.5 text-dim" style={{ borderColor: 'var(--accent)' }}>
|
||||
<Inline text={block.text} />
|
||||
</blockquote>
|
||||
)
|
||||
case 'list': {
|
||||
const Tag = block.ordered ? 'ol' : 'ul'
|
||||
return (
|
||||
<Tag className={clsx('space-y-1', block.ordered ? 'list-decimal' : 'list-disc',
|
||||
'ml-4 marker:text-faint')}>
|
||||
{block.items.map((it, i) => (
|
||||
<li key={i} style={{ marginLeft: it.depth * 14 }}>
|
||||
<Inline text={it.text} />
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'table':
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className={clsx('w-full', dense ? 'text-[10.5px]' : 'text-[11px]')}>
|
||||
<thead>
|
||||
<tr className="border-b bg-elev">
|
||||
{block.head.map((h, i) => (
|
||||
<th key={i} className="px-2 py-1 text-left font-medium text-faint">
|
||||
<Inline text={h} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
{/* 用表头的列数对齐:模型偶尔会少写一格,少的补空,
|
||||
多的截掉——否则整张表会错位 */}
|
||||
{block.head.map((_, j) => (
|
||||
<td key={j} className="px-2 py-1 align-top text-dim">
|
||||
<Inline text={row[j] ?? ''} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return <p className="whitespace-pre-wrap"><Inline text={block.text} /></p>
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 行内
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// 顺序重要:行内代码最先,它里面的一切都不再当语法看;然后链接、粗体、
|
||||
// 斜体。斜体放最后且要求两侧不是 *,否则 **粗体** 会被它先拆掉。
|
||||
// 不带 g:每次用的时候按 source 新建一个带 g 的,避免 lastIndex 被共享
|
||||
const INLINE = new RegExp([
|
||||
'(`+)([^`]+?)\\1', // `code`
|
||||
'\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)', // [text](url)
|
||||
'\\*\\*([^*]+?)\\*\\*', // **bold**
|
||||
'__([^_]+?)__', // __bold__
|
||||
'(?<!\\*)\\*([^*\\n]+?)\\*(?!\\*)', // *italic*
|
||||
'~~([^~]+?)~~', // ~~strike~~
|
||||
].join('|'))
|
||||
|
||||
function Inline({ text, depth = 0 }: { text: string; depth?: number }): ReactNode {
|
||||
if (!text) return null
|
||||
const out: ReactNode[] = []
|
||||
let last = 0
|
||||
let key = 0
|
||||
// 模块级正则带 g 标志,lastIndex 是有状态的。这里会递归调用自己(粗体
|
||||
// 里可以套行内代码),不每次重置就会从上一层留下的位置接着扫
|
||||
const re = new RegExp(INLINE.source, 'g')
|
||||
|
||||
for (let m = re.exec(text); m; m = re.exec(text)) {
|
||||
if (m.index > 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 ? <Inline text={t} depth={depth + 1} /> : t
|
||||
|
||||
if (code !== undefined) {
|
||||
// 代码内部不再解析——这正是代码的含义
|
||||
out.push(
|
||||
<code key={key++} className="mono rounded bg-bg px-1 py-px text-[0.92em]">{code}</code>,
|
||||
)
|
||||
} else if (linkText !== undefined) {
|
||||
out.push(<Link key={key++} href={href} text={linkText} />)
|
||||
} else if (bold1 !== undefined || bold2 !== undefined) {
|
||||
out.push(<strong key={key++} className="font-semibold">{sub(bold1 ?? bold2)}</strong>)
|
||||
} else if (italic !== undefined) {
|
||||
out.push(<em key={key++}>{sub(italic)}</em>)
|
||||
} else if (strike !== undefined) {
|
||||
out.push(<s key={key++} className="text-faint">{sub(strike)}</s>)
|
||||
}
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last))
|
||||
return <>{out.map((n, i) => <Fragment key={i}>{n}</Fragment>)}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* 链接。
|
||||
*
|
||||
* 只放行 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 (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2"
|
||||
style={{ color: 'var(--accent)' }}>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -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 }: {
|
||||
</div>
|
||||
|
||||
{payload.message && (
|
||||
<div className="mb-2 max-h-40 overflow-y-auto whitespace-pre-wrap rounded border bg-bg p-2 text-[11px] leading-relaxed">
|
||||
{payload.message}
|
||||
// 待审的往往是一整段模型写的文案,同样是 Markdown。要人家判断"能不能
|
||||
// 发",却让他对着一堆星号看,是在给审批这件事添难度
|
||||
<div className="mb-2 max-h-40 overflow-y-auto rounded border bg-bg p-2">
|
||||
<Markdown text={String(payload.message)} dense />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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` 作为\"有数据\"的代表。)"
|
||||
]
|
||||
@@ -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}\*\*|(?<!\w)`[^`\n]{1,40}`/g)].map((m) => 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('<script>'))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
console.log('\n=== 空态 ===')
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
Reference in New Issue
Block a user