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 } })