mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user