长报告不再被咬掉一口:折叠只在行边界切

用户看到的是一份读起来完整、末尾却挂着半行原始 markdown 的报告:

    userID	用户名	邮箱	昵称	providerID	商家名称	联系人邮箱
    | 1 | ThearchyHelios | user@example.com | … | Test Marchant By Shop

表头是制表符(已渲染成表格),数据行是竖线(还是原始文本)——折叠是
text.slice(0, 3000) 按字符硬切的,切点落进表格中间,前面几行渲染成表格、
最后半行没能成行。报告本身是完整的,只是被折叠了,但界面上看不出来。

改成只在行边界切;切点落进表格时整块表格都不要——宁可少显示一段,也不给
一张半截的表。另外原来只有一个"展开全部"按钮,看上去像报告就这么长,
现在先说"…后面还有,这里先折叠了"。

顺带治了落库那一处:answerText 在 4000 字处无声截断,刷新页面后那份就永久
少一截,断点同样常常落在表格中间。上限提到 20000,真切到了会写明已截断、
完整内容见运行记录。喂给下一轮的那份另有更狠的截断,两回事。

check-stream 多一节,盯的就是"折叠后不许留下没渲染的原始表格行"。
把切法改回硬切,它会立刻抓到残留的 `| 24 | user_23 | …`。
This commit is contained in:
ThearchyHelios
2026-09-18 22:29:24 +08:00
parent dcadc0c48b
commit 152264b29c
4 changed files with 105 additions and 6 deletions
+34
View File
@@ -69,6 +69,37 @@ const HOSTILE = [
'`<script>alert(1)</script>`',
].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 <div className="h-full overflow-y-auto"><MarkdownCases /></div>
if (params.get('long') === '1') {
return <AssistantStream turns={[LONG_TURN]} />
}
return (
<div className="flex h-full flex-col">
+37 -5
View File
@@ -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 (
<div>
@@ -379,10 +404,17 @@ function OutputValue({ value, dense }: { value: unknown; dense: boolean }) {
dense ? 'text-[10.5px]' : 'text-[11px]')}>{shown}</pre>
)}
{long && (
<button className="mt-1 text-[10.5px] text-[var(--accent)] hover:underline"
onClick={() => setFull((v) => !v)}>
{full ? '收起' : `展开全部(${text.length} 字)`}
</button>
<div className="mt-1 flex items-center gap-2 text-[10.5px]">
{!full && (
// 光有一个"展开"按钮不够:用户看到的是一份读起来完整的报告,
// 不会想到它下面还有。得先说"这里断了"
<span className="text-faint">…后面还有,这里先折叠了</span>
)}
<button className="text-[var(--accent)] hover:underline"
onClick={() => setFull((v) => !v)}>
{full ? '收起' : `展开全部(${text.length} 字)`}
</button>
</div>
)}
</div>
)
+10 -1
View File
@@ -101,6 +101,9 @@ const PHASE_TEXT: Record<Phase, string> = {
const EMPTY: ChatTurn[] = []
/** 落库成果的上限。只是防跑飞,不是显示上限 */
const ANSWER_CAP = 20_000
type Patch = (fn: (t: ChatTurn) => Partial<ChatTurn>) => void
/**
@@ -117,7 +120,13 @@ function answerText(output: Record<string, any> | 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<ChatState>((set, get) => ({
+24
View File
@@ -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 } })