diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index 021f6d98..4ad50408 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -69,6 +69,37 @@ const HOSTILE = [ '``', ].join('\n') +/** + * 一份会超出折叠上限的报告,表格正好横跨切点。 + * + * 之前折叠是按字符硬切的,切点落进表格中间——前面几行渲染成表格、最后半行 + * 留成原始的 `| 1 | ThearchyHelios | …`。用户看到的是一份被咬掉一口的报告, + * 而它其实是完整的,只是被折叠了。 + */ +function longReport(): string { + const head = [ + "## 总结", "", + "- **管理员(平台级/区域级角色)**:共 9 人", "- **商家账户**:共 5 个", "", + "口径说明:".padEnd(400, "口"), "", + "## 一、管理员名单", "", + ].join("\n") + const rows = Array.from({ length: 40 }, (_, i) => + `| ${i + 1} | user_${i} | user${i}@example.com | 昵称${i} | ` + + `85e11976-6a7b-4167-b561-deec4fd${String(i).padStart(4, "0")} | 商家 ${i} | c${i}@example.com |`) + return [ + head, + "| userID | 用户名 | 邮箱 | 昵称 | providerID | 商家名称 | 联系人邮箱 |", + "| --- | --- | --- | --- | --- | --- | --- |", + ...rows, + "", "> 说明:部分账户身兼管理员与商家双重身份。", + ].join("\n") +} + +const LONG_TURN: StreamTurn = { + id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成", + steps: [], output: { result: longReport() }, +} + /** Markdown 渲染用真实输出验,不用编的样本 */ function MarkdownCases() { return ( @@ -104,6 +135,9 @@ function Preview() { ) if (md) return
+ if (params.get('long') === '1') { + return + } return (
diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index 9c0a3f9c..1e6162d5 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -333,6 +333,31 @@ function Output({ output, dense, runClass }: { const TEXT_CAP = 3000 +/** + * 折叠长文本的切点。 + * + * 不能按字符硬切:模型的报告几乎总是 Markdown,切点一旦落在表格中间, + * 前面的行渲染成表格、最后半行留成原始的 `| 1 | ThearchyHelios | …`, + * 看上去就是一份被咬掉一口的报告——而它其实是完整的,只是被折叠了。 + * + * 所以只在行边界切;而且切点落进表格里时整块表格都不要,宁可少显示一段, + * 也不要显示一张半截的表。 + */ +function cutForPreview(text: string, cap: number): string { + const head = text.slice(0, cap) + const atLine = head.lastIndexOf('\n') + let cut = atLine > cap * 0.5 ? head.slice(0, atLine) : head + + // 收尾的连续表格行(含分隔行)一起去掉——只剩表头的表格比没有更难读 + const lines = cut.split('\n') + while (lines.length && /^\s*\|/.test(lines[lines.length - 1])) lines.pop() + const withoutTable = lines.join('\n') + // 整段都是表格时别把内容清空,那还不如原样折叠 + if (withoutTable.trim()) cut = withoutTable + + return cut.trimEnd() +} + function isBlank(v: unknown): boolean { if (v == null) return true if (typeof v === 'string') return !v.trim() @@ -365,7 +390,7 @@ 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 + const shown = long && !full ? cutForPreview(text, TEXT_CAP) : text return (
@@ -379,10 +404,17 @@ function OutputValue({ value, dense }: { value: unknown; dense: boolean }) { dense ? 'text-[10.5px]' : 'text-[11px]')}>{shown} )} {long && ( - +
+ {!full && ( + // 光有一个"展开"按钮不够:用户看到的是一份读起来完整的报告, + // 不会想到它下面还有。得先说"这里断了" + …后面还有,这里先折叠了 + )} + +
)}
) diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index abb5be47..638db198 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -101,6 +101,9 @@ const PHASE_TEXT: Record = { const EMPTY: ChatTurn[] = [] +/** 落库成果的上限。只是防跑飞,不是显示上限 */ +const ANSWER_CAP = 20_000 + type Patch = (fn: (t: ChatTurn) => Partial) => void /** @@ -117,7 +120,13 @@ function answerText(output: Record | null): string { if (key.startsWith('_') || value == null || value === '') continue parts.push(typeof value === 'string' ? value : JSON.stringify(value)) } - return parts.join('\n').slice(0, 4000) + // 落库的是完整报告。之前在 4000 字处无声切断,刷新页面后那份就永久少一截, + // 而且断点常常落在表格中间。上限只是防跑飞的护栏,真切到了必须说出来—— + // 喂给下一轮的那份另有更狠的截断(HISTORY_ANSWER_CHARS),两回事 + const text = parts.join('\n') + return text.length > ANSWER_CAP + ? text.slice(0, ANSWER_CAP) + `\n\n…(成果过长,已截断,完整内容见运行记录)` + : text } export const useChat = create((set, get) => ({ diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index d11753c5..f0fe52e4 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -115,6 +115,30 @@ console.log('\n=== Markdown 渲染 ===') await page.close() } +console.log('\n=== 长报告的折叠 ===') +{ + // 折叠以前是按字符硬切的,切点落进表格中间:前面几行渲染成表格、最后半行 + // 留成原始的 `| 1 | ThearchyHelios | …`。用户看到的是一份被咬掉一口的报告。 + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }) + await page.goto(`${WEB}/preview.html?long=1`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(300) + + const folded = await page.locator('body').innerText() + check('折叠时说破了"后面还有"', folded.includes('后面还有')) + check('给得出展开入口', folded.includes('展开全部')) + // 关键一条:折叠后不能留下没渲染成表格的原始 markdown 行 + const rawRow = folded.split('\n').find((l) => /^\s*\|.*\|/.test(l)) + check('没有半截的原始表格行', !rawRow, rawRow ? `残留:${rawRow.slice(0, 48)}` : '') + check('开头的正文还在', folded.includes('总结')) + + await page.locator('button', { hasText: '展开全部' }).first().click() + await page.waitForTimeout(250) + const full = await page.locator('body').innerText() + check('展开后拿得到结尾', full.includes('身兼管理员与商家双重身份')) + check('展开后表格渲染完整', full.includes('user_39')) + await page.close() +} + console.log('\n=== 空态 ===') { const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })