feat(studio): send messages alongside a running production task

此前任务运行期间用户发消息,前端会先中止任务再发送;聊天轮结束时
agent:complete 一到就关掉 SSE 连接,把还在跑的任务流也一起关了。
本次改动(issue #341 前端部分):

1. SessionRuntime 新增 isChatStreaming(聊天轮流式中);isStreaming 保持
   "聊天轮或任务运行中"语义,Sidebar/PlayHud 等现有读取方不受影响。
   确认式生产任务的发送轮不置 isChatStreaming(该请求挂起到任务结束,
   期间用户仍可聊天),判定逻辑与服务端 isConfirmedProductionAction 对齐。
2. sendMessage 只挡 isChatStreaming:任务在跑时允许发送。发送时关掉旧的
   任务恢复连接、换成新连接(单连接原则);任务卡不受影响——服务端在新
   连接建立时重放 running 快照,任务日志与收尾均按 execution id 匹配。
3. 聊天轮收尾不再一律关连接:agent:complete/agent:error/agent:aborted
   到达时,聊天轮还在进行或消息里还有 in-flight 任务卡就保持连接,等任务
   自己的终态事件(tool:end → agent:complete)再关闭。sendMessage 的
   finally 统一按"是否还有任务在跑"决定连接与 isStreaming 的最终状态。
4. tool:end / log / llm:progress 改为跨消息定位工具卡(按 execution id /
   倒序找运行中的卡),并行聊天时任务卡挂在更早的任务轮消息上,原先只在
   当前 streamTs 消息里找会漏更新。
5. 停止按钮分对象:聊天轮流式中 abort 用 scope=chat(只停聊天轮,任务
   继续跑,也不把任务卡标记为失败);只有任务在跑时才 scope=all 停任务。
   聊天轮出错时只把本轮消息里的运行中工具标记为失败,不连带任务卡。
This commit is contained in:
Ma
2026-07-15 01:52:13 +08:00
parent c81272febb
commit 39d2edfcc9
8 changed files with 399 additions and 92 deletions
+9 -3
View File
@@ -414,6 +414,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
const activeSessionId = useChatStore((s) => s.activeSessionId);
const input = useChatStore((s) => s.input);
const loading = useChatStore(chatSelectors.isActiveSessionStreaming);
const chatStreaming = useChatStore(chatSelectors.isActiveSessionChatStreaming);
const selectedModel = useChatStore((s) => s.selectedModel);
const selectedService = useChatStore((s) => s.selectedService);
// -- Store actions --
@@ -709,14 +710,19 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
if (!activeSessionId) return;
const hasPendingMessage = Boolean(text.trim()) || attachedFiles.length > 0;
if (!hasPendingMessage) {
if (loading) await abortSession(activeSessionId);
// 停止按钮按对象分语义:聊天轮流式中只停聊天轮(后台任务继续跑);
// 只有后台任务在跑时才停任务(旧行为)。
if (chatStreaming) await abortSession(activeSessionId, "chat");
else if (loading) await abortSession(activeSessionId);
return;
}
const requestedSkills = selectedSkillIdsForSend(selectedSkillIds);
autoScrollPinnedRef.current = true;
const attachments = await serializeChatAttachments(attachedFiles);
if (loading) {
await abortSession(activeSessionId);
if (chatStreaming) {
// 聊天轮流式中再发消息:先停当前聊天轮(不动后台任务)再发送。
// 只有后台任务在跑时直接发送,不中止任务。
await abortSession(activeSessionId, "chat");
}
await sendMessage(activeSessionId, text, {
activeBookId,
@@ -1,5 +1,34 @@
import type { ChatActionSource, ChatRequestedIntent } from "./types";
const READ_ONLY_TOOLS = new Set(["read", "grep", "ls"]);
export function shouldRefreshSidebarForTool(toolName: string): boolean {
return !READ_ONLY_TOOLS.has(toolName);
}
// 与服务端 server.ts 的 isConfirmedProductionAction 保持一致:
// 这些 intent 经 button/slash 确认后走服务端的确认式生产分支(task-store 跟踪、
// 可长时间运行)。这样的发送轮不是"聊天轮"——请求会挂起到任务结束,
// 期间用户应当仍能继续聊天,所以它不置 isChatStreaming。
const CONFIRMED_PRODUCTION_INTENTS: ReadonlySet<ChatRequestedIntent> = new Set([
"create_book",
"short_run",
"script_create",
"storyboard_create",
"interactive_film_create",
"translation_create",
"play_start",
"generate_cover",
"draft_structure",
"connect_choice",
"remove_node",
] as const);
export function isConfirmedProductionSend(
actionSource: ChatActionSource,
requestedIntent: ChatRequestedIntent | undefined,
): boolean {
return (actionSource === "button" || actionSource === "slash")
&& requestedIntent !== undefined
&& CONFIRMED_PRODUCTION_INTENTS.has(requestedIntent);
}
@@ -7,6 +7,9 @@ export const chatSelectors = {
activeMessages: (s: ChatState) =>
(s.activeSessionId ? s.sessions[s.activeSessionId]?.messages : undefined) ?? EMPTY_MESSAGES,
isActiveSessionStreaming: (s: ChatState) => Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.isStreaming),
// 聊天轮本身是否在流式中;后台任务运行期间为 false(此时仍可继续发消息)。
isActiveSessionChatStreaming: (s: ChatState) =>
Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.isChatStreaming),
isEmpty: (s: ChatState) =>
((s.activeSessionId ? s.sessions[s.activeSessionId]?.messages.length : 0) ?? 0) === 0
&& !Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.isStreaming),
@@ -14,6 +14,7 @@ vi.mock("../../../../hooks/use-api", () => ({ fetchJson }));
class FakeEventSource {
readonly url: string;
readonly listeners = new Map<string, Array<(event: MessageEvent) => void>>();
closed = false;
constructor(url: string) {
this.url = url;
fakeEventSources.push(this);
@@ -23,7 +24,9 @@ class FakeEventSource {
current.push(listener);
this.listeners.set(type, current);
}
close() {}
close() {
this.closed = true;
}
emit(type: string, data: unknown) {
for (const listener of this.listeners.get(type) ?? []) {
listener({ data: JSON.stringify(data) } as MessageEvent);
@@ -433,4 +436,168 @@ describe("chat message actions", () => {
);
now.mockRestore();
});
// 恢复出一个"任务运行中"的会话:磁盘上有 running 任务快照,前端加载详情后
// 会 merge 任务卡、建立 SSE 连接并把 isStreaming 置为 true。
async function setupRunningTaskSession(store: ReturnType<typeof createTestStore>): Promise<string> {
fetchJson.mockResolvedValueOnce({
session: { sessionId: "task-session-1", bookId: null, sessionKind: "short", title: "雨夜账本" },
});
const sessionId = await store.getState().createSession(null, "short");
store.getState().setSelectedModel("deepseek-v4-flash", "kkaiapi");
fetchJson.mockResolvedValueOnce({
session: { sessionId, bookId: null, sessionKind: "short", title: "雨夜账本", messages: [] },
task: {
version: 1,
sessionId,
requestedIntent: "short_run",
updatedAt: 20,
execution: {
id: "direct-short_run-1",
tool: "short_fiction_run",
label: "短篇生产",
status: "running",
startedAt: 10,
},
},
});
await store.getState().loadSessionDetail(sessionId);
expect(fakeEventSources).toHaveLength(1);
return sessionId;
}
function findTaskExecution(store: ReturnType<typeof createTestStore>, sessionId: string) {
return (store.getState().sessions[sessionId]?.messages ?? [])
.flatMap((message) => message.toolExecutions ?? [])
.find((execution) => execution.id === "direct-short_run-1");
}
it("sends a chat message while a production task is running without aborting the task", async () => {
const store = createTestStore();
const sessionId = await setupRunningTaskSession(store);
fetchJson.mockClear();
fetchJson.mockResolvedValueOnce({ response: "任务还在跑。", session: { sessionId, sessionKind: "short" } });
await store.getState().sendMessage(sessionId, "写得怎么样了?");
// 发送没有被挡、也没有调用 abort 接口
const calledPaths = fetchJson.mock.calls.map(([path]) => path);
expect(calledPaths).toContain("/agent");
expect(calledPaths).not.toContain(`/sessions/${sessionId}/abort`);
// 单连接原则:旧的任务恢复连接被换成新连接
expect(fakeEventSources).toHaveLength(2);
expect(fakeEventSources[0]?.closed).toBe(true);
expect(fakeEventSources[1]?.closed).toBe(false);
// 聊天轮结束后任务仍在跑:isStreaming 保持 true、连接保持、任务卡还在 running
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true, isChatStreaming: false });
expect(store.getState().sessions[sessionId]?.stream).not.toBeNull();
expect(findTaskExecution(store, sessionId)).toMatchObject({ status: "running" });
// 聊天回复正常写入
expect(store.getState().sessions[sessionId]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: "任务还在跑。",
});
});
it("keeps the task stream open when the chat round completes while the task is still running", async () => {
const store = createTestStore();
const sessionId = await setupRunningTaskSession(store);
let resolveAgent!: (value: unknown) => void;
fetchJson.mockClear();
fetchJson.mockImplementationOnce(() => new Promise((resolve) => {
resolveAgent = resolve;
}));
const sent = store.getState().sendMessage(sessionId, "顺便聊两句");
await vi.waitFor(() => expect(fakeEventSources).toHaveLength(2));
// 聊天轮的 agent:complete 到达时任务仍在跑:不能把连接关掉
fakeEventSources[1]?.emit("agent:complete", { sessionId });
expect(fakeEventSources[1]?.closed).toBe(false);
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true });
resolveAgent({ response: "聊完了。", session: { sessionId, sessionKind: "short" } });
await sent;
expect(fakeEventSources[1]?.closed).toBe(false);
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true, isChatStreaming: false });
// 任务完成:tool:end 按 execution id 找到早前消息里的任务卡收尾,随后的 agent:complete 关闭连接
fakeEventSources[1]?.emit("tool:end", {
sessionId,
id: "direct-short_run-1",
tool: "short_fiction_run",
result: { content: [{ type: "text", text: "短篇已完成" }] },
});
fakeEventSources[1]?.emit("agent:complete", { sessionId });
expect(findTaskExecution(store, sessionId)).toMatchObject({ status: "completed" });
expect(fakeEventSources[1]?.closed).toBe(true);
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: false, stream: null });
});
it("closes the stream after a plain chat round when no production task is running", async () => {
const store = createTestStore();
const sessionId = store.getState().createDraftSession(null, "chat");
store.getState().setSelectedModel("deepseek-v4-flash", "kkaiapi");
let resolveAgent!: (value: unknown) => void;
fetchJson
.mockResolvedValueOnce({ session: { sessionId, bookId: null, sessionKind: "chat" } })
.mockImplementationOnce(() => new Promise((resolve) => {
resolveAgent = resolve;
}));
const sent = store.getState().sendMessage(sessionId, "你好");
await vi.waitFor(() => expect(fakeEventSources).toHaveLength(1));
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true, isChatStreaming: true });
resolveAgent({ response: "你好!", session: { sessionId, sessionKind: "chat" } });
await sent;
expect(store.getState().sessions[sessionId]).toMatchObject({
isStreaming: false,
isChatStreaming: false,
stream: null,
});
expect(fakeEventSources[0]?.closed).toBe(true);
});
it("aborts only the chat round with scope=chat and keeps the running task card intact", async () => {
const store = createTestStore();
const sessionId = await setupRunningTaskSession(store);
let rejectAgent!: (error: Error) => void;
fetchJson.mockClear();
fetchJson
.mockImplementationOnce(() => new Promise((_resolve, reject) => {
rejectAgent = reject;
}))
.mockResolvedValueOnce({ ok: true, aborted: true });
const sent = store.getState().sendMessage(sessionId, "顺便问一下");
await vi.waitFor(() => expect(fakeEventSources).toHaveLength(2));
await store.getState().abortSession(sessionId, "chat");
const abortCall = fetchJson.mock.calls.find(([path]) => path === `/sessions/${sessionId}/abort`);
expect(abortCall?.[1]).toMatchObject({
method: "POST",
body: JSON.stringify({ scope: "chat" }),
});
// scope=chat 不把任务卡标记为失败,也不关任务连接
expect(findTaskExecution(store, sessionId)).toMatchObject({ status: "running" });
expect(fakeEventSources[1]?.closed).toBe(false);
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true, isChatStreaming: false });
rejectAgent(new Error("This operation was aborted"));
await sent;
// 聊天轮收尾后任务照旧运行
expect(findTaskExecution(store, sessionId)).toMatchObject({ status: "running" });
expect(fakeEventSources[1]?.closed).toBe(false);
expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true });
});
});
@@ -11,6 +11,7 @@ import type {
} from "../../types";
import { fetchJson } from "../../../../hooks/use-api";
import { tr } from "../../../../lib/app-language";
import { isConfirmedProductionSend } from "../../message-policy";
import { attachSessionStreamListeners } from "./stream-events";
import {
bookKey,
@@ -18,6 +19,7 @@ import {
deriveResolvedProposals,
deserializeMessages,
extractErrorMessage,
hasAnyInFlightExecution,
markRunningToolsFailed,
mergeTaskExecution,
mergeSessionIds,
@@ -149,8 +151,16 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
|| execution.status === "processing"
|| execution.status === "error",
);
// 只把本轮(streamTs 消息)里的运行中工具标记为失败:并行运行的后台
// 任务卡挂在更早的消息上,聊天轮出错不代表任务失败,不能连带标记。
// isStreaming / stream 的收尾统一交给 sendMessage 的 finally 判断
//(那里会检查是否还有任务在跑)。
const messages = hasActiveOrFailedTool
? markRunningToolsFailed(session.messages, errorMsg)
? session.messages.map((message) => (
message.timestamp === streamTs && message.role === "assistant"
? markRunningToolsFailed([message], errorMsg)[0]!
: message
))
: [
...session.messages.filter(
(message) => !(message.timestamp === streamTs && message.role === "assistant"),
@@ -159,9 +169,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
];
return {
messages,
isStreaming: false,
lastError: errorMsg,
stream: null,
};
}),
})),
@@ -331,21 +339,42 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
});
},
abortSession: async (sessionId) => {
abortSession: async (sessionId, scope = "all") => {
const session = get().sessions[sessionId];
session?.stream?.close();
const stoppedAt = Date.now();
const stoppedMessage = tr("已由用户停止", "Stopped by user");
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
isStreaming: false,
stream: null,
lastError: null,
messages: markRunningToolsFailed(runtime.messages, stoppedMessage, stoppedAt),
})),
}));
if (scope === "all") {
session?.stream?.close();
const stoppedAt = Date.now();
const stoppedMessage = tr("已由用户停止", "Stopped by user");
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
isStreaming: false,
isChatStreaming: false,
stream: null,
lastError: null,
messages: markRunningToolsFailed(runtime.messages, stoppedMessage, stoppedAt),
})),
}));
} else {
// scope=chat:只停当前聊天轮,后台任务还在跑。
// 不关连接(任务事件还要继续到达)、不把任务卡标记为失败;
// 聊天轮自身的收尾由 sendMessage 的 finally 完成。
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isChatStreaming: false,
lastError: null,
})),
}));
}
try {
await fetchJson(`/sessions/${sessionId}/abort`, { method: "POST" });
await fetchJson(`/sessions/${sessionId}/abort`, {
method: "POST",
...(scope === "chat"
? {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope: "chat" }),
}
: {}),
});
} catch (error) {
get().addErrorMessage(sessionId, error instanceof Error ? error.message : String(error));
}
@@ -432,7 +461,9 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
const trimmed = text.trim();
const attachments = options?.attachments ?? [];
const session = get().sessions[sessionId];
if ((!trimmed && attachments.length === 0) || !session || session.isStreaming) return;
// 只挡"聊天轮流式中":后台生产任务运行期间(isStreaming=true 但
// isChatStreaming=false)允许继续发消息,聊天与任务并行。
if ((!trimmed && attachments.length === 0) || !session || session.isChatStreaming) return;
const userInstruction = trimmed || tr("请阅读我上传的文件。", "Please read the files I uploaded.");
const activeBookId = options?.activeBookId ?? session.bookId ?? undefined;
const sessionKind: ChatSessionKind = options?.sessionKind
@@ -480,17 +511,24 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
const requestedSkills = mergeSkillIds(skillDirectives.requestedSkills, options?.requestedSkills);
const disabledSkills = mergeSkillIds([], options?.disabledSkills);
const streamTs = Date.now() + 1;
// 确认式生产任务的发送轮不是"聊天轮":请求会挂起到任务结束,
// 期间用户仍可继续聊天,所以不置 isChatStreaming。
const isProductionTaskSend = isConfirmedProductionSend(actionSource, options?.requestedIntent);
set((state) => ({
input: "",
activeSessionId: sessionId,
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: true,
isChatStreaming: !isProductionTaskSend,
lastError: null,
})),
}));
get().addUserMessage(sessionId, formatUserMessageForDisplay(userInstruction, attachments));
// 单连接原则:任务恢复流等旧连接先关掉,换成本轮的新连接。
// 运行中的任务卡不受影响——新连接建立时服务端会重放 running 快照,
// 任务日志(log)与收尾(tool:end)都按 execution id 匹配,与 streamTs 无关。
session.stream?.close();
const streamEs = new EventSource(`/api/v1/events?sessionId=${encodeURIComponent(sessionId)}`);
set((state) => ({
@@ -519,8 +557,6 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
}),
});
streamEs.close();
const finalContent = data.details?.draftRaw || data.response || "";
const toolCall = data.details?.toolCall ?? undefined;
const responseToolExecutions = data.details?.toolExecutions ?? [];
@@ -620,7 +656,6 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
}
}
} catch (error) {
streamEs.close();
const errorMessage = error instanceof Error ? error.message : String(error);
const failureAlreadyShown = get().sessions[sessionId]?.messages.some((message) => {
const executions = [
@@ -644,12 +679,23 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
get().addErrorMessage(sessionId, errorMessage);
}
} finally {
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
isStreaming: false,
stream: runtime.stream === streamEs ? null : runtime.stream,
})),
}));
// 本轮请求已结束(成功/出错都走这里)。只有当会话的连接仍归本轮所有时
// 才收尾:如果发新消息时旧连接已被替换(stream 指向更新一轮的连接),
// 由新一轮负责后续状态。
const runtime = get().sessions[sessionId];
if (runtime && (runtime.stream === streamEs || runtime.stream === null)) {
// 还有生产任务在跑:保持连接与 isStreaming,等任务自己的终态事件
//tool:end → agent:complete)到来时由 stream-events 收尾。
const taskInFlight = hasAnyInFlightExecution(runtime.messages);
if (!taskInFlight) streamEs.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isChatStreaming: false,
isStreaming: taskInFlight,
stream: taskInFlight ? streamEs : null,
})),
}));
}
}
},
});
@@ -200,6 +200,7 @@ export function createSessionRuntime(input: {
messages: input.messages ?? [],
stream: null,
isStreaming: false,
isChatStreaming: false,
lastError: null,
isDraft: input.isDraft ?? false,
};
@@ -284,6 +285,47 @@ export function hasInFlightExecution(
);
}
/**
* 消息里是否还有任何 running/processing 的工具执行。
* 聊天轮结束时用它判断"后台生产任务是否还在跑":只有全部执行都到终态,
* 才允许关闭 SSE 连接并把 isStreaming 置回 false。
*/
export function hasAnyInFlightExecution(messages: ReadonlyArray<Message>): boolean {
const inFlight = (execution: ToolExecution): boolean =>
execution.status === "running" || execution.status === "processing";
return messages.some((message) =>
(message.toolExecutions?.some(inFlight) ?? false)
|| (message.parts?.some((part) => part.type === "tool" && inFlight(part.execution)) ?? false),
);
}
/**
* 按 execution id 在全部消息里定位工具卡并更新(并行聊天时任务卡挂在更早的
* 任务轮消息上,不能只在当前 streamTs 的消息里找)。找不到时返回 null。
*/
export function updateToolPartById(
messages: ReadonlyArray<Message>,
executionId: string,
update: (execution: ToolExecution) => ToolExecution,
): ReadonlyArray<Message> | null {
let found = false;
const next = messages.map((message) => {
const hasPart = message.parts?.some(
(part) => part.type === "tool" && part.execution.id === executionId,
) ?? false;
if (!hasPart) return message;
found = true;
const parts = (message.parts ?? []).map((part) => (
part.type === "tool" && part.execution.id === executionId
? { type: "tool" as const, execution: update(part.execution) }
: part
));
return { ...message, ...deriveFlat(parts), parts };
});
return found ? next : null;
}
export function markRunningToolsFailed(
messages: ReadonlyArray<Message>,
error: string,
@@ -1,5 +1,5 @@
import type { StateCreator } from "zustand";
import type { ChatStore, MessageActions, MessagePart, PipelineStage, ToolExecution } from "../../types";
import type { ChatStore, Message, MessageActions, MessagePart, PipelineStage, ToolExecution } from "../../types";
import { shouldRefreshSidebarForTool } from "../../message-policy";
import { tr } from "../../../../lib/app-language";
import {
@@ -8,6 +8,7 @@ import {
extractToolError,
findRunningToolPart,
getOrCreateStream,
hasAnyInFlightExecution,
hasInFlightExecution,
mergeTaskExecution,
replaceLast,
@@ -15,6 +16,7 @@ import {
sessionMatchesEvent,
summarizeResult,
updateSession,
updateToolPartById,
} from "./runtime";
type SliceSet = Parameters<StateCreator<ChatStore, [], [], MessageActions>>[0];
@@ -96,6 +98,35 @@ export function appendBoundedToolLogs(
return [...(existing ?? []), ...incoming].slice(-MAX_TOOL_LOGS);
}
/**
* 倒序扫描消息,找到最近一个仍在运行的工具卡并更新它。
* 任务与聊天并行时,任务卡挂在更早的任务轮消息上,不能只看当前 streamTs
* 的消息。update 返回 null 表示这张卡不需要更新(整体视为 no-op)。
*/
export function updateLatestRunningToolMessage(
messages: ReadonlyArray<Message>,
update: (execution: ToolExecution) => ToolExecution | null,
): ReadonlyArray<Message> | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i]!;
const running = findRunningToolPart([...(message.parts ?? [])]);
if (!running) continue;
const updated = update(running.execution);
if (!updated) return null;
const parts = (message.parts ?? []).map((part) => (
part.type === "tool" && part.execution.id === running.execution.id
? { type: "tool" as const, execution: updated }
: part
));
return [
...messages.slice(0, i),
{ ...message, ...deriveFlat(parts), parts },
...messages.slice(i + 1),
];
}
return null;
}
export function createStreamTextDeltaBatcher(
flushDeltas: (deltas: StreamTextDelta[]) => void,
delayMs = STREAM_TEXT_FLUSH_MS,
@@ -210,33 +241,26 @@ export function attachSessionStreamListeners({
const progressThrottle = createLatestEventThrottle<StreamProgressEventData>((data) => {
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => {
const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
if (!runningTool?.execution.stages) return {};
const parts = (stream.parts ?? []).map((part) => {
if (part.type !== "tool" || part.execution.id !== runningTool.execution.id) return part;
const messages = updateLatestRunningToolMessage(runtime.messages, (execution) => {
if (!execution.stages) return null;
return {
type: "tool" as const,
execution: {
...part.execution,
stages: part.execution.stages?.map((stage) =>
stage.status === "active"
? {
...stage,
progress: {
status: data.status,
elapsedMs: data.elapsedMs,
totalChars: data.totalChars,
chineseChars: data.chineseChars,
},
}
: stage,
),
},
...execution,
stages: execution.stages.map((stage) =>
stage.status === "active"
? {
...stage,
progress: {
status: data.status,
elapsedMs: data.elapsedMs,
totalChars: data.totalChars,
chineseChars: data.chineseChars,
},
}
: stage,
),
};
});
const flat = deriveFlat(parts);
return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
return messages ? { messages } : {};
}),
}));
});
@@ -244,12 +268,22 @@ export function attachSessionStreamListeners({
streamEs.addEventListener("draft:complete", flushTextDeltas);
streamEs.addEventListener("draft:error", flushTextDeltas);
// agent:complete / agent:error / agent:aborted 都是"某一轮请求结束"的信号,
// 但事件本身分不清结束的是聊天轮还是后台任务轮(两者共享 sessionId):
// - 聊天轮还在进行(isChatStreaming=true)时不能关连接——事件既可能属于
// 聊天轮自己(随后 sendMessage 的 finally 会收尾),也可能属于后台任务
// (聊天要继续);
// - 聊天轮已结束时,只要消息里还有 in-flight 的任务卡,连接也要保持,
// 等任务自己的终态事件(tool:end → agent:complete)到来再关闭。
const finishSessionStream = (event: MessageEvent) => {
try {
const data = event.data ? JSON.parse(event.data) : null;
if (!sessionMatchesEvent(sessionId, data)) return;
flushTextDeltas();
progressThrottle.flush();
const runtime = get().sessions[sessionId];
if (!runtime || runtime.isChatStreaming) return;
if (hasAnyInFlightExecution(runtime.messages)) return;
streamEs.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
@@ -290,23 +324,7 @@ export function attachSessionStreamListeners({
}
});
streamEs.addEventListener("agent:aborted", (event: MessageEvent) => {
try {
const data = event.data ? JSON.parse(event.data) : null;
if (!sessionMatchesEvent(sessionId, data)) return;
flushTextDeltas();
progressThrottle.flush();
streamEs.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: false,
stream: null,
})),
}));
} catch {
// ignore
}
});
streamEs.addEventListener("agent:aborted", finishSessionStream);
streamEs.addEventListener("thinking:start", (event: MessageEvent) => {
try {
@@ -430,10 +448,9 @@ export function attachSessionStreamListeners({
progressThrottle.flush();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => {
const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
const parts = (stream.parts ?? []).map((part) => {
if (part.type !== "tool" || part.execution.id !== data.id) return part;
const execution = { ...part.execution };
// 按 execution id 全量定位:并行聊天时任务卡在更早的消息里
const messages = updateToolPartById(runtime.messages, data.id as string, (previous) => {
const execution = { ...previous };
execution.status = data.isError ? "error" : "completed";
execution.completedAt = Date.now();
execution.stages = execution.stages?.map((stage) =>
@@ -445,10 +462,9 @@ export function attachSessionStreamListeners({
else execution.result = summarizeResult(data.result);
const details = data.details ?? extractToolDetails(data.result);
if (details !== undefined) execution.details = details;
return { type: "tool" as const, execution };
return execution;
});
const flat = deriveFlat(parts);
return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
return messages ? { messages } : {};
}),
}));
@@ -469,18 +485,11 @@ export function attachSessionStreamListeners({
flushTextDeltas();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => {
const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
if (!runningTool) return {};
const parts = (stream.parts ?? []).map((part) => {
if (part.type !== "tool" || part.execution.id !== runningTool.execution.id) return part;
return {
type: "tool" as const,
execution: { ...part.execution, logs: appendBoundedToolLogs(part.execution.logs, [message]) },
};
});
const flat = deriveFlat(parts);
return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
const messages = updateLatestRunningToolMessage(runtime.messages, (execution) => ({
...execution,
logs: appendBoundedToolLogs(execution.logs, [message]),
}));
return messages ? { messages } : {};
}),
}));
} catch {
+6 -1
View File
@@ -155,7 +155,11 @@ export interface SessionRuntime {
readonly title: string | null;
readonly messages: ReadonlyArray<Message>;
readonly stream: EventSource | null;
// isStreaming = 聊天轮流式中 或 后台生产任务运行中(面向"会话是否忙"的读取方)。
readonly isStreaming: boolean;
// isChatStreaming 只表示聊天轮本身在流式中;后台任务运行期间它是 false,
// 用户仍可继续发消息。
readonly isChatStreaming: boolean;
readonly lastError: string | null;
// 仅前端存在、尚未持久化到磁盘的草稿会话。发送第一条消息时才调 POST /sessions 把它落盘。
readonly isDraft: boolean;
@@ -204,7 +208,8 @@ export interface MessageActions {
deleteSession: (sessionId: string) => Promise<void>;
loadSessionDetail: (sessionId: string) => Promise<void>;
sendMessage: (sessionId: string, text: string, options?: SendMessageOptions) => Promise<void>;
abortSession: (sessionId: string) => Promise<void>;
// scope="chat" 只中止当前聊天轮,不停后台生产任务;默认 "all" 两者一起停。
abortSession: (sessionId: string, scope?: "chat" | "all") => Promise<void>;
setSelectedModel: (model: string, service: string) => void;
}