Skip to content

Commit 135ed5b

Browse files
committed
工作台正文版面精简与流式渲染性能:阶段正文按「精华上屏、细节收纳」四规则重排——结论条只放第一句悬停见全文、表格字段清单渲染成字段名徽标、长句列表与策略段按行数截断点击展开收起(渲染后量高摘掉未溢出条目的截断态)、纯演示子分页在真实数据到达后隐藏不拿假数据示人;新增 stream-render 流式渲染调度器(渲染按节奏合并至多每 160ms 一次+markdown 离屏渲染按块级前缀对比增量上屏,已排版公式原样保留,解决长回复每增量整段重建的卡顿),实时生成区打字机逐字匀速上屏;工作台控制器、agent/home 对话与样式配套调整,英文词典补新增文案
1 parent 28dc49d commit 135ed5b

10 files changed

Lines changed: 1338 additions & 150 deletions

File tree

apps/web/src/attachments/attachments.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545
cursor: pointer;
4646
}
4747

48+
/* 折叠头是 <button>,会吃到全局 button:hover 的浅灰底;这里明确按住透明,
49+
悬停只靠回形针↔箭头的图标切换表达,不出现灰色背景盒子。 */
50+
.composer-attachments-head:hover,
51+
html[data-theme="dark"] .composer-attachments-head:hover {
52+
background: transparent;
53+
border-color: transparent;
54+
}
55+
4856
.composer-attachments-head-icon {
4957
position: relative;
5058
flex: none;

apps/web/src/i18n/en-US.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ const TASK_RUNNING: Record<string, string> = {
366366
"正在生成回复": "Generating the reply",
367367
"已生成回复": "Reply generated",
368368
"回复生成中断": "Reply interrupted",
369+
"暂停生成": "Stop generating",
370+
"已暂停生成": "Generation stopped",
371+
"已暂停生成。": "Generation stopped.",
372+
"已暂停(保留部分回复)": "Stopped (partial reply kept)",
369373
"执行计划": "To-dos",
370374
"展开或收起执行计划": "Toggle the plan",
371375
"复制回复": "Copy reply",
@@ -522,6 +526,15 @@ const DATA_STAGE: Record<string, string> = {
522526
"汇总": "Summary",
523527
"字段说明": "Field guide",
524528
"字段、类型、单位与质量状态": "Fields, types, units and quality status",
529+
"来自数据准备阶段的真实字段清单": "Real field list from the data preparation stage",
530+
"说明": "Description",
531+
"个字段": "fields",
532+
"所属数据集": "Dataset",
533+
"完整字段清单见「字段说明」页": "See the Field guide tab for the full list",
534+
"点击展开或收起全文": "Click to expand or collapse",
535+
"来自方案": "From plan",
536+
"步": "steps",
537+
"按执行顺序": "In execution order",
525538
"18 个字段": "18 fields",
526539
"核心建模字段已完成类型和单位校验。":
527540
"Types and units are verified for all core modeling fields.",

apps/web/src/integration/agent-chat.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface ChatMeta {
3939
usage?: { prompt_tokens?: number; completion_tokens?: number };
4040
elapsed_ms?: number;
4141
route?: ChatRouteMeta | null;
42+
/** 用户中途暂停、已收到的部分按完整回复收尾时为 true。 */
43+
stopped?: boolean;
4244
}
4345

4446
export interface ChatHandlers {
@@ -228,6 +230,9 @@ export interface ChatTurnOptions {
228230
images?: ChatImagePayload[];
229231
/** 携图时钉住的接口 id:绕过 Auto 难度路由,确保图片落在视觉模型上。 */
230232
pinEndpointId?: string;
233+
/** 暂停生成:中止后已收到的部分回复按完整回复处理;一字未收则抛
234+
* GENERATION_STOPPED,由调用方安静收尾(不按错误渲染)。 */
235+
signal?: AbortSignal;
231236
}
232237

233238
/**
@@ -266,6 +271,7 @@ export async function sendConversationTurn(
266271
response = await fetch("/api/chat", {
267272
method: "POST",
268273
credentials: "same-origin",
274+
signal: options.signal,
269275
headers: { "Content-Type": "application/json", Accept: "text/event-stream, application/json" },
270276
body: JSON.stringify({
271277
messages: [...history],
@@ -274,8 +280,11 @@ export async function sendConversationTurn(
274280
...(images ? { images } : {}),
275281
}),
276282
});
277-
} catch {
283+
} catch (error) {
278284
history.pop();
285+
if (error instanceof DOMException && error.name === "AbortError") {
286+
throw new ChatError("GENERATION_STOPPED", "已暂停生成");
287+
}
279288
throw new ChatError("NETWORK_ERROR", "无法连接服务,请确认后端已启动");
280289
}
281290
if (!response.ok) {
@@ -293,8 +302,20 @@ export async function sendConversationTurn(
293302
const decoder = new TextDecoder();
294303
let buffer = "";
295304
let failure: ChatError | null = null;
305+
let stopped = false;
296306
for (;;) {
297-
const { done, value } = await reader.read();
307+
let done: boolean;
308+
let value: Uint8Array | undefined;
309+
try {
310+
({ done, value } = await reader.read());
311+
} catch (error) {
312+
// 用户点了暂停:已收到的部分按完整回复处理;一字未收按停止收尾
313+
if (error instanceof DOMException && error.name === "AbortError") {
314+
stopped = true;
315+
break;
316+
}
317+
throw error;
318+
}
298319
if (value) buffer += decoder.decode(value, { stream: true });
299320
const { events, rest } = parseSseChunk(done ? `${buffer}\n\n` : buffer);
300321
buffer = done ? "" : rest;
@@ -318,12 +339,26 @@ export async function sendConversationTurn(
318339
}
319340
if (done) break;
320341
}
342+
if (stopped && !full) {
343+
history.pop();
344+
throw new ChatError("GENERATION_STOPPED", "已暂停生成");
345+
}
346+
if (stopped) meta.stopped = true;
321347
if (failure && !full) {
322348
history.pop();
323349
throw failure;
324350
}
325351
} else {
326-
const payload = (await response.json()) as ChatMeta & { reply?: string; reasoning?: string };
352+
let payload: ChatMeta & { reply?: string; reasoning?: string };
353+
try {
354+
payload = (await response.json()) as ChatMeta & { reply?: string; reasoning?: string };
355+
} catch (error) {
356+
history.pop();
357+
if (error instanceof DOMException && error.name === "AbortError") {
358+
throw new ChatError("GENERATION_STOPPED", "已暂停生成");
359+
}
360+
throw new ChatError("CHAT_FAILED", "对话响应解析失败,请稍后再试");
361+
}
327362
full = payload.reply ?? "";
328363
reasoning = payload.reasoning ?? "";
329364
Object.assign(meta, payload);

apps/web/src/integration/home-chat.ts

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313

1414
import { openAuthDialog } from "../auth/auth-dialog";
1515
import { t } from "../i18n/locale";
16-
import { renderMarkdown } from "../text/markdown";
17-
import { typesetMath } from "../text/math-typeset";
16+
import { createStreamingMarkdownRenderer, createThrottledTextSink } from "../text/stream-render";
1817
import { ChatError, sendConversationTurn } from "./agent-chat";
1918

2019
const AGENT_ID_HTML =
@@ -105,15 +104,20 @@ function createThinkingBlock(replyBlock: HTMLElement): {
105104
applyOpen();
106105
if (open) viewport.scrollTop = 0;
107106
});
107+
// 文本赋值与布局读写按节流节奏合并:高频 reasoning 增量不再逐条触发重排
108+
const sink = createThrottledTextSink(fullText => {
109+
stream.textContent = fullText;
110+
viewport.classList.toggle("is-capped", viewport.scrollHeight > viewport.clientHeight + 1);
111+
viewport.scrollTop = viewport.scrollHeight;
112+
});
108113
return {
109114
append(fullText: string): void {
110-
stream.textContent = fullText;
111-
viewport.classList.toggle("is-capped", viewport.scrollHeight > viewport.clientHeight + 1);
112-
viewport.scrollTop = viewport.scrollHeight;
115+
sink.update(fullText);
113116
},
114117
finish(): void {
115118
if (done) return;
116119
done = true;
120+
sink.flush();
117121
const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
118122
label.classList.remove("thinking-shimmer");
119123
label.innerHTML = `<span class="thinking-verb">${t("已思考")}</span> ${seconds} ${t("秒")}`;
@@ -132,6 +136,33 @@ export function isHomeChatActive(root: HTMLElement): boolean {
132136
return root.dataset.homeChat === "on";
133137
}
134138

139+
// ── 暂停生成:回复流式期间发送键变为暂停键(与执行页同语义) ────────────────
140+
141+
let activeAbort: AbortController | null = null;
142+
143+
/** 首页对话是否在生成中;点暂停键时由 task-start-controller 调用。 */
144+
export function stopHomeChatGeneration(): boolean {
145+
if (!activeAbort) return false;
146+
activeAbort.abort();
147+
return true;
148+
}
149+
150+
function setSendButtonGenerating(root: HTMLElement, on: boolean): void {
151+
root.querySelectorAll<HTMLButtonElement>('.composer [data-action="send"]').forEach(button => {
152+
if (on) {
153+
button.dataset.mode = "stop";
154+
button.innerHTML = '<i class="ph-fill ph-stop" aria-hidden="true"></i>';
155+
button.title = t("暂停生成");
156+
button.setAttribute("aria-label", t("暂停生成"));
157+
} else {
158+
delete button.dataset.mode;
159+
button.innerHTML = '<i class="ph ph-arrow-up" aria-hidden="true"></i>';
160+
button.title = t("发送(Enter)");
161+
button.setAttribute("aria-label", t("发送"));
162+
}
163+
});
164+
}
165+
135166
export interface HomeChatTurnOptions {
136167
/** @ 引用资料的上下文块(composerReferenceBlock):只进请求正文,不进气泡。 */
137168
referenceContext?: string;
@@ -162,8 +193,13 @@ export async function runHomeChatTurn(
162193
thinking: ReturnType<typeof createThinkingBlock> | null;
163194
sawDelta: boolean;
164195
} = { thinking: null, sawDelta: false };
196+
// 渲染节流 + 块级增量上屏(长回复不再逐增量整段重建),公式排版随之削峰
197+
const renderer = createStreamingMarkdownRenderer(copy);
198+
const abort = new AbortController();
199+
activeAbort = abort;
200+
setSendButtonGenerating(root, true);
165201
try {
166-
await sendConversationTurn(
202+
const { text: reply } = await sendConversationTurn(
167203
text,
168204
{
169205
onReasoning: (_delta, full) => {
@@ -175,21 +211,26 @@ export async function runHomeChatTurn(
175211
state.sawDelta = true;
176212
state.thinking?.finish();
177213
}
178-
copy.innerHTML = renderMarkdown(full);
179-
// 已闭合的公式随增量立即排版,与执行页对话同语义(实时渲染)
180-
typesetMath(copy);
214+
renderer.update(full);
181215
},
182216
},
183217
{
184218
...(options.referenceContext ? { attachmentContext: options.referenceContext } : {}),
185219
...(options.referenceTitles?.length ? { attachmentNames: options.referenceTitles } : {}),
220+
signal: abort.signal,
186221
},
187222
);
188223
state.thinking?.finish();
224+
renderer.finish(reply);
189225
scrollIntoView(block);
190226
return true;
191227
} catch (error) {
192228
state.thinking?.finish();
229+
renderer.cancel();
230+
if (error instanceof ChatError && error.code === "GENERATION_STOPPED") {
231+
copy.innerHTML = `<p class="muted">${t("已暂停生成。")}</p>`;
232+
return false;
233+
}
193234
if (error instanceof ChatError && error.code === "AUTH_REQUIRED") {
194235
copy.innerHTML = `<p class="muted">${t("请先登录后再继续对话。")}</p>`;
195236
openAuthDialog({});
@@ -202,5 +243,8 @@ export async function runHomeChatTurn(
202243
failure.textContent = message;
203244
copy.append(failure);
204245
return false;
246+
} finally {
247+
if (activeAbort === abort) activeAbort = null;
248+
setSendButtonGenerating(root, false);
205249
}
206250
}

0 commit comments

Comments
 (0)