Skip to content

Commit b56e04b

Browse files
committed
perf: smooth streaming and split account settings
1 parent 21ae461 commit b56e04b

2 files changed

Lines changed: 156 additions & 92 deletions

File tree

frontend/src/pages/Chat.tsx

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useEffect, useRef, useState } from "react";
1+
import { memo, useEffect, useMemo, useRef, useState } from "react";
22
import { useNavigate, useParams } from "react-router-dom";
33
import {
44
Send,
@@ -867,6 +867,44 @@ export default function Chat() {
867867
// process thinking a generation is still in flight forever.
868868
window.api.app.setBusy(true);
869869

870+
// Models often emit many tiny chunks per second. Updating React for
871+
// every token makes Markdown parsing and layout dominate the UI thread,
872+
// so coalesce chunks into a steady ~30 FPS stream instead.
873+
let pendingText = "";
874+
let pendingUsage: UsageInfo | undefined;
875+
let pendingToolCalls: ToolCall[] = [];
876+
let flushTimer: number | null = null;
877+
const flushStream = () => {
878+
flushTimer = null;
879+
if (!pendingText && !pendingUsage && pendingToolCalls.length === 0) return;
880+
const text = pendingText;
881+
const usage = pendingUsage;
882+
const toolCalls = pendingToolCalls;
883+
pendingText = "";
884+
pendingUsage = undefined;
885+
pendingToolCalls = [];
886+
setMessages((current) => {
887+
const next = [...current];
888+
const last = next[next.length - 1];
889+
next[next.length - 1] = {
890+
role: "assistant",
891+
content: last.content + text,
892+
usage: usage
893+
? {
894+
promptTokens: usage.promptTokens ?? last.usage?.promptTokens,
895+
completionTokens: usage.completionTokens ?? last.usage?.completionTokens,
896+
elapsedMs: Date.now() - streamStartedAt,
897+
}
898+
: last.usage,
899+
toolCalls: toolCalls.length > 0 ? [...(last.toolCalls ?? []), ...toolCalls] : last.toolCalls,
900+
};
901+
return next;
902+
});
903+
};
904+
const scheduleFlush = () => {
905+
if (flushTimer === null) flushTimer = window.setTimeout(flushStream, 32);
906+
};
907+
870908
const { requestId, promise } = window.api.chat.send(
871909
parsed.provider,
872910
parsed.modelId,
@@ -875,28 +913,17 @@ export default function Chat() {
875913
(chunk) => {
876914
const piece = chunk.message?.content ?? "";
877915
if (!piece && !chunk.usage && !chunk.toolCalls) return;
878-
setMessages((m) => {
879-
const next = [...m];
880-
const last = next[next.length - 1];
881-
next[next.length - 1] = {
882-
role: "assistant",
883-
content: last.content + piece,
884-
usage: chunk.usage
885-
? {
886-
promptTokens: chunk.usage.promptTokens ?? last.usage?.promptTokens,
887-
completionTokens: chunk.usage.completionTokens ?? last.usage?.completionTokens,
888-
elapsedMs: Date.now() - streamStartedAt,
889-
}
890-
: last.usage,
891-
toolCalls: chunk.toolCalls ? [...(last.toolCalls ?? []), ...chunk.toolCalls] : last.toolCalls,
892-
};
893-
return next;
894-
});
916+
pendingText += piece;
917+
if (chunk.usage) pendingUsage = { ...pendingUsage, ...chunk.usage };
918+
if (chunk.toolCalls) pendingToolCalls.push(...chunk.toolCalls);
919+
scheduleFlush();
895920
},
896921
agentMode && !!agentWorkspace
897922
);
898923
setActiveRequestId(requestId);
899924
const result = await promise;
925+
if (flushTimer !== null) window.clearTimeout(flushTimer);
926+
flushStream();
900927
setActiveRequestId(null);
901928

902929
// One silent retry for errors that usually clear on their own
@@ -1252,6 +1279,30 @@ export default function Chat() {
12521279
}
12531280
}
12541281

1282+
const parsedModel = useMemo(() => parseModelRef(model), [model]);
1283+
const { individualAttachments, folderGroups } = useMemo(() => {
1284+
const individual = attachments.filter((file) => !file.folder);
1285+
const counts = new Map<string, number>();
1286+
for (const file of attachments) {
1287+
if (file.folder) counts.set(file.folder, (counts.get(file.folder) ?? 0) + 1);
1288+
}
1289+
return {
1290+
individualAttachments: individual,
1291+
folderGroups: [...counts].map(([folder, count]) => ({ folder, count })),
1292+
};
1293+
}, [attachments]);
1294+
const lastAssistantIndex = useMemo(() => messages.findLastIndex((message) => message.role === "assistant"), [messages]);
1295+
const sessionCost = useMemo(
1296+
() =>
1297+
parsedModel && parsedModel.provider !== "ollama" && parsedModel.provider !== "llamacpp"
1298+
? messages.reduce((sum, message) => {
1299+
if (message.role !== "assistant" || !message.usage) return sum;
1300+
return sum + (estimateCost(parsedModel.modelId, message.usage.promptTokens, message.usage.completionTokens) ?? 0);
1301+
}, 0)
1302+
: 0,
1303+
[messages, parsedModel]
1304+
);
1305+
12551306
if (!hasApi) {
12561307
return (
12571308
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
@@ -1261,23 +1312,8 @@ export default function Chat() {
12611312
);
12621313
}
12631314

1264-
const parsedModel = parseModelRef(model);
1265-
const individualAttachments = attachments.filter((f) => !f.folder);
1266-
const folderGroups = Array.from(new Set(attachments.filter((f) => f.folder).map((f) => f.folder!))).map(
1267-
(folder) => ({ folder, count: attachments.filter((f) => f.folder === folder).length })
1268-
);
1269-
const lastAssistantIndex = [...messages].map((m) => m.role).lastIndexOf("assistant");
12701315
const currentProject = getCurrentProject();
12711316

1272-
const sessionCost =
1273-
parsedModel && parsedModel.provider !== "ollama" && parsedModel.provider !== "llamacpp"
1274-
? messages.reduce((sum, m) => {
1275-
if (m.role !== "assistant" || !m.usage) return sum;
1276-
const cost = estimateCost(parsedModel.modelId, m.usage.promptTokens, m.usage.completionTokens);
1277-
return sum + (cost ?? 0);
1278-
}, 0)
1279-
: 0;
1280-
12811317
return (
12821318
<div className="flex h-full flex-col bg-background/35">
12831319
<div className="surface-glass flex min-h-14 flex-wrap items-center gap-2 border-b border-border/70 px-4 py-2.5 pl-14 shadow-sm md:pl-4">

0 commit comments

Comments
 (0)