# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/components/workspace/custom-prompt-menu.tsx
This commit is contained in:
董宏凯 committed 2026-08-26 17:45:39 +08:00
commit 5be21f2c3d
47 files changed
+3065 -530

No files matched your search

+65 -23
View File
@@ -1,32 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
loadArtifactContentFromToolCall,
} from "./loader";
import {
SANDBOX_STREAM_FLUSH_MS,
isSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
import { isArtifactApiPath } from "./streaming-write-preview";
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
function hasToolResult(
thread: ReturnType<typeof useThread>["thread"],
toolCallId?: string,
) {
if (!toolCallId) {
return false;
}
@@ -81,6 +74,48 @@ function applyPendingTextEdit({
return null;
}
/**
* Keep the latest write_file text in a ref (always current for later readers)
* but only publish a display snapshot on a timer while the run is in flight.
* Display therefore does not follow `thread` object identity / every SSE chunk.
*/
function useThrottledWriteFileDisplay({
filepath,
liveContent,
enabled,
streaming,
}: {
filepath: string;
liveContent: string | undefined;
enabled: boolean;
streaming: boolean;
}) {
const latestRef = useRef(liveContent);
latestRef.current = liveContent;
const [displayed, setDisplayed] = useState(liveContent);
useEffect(() => {
if (!enabled) {
setDisplayed(latestRef.current);
return;
}
const flush = () => {
setDisplayed((previous) =>
Object.is(previous, latestRef.current) ? previous : latestRef.current,
);
};
if (!streaming) {
flush();
return;
}
flush();
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
return () => window.clearInterval(intervalId);
}, [enabled, filepath, streaming]);
return enabled && streaming ? displayed : liveContent;
}
export function useArtifactContent({
filepath,
threadId,
@@ -94,12 +129,19 @@ export function useArtifactContent({
return isWriteFileStreamingUrl(filepath);
}, [filepath]);
const { thread, isMock, publicShareCode } = useThread();
const content = useMemo(() => {
if (useWriteFileStreaming) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
}
return null;
}, [filepath, useWriteFileStreaming, thread]);
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const liveWriteFileContent = useWriteFileStreaming
? loadArtifactContentFromToolCall({ url: filepath, thread })
: undefined;
const writeFileInFlight =
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
const displayedWriteFileContent = useThrottledWriteFileDisplay({
filepath,
liveContent: liveWriteFileContent,
enabled: streamPerfEnabled && useWriteFileStreaming,
streaming: writeFileInFlight,
});
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SANDBOX_STREAM_PERF_QUERY,
resolveSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
test("sandbox stream perf defaults to on", () => {
assert.equal(resolveSandboxStreamPerfEnabled({}), true);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: null }), true);
});
test("sandbox stream perf can be rolled back from hash or search query", () => {
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/chats/new?sandboxStreamPerf=0",
}),
false,
);
assert.equal(
resolveSandboxStreamPerfEnabled({
search: `?${SANDBOX_STREAM_PERF_QUERY}=off`,
}),
false,
);
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/agents/abc/chats/def?foo=1&sandboxStreamPerf=false",
}),
false,
);
});
test("sandbox stream perf query wins over localStorage", () => {
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/chats/new?sandboxStreamPerf=1",
storageValue: "0",
}),
true,
);
});
test("sandbox stream perf can be rolled back from localStorage", () => {
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "0" }), false);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "off" }), false);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "1" }), true);
});
@@ -0,0 +1,115 @@
import { useEffect } from "react";
/**
* Sandbox streaming performance guard (write_file live preview).
*
* Default ON. Rollback without a rebuild:
* 1. Append `?sandboxStreamPerf=0` to the page URL and refresh
* e.g. `/#/page/workspace/chats/new?sandboxStreamPerf=0`
* 2. Or in the console:
* localStorage.setItem("deerflow.sandbox-stream-perf", "0"); location.reload();
*
* Restore the optimized path:
* localStorage.removeItem("deerflow.sandbox-stream-perf") and drop the query, then refresh.
*/
export const SANDBOX_STREAM_PERF_STORAGE_KEY = "deerflow.sandbox-stream-perf";
export const SANDBOX_STREAM_PERF_QUERY = "sandboxStreamPerf";
/** Aligns with roundtable write_file preview (~12fps). */
export const SANDBOX_STREAM_FLUSH_MS = 80;
export const SANDBOX_LEGACY_SCROLL_INTERVAL_MS = 180;
function parseFlag(raw: string | null | undefined): boolean | null {
if (raw == null) return null;
const value = raw.trim().toLowerCase();
if (value === "0" || value === "off" || value === "false" || value === "no") {
return false;
}
if (value === "1" || value === "on" || value === "true" || value === "yes") {
return true;
}
return null;
}
function queryValue(search: string, key: string): string | null {
if (!search) return null;
const normalized = search.startsWith("?") ? search.slice(1) : search;
return new URLSearchParams(normalized).get(key);
}
export function resolveSandboxStreamPerfEnabled(input: {
search?: string;
hash?: string;
storageValue?: string | null;
}): boolean {
const searchFlag = parseFlag(queryValue(input.search ?? "", SANDBOX_STREAM_PERF_QUERY));
if (searchFlag != null) return searchFlag;
const hash = input.hash ?? "";
const hashQueryIndex = hash.indexOf("?");
if (hashQueryIndex >= 0) {
const hashFlag = parseFlag(
queryValue(hash.slice(hashQueryIndex), SANDBOX_STREAM_PERF_QUERY),
);
if (hashFlag != null) return hashFlag;
}
const storedFlag = parseFlag(input.storageValue);
if (storedFlag != null) return storedFlag;
return true;
}
export function isSandboxStreamPerfEnabled(): boolean {
if (typeof window === "undefined") return true;
let storageValue: string | null = null;
try {
storageValue = window.localStorage.getItem(SANDBOX_STREAM_PERF_STORAGE_KEY);
} catch {
storageValue = null;
}
return resolveSandboxStreamPerfEnabled({
search: window.location.search,
hash: window.location.hash,
storageValue,
});
}
export function useSandboxFollowScroll({
follow,
scrollToBottom,
contentKey,
viewMode,
}: {
follow: boolean;
scrollToBottom: (behavior?: ScrollBehavior) => boolean;
contentKey: string;
viewMode: string;
}) {
const perfEnabled = isSandboxStreamPerfEnabled();
useEffect(() => {
if (!perfEnabled || !follow) return;
let raf = 0;
raf = requestAnimationFrame(() => {
raf = 0;
scrollToBottom("auto");
});
return () => {
if (raf) cancelAnimationFrame(raf);
};
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
useEffect(() => {
if (perfEnabled || !follow) return;
const tick = () => {
scrollToBottom("auto");
};
tick();
const intervalId = window.setInterval(tick, SANDBOX_LEGACY_SCROLL_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [perfEnabled, follow, scrollToBottom]);
useEffect(() => {
if (perfEnabled || !follow) return;
scrollToBottom("auto");
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
"report.md",
);
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
});
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
const inFlight = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
],
};
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
const completed = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
],
};
assert.equal(
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
false,
);
});
test("a later thread turn does not treat a finished write as in-flight", () => {
const laterTurn = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
{ type: "human", content: "按方案 A" },
{
type: "ai",
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
},
],
};
assert.equal(
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
false,
);
});
@@ -0,0 +1,77 @@
import { isImageFile } from "../utils/files";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
export function resolveWriteFilePath(filepath: string): string {
if (filepath.startsWith("write-file:")) {
try {
return decodeURIComponent(new URL(filepath).pathname).trim();
} catch {
const rest = filepath.slice("write-file:".length);
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
}
}
return filepath.trim();
}
type WriteFileThreadLike = {
messages: Array<{
type?: string;
tool_call_id?: string;
tool_calls?: Array<{
id?: string;
name?: string;
args?: unknown;
}>;
}>;
};
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
/** True only while THIS file's write_file call has not yet returned a tool result. */
export function isWriteFileInFlight(
thread: WriteFileThreadLike,
filepath: string,
): boolean {
const path = resolveWriteFilePath(filepath);
if (!path) {
return false;
}
for (const message of thread.messages) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of message.tool_calls) {
if (toolCall.name !== "write_file") {
continue;
}
const args = toolCall.args as { path?: unknown } | undefined;
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
if (callPath !== path) {
continue;
}
if (!hasToolResult(thread, toolCall.id)) {
return true;
}
}
}
return false;
}
@@ -0,0 +1,228 @@
/**
* embed-session.ts 浏览上下文隔离单测。
* 运行方式(项目无 vitest):
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
* node --test .tmp-tests/embed-session.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
isChromeHidden,
isEmbedActive,
isIsolatedSession,
readEmbedSession,
syncEmbedSessionFromSearch,
} from "./embed-session";
class MemoryStorage {
#map = new Map<string, string>();
getItem(key: string): string | null {
return this.#map.has(key) ? this.#map.get(key)! : null;
}
setItem(key: string, value: string): void {
this.#map.set(key, String(value));
}
removeItem(key: string): void {
this.#map.delete(key);
}
}
type FakeWindow = {
self: FakeWindow;
top: FakeWindow;
sessionStorage: MemoryStorage;
location: { hash: string; search: string };
__deerflowTopChromeEmbed?: boolean;
};
const originalWindow = (globalThis as { window?: unknown }).window;
function withSharedTab<T>(run: (ctx: {
storage: MemoryStorage;
asTop: () => void;
asIframe: () => void;
}) => T): T {
const storage = new MemoryStorage();
const top = {
sessionStorage: storage,
location: { hash: "", search: "" },
} as FakeWindow;
top.self = top;
top.top = top;
const iframe = {
sessionStorage: storage,
location: { hash: "#/login/guest?embed=1&theme=blue&hideJump=0", search: "" },
} as FakeWindow;
iframe.self = iframe;
iframe.top = top;
const asTop = () => {
(globalThis as { window: FakeWindow }).window = top;
};
const asIframe = () => {
(globalThis as { window: FakeWindow }).window = iframe;
};
asTop();
try {
return run({ storage, asTop, asIframe });
} finally {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window: unknown }).window = originalWindow;
}
}
}
test("nested embed=1 does not hide chrome on the same-origin top window", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
asIframe();
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
assert.equal(isEmbedActive(), true);
assert.equal(isChromeHidden(), true);
assert.ok(storage.getItem("embed.session.nested"));
assert.equal(storage.getItem("embed.session"), null);
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
});
});
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
syncEmbedSessionFromSearch("");
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
}),
);
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("top sync after old nested pollution does not mark owner", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
}),
);
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("nested iframe reclaims a polluted shared embed.session key", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("nested iframe keeps the top window's own embed session", () => {
withSharedTab(({ asTop, asIframe }) => {
asTop();
syncEmbedSessionFromSearch("?embed=1&theme=dark");
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), true);
assert.equal(readEmbedSession()?.topOwned, true);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), true);
assert.equal(readEmbedSession()?.theme, "dark");
});
});
test("nested reclaim strips embed but keeps top isolate", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
test("top reclaim strips embed but keeps isolate without nested help", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
+251 -39
View File
@@ -27,11 +27,54 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
* localStorage 偏好。
*
* iframe 的浏览器上下文就是它自己的标签页,sessionStorage 天然「一个 iframe 一份、随关闭消失」;
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
* 路由看起来却没变。因此:
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
*/
const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
*/
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
function getWindow(): TopChromeOwnerWindow | undefined {
if (typeof globalThis === "undefined") return undefined;
return (globalThis as { window?: TopChromeOwnerWindow }).window;
}
function getSessionStorage(): Storage | null {
try {
return getWindow()?.sessionStorage ?? null;
} catch {
return null;
}
}
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
function isTopLevelWindow(): boolean {
const w = getWindow();
if (!w) return true;
try {
return w.self === w.top;
} catch {
return false;
}
}
function embedSessionKey(): string {
return isTopLevelWindow() ? EMBED_SESSION_KEY : EMBED_SESSION_NESTED_KEY;
}
export type EmbedTheme = "light" | "dark";
@@ -53,6 +96,154 @@ export interface EmbedSession {
hideChrome: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean;
/**
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
*/
topOwned?: boolean;
}
function parseEmbedSession(raw: string | null): EmbedSession | null {
if (!raw) return null;
try {
const p = JSON.parse(raw) as Partial<EmbedSession>;
return {
embed: p.embed === true,
theme: p.theme === "dark" ? "dark" : "light",
hideJump: p.hideJump === true,
hideHeader: p.hideHeader === true,
hideChrome: p.hideChrome === true,
isolate: p.isolate === true,
topOwned: p.topOwned === true,
};
} catch {
return null;
}
}
function sessionHidesChrome(session: EmbedSession | null | undefined): boolean {
return (
session?.embed === true ||
session?.hideChrome === true ||
session?.hideHeader === true ||
session?.hideJump === true
);
}
function emptySession(): EmbedSession {
return {
embed: false,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
topOwned: false,
};
}
function stripChromeFlags(session: EmbedSession): EmbedSession {
return {
...session,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
function persistTopSession(next: EmbedSession): void {
const store = getSessionStorage();
if (!store) return;
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(EMBED_SESSION_KEY);
return;
}
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
}
function isTopChromeOwned(session?: EmbedSession | null): boolean {
const w = getWindow();
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
return session?.topOwned === true;
}
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow();
if (!w || !isTopLevelWindow()) return;
if (!sessionHidesChrome(session) || !session.topOwned) return;
w[TOP_CHROME_OWNER_FLAG] = true;
}
/**
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
*/
function reclaimUnownedTopChrome(): void {
if (!isTopLevelWindow()) return;
try {
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur || !sessionHidesChrome(cur)) return;
if (isTopChromeOwned(cur)) {
const w = getWindow();
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
return;
}
persistTopSession(stripChromeFlags(cur));
} catch {
/* noop */
}
}
/**
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
*/
function migrateLegacySharedSessionIfNested(): void {
if (isTopLevelWindow()) return;
try {
const store = getSessionStorage();
if (!store || store.getItem(EMBED_SESSION_NESTED_KEY)) return;
const legacy = store.getItem(EMBED_SESSION_KEY);
if (legacy) store.setItem(EMBED_SESSION_NESTED_KEY, legacy);
} catch {
/* noop */
}
}
function reclaimPollutedTopChrome(): void {
if (isTopLevelWindow()) return;
try {
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur) return;
// 顶层已认领(内存标记或 session.topOwned)则不动。
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
persistTopSession(stripChromeFlags(cur));
} catch {
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
}
}
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
@@ -101,20 +292,21 @@ export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
}
/** 读本标签页的嵌入会话;无 → null。 */
function searchEnablesChromeHide(search: string): boolean {
return (
readEmbedParam(search) === "on" ||
readHideChromeParam(search) === "on" ||
readHideHeaderParam(search) === "on" ||
readHideJumpParam(search) === "on"
);
}
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
export function readEmbedSession(): EmbedSession | null {
try {
const raw = sessionStorage.getItem(EMBED_SESSION_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Partial<EmbedSession>;
return {
embed: p.embed === true,
theme: p.theme === "dark" ? "dark" : "light",
hideJump: p.hideJump === true,
hideHeader: p.hideHeader === true,
hideChrome: p.hideChrome === true,
isolate: p.isolate === true,
};
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
reclaimUnownedTopChrome();
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
return null;
}
@@ -123,11 +315,29 @@ export function readEmbedSession(): EmbedSession | null {
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
sessionStorage.removeItem(EMBED_SESSION_KEY);
const store = getSessionStorage();
if (!store) return;
const key = embedSessionKey();
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(key);
return;
}
sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(key, JSON.stringify(payload));
} catch {
/* noop */
}
@@ -180,18 +390,15 @@ export function isIsolatedSession(): boolean {
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
* 之前置位的开关不会被抹掉。
*
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
*/
export function syncEmbedSessionFromSearch(search: string): void {
const cur =
readEmbedSession() ??
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
migrateLegacySharedSessionIfNested();
if (isTopLevelWindow()) reclaimUnownedTopChrome();
const cur = readEmbedSession() ?? emptySession();
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -218,7 +425,20 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false;
if (isTopLevelWindow()) {
if (searchEnablesChromeHide(search)) {
next.topOwned = true;
} else if (!isTopChromeOwned(next)) {
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
} else {
next.topOwned = true;
}
}
writeEmbedSession(next);
if (isTopLevelWindow()) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
}
/**
@@ -227,8 +447,9 @@ export function syncEmbedSessionFromSearch(search: string): void {
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
*/
function currentHashSearch(): string {
if (typeof window === "undefined") return "";
const hash = window.location.hash || "";
const w = getWindow();
if (!w) return "";
const hash = w.location?.hash || "";
const i = hash.indexOf("?");
return i >= 0 ? hash.slice(i) : "";
}
@@ -259,15 +480,6 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void {
const cur =
readEmbedSession() ??
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
const cur = readEmbedSession() ?? emptySession();
writeEmbedSession({ ...cur, isolate: on });
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import { groupMessages } from "./utils";
function groupTypes(messages: Message[]) {
const types: string[] = [];
groupMessages(messages, (group) => {
types.push(group.type);
return group.type;
});
return types;
}
test("write_file tool after 用户协助 opens a processing group instead of erroring", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一份报告" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "ask_clarification", args: {} }],
} as Message,
{
type: "tool",
id: "t1",
name: "ask_clarification",
content: "请选择范围",
tool_call_id: "c1",
} as Message,
{ type: "human", id: "h2", content: "按方案 A" } as Message,
{
type: "tool",
id: "t2",
name: "write_file",
content: "OK",
tool_call_id: "w1",
} as Message,
{
type: "tool",
id: "t3",
name: "write_file",
content: "OK",
tool_call_id: "w2",
} as Message,
]);
assert.deepEqual(types, [
"human",
"assistant:processing",
"assistant:clarification",
"human",
"assistant:processing",
]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "a.md" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "write_file",
content: "OK",
tool_call_id: "w1",
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
+9 -4
View File
@@ -119,10 +119,15 @@ export function groupMessages<T>(
if (open) {
open.messages.push(message);
} else {
console.error(
"Unexpected tool message outside a processing group",
message,
);
// After 用户协助 the last group is human/clarification, so later
// write_file results have nowhere to attach. Opening a processing
// group keeps them renderable and avoids console.error(full message)
// on every MessageList render (that froze the SSE thread).
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
}
continue;
+40 -11
View File
@@ -1,13 +1,14 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { readSentimentAgentEndpoint } from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 舆情分析智能体走 Gateway 反向代理(POST,SSE 返回):
* 前端把 runtime-config.js 的 apiUrl / Basic Auth 随 AG-UI 体传给
* `/api/sentiment-agent/stream`,由后台转发上游(不校验证书、不拦地址)。
* 请求体业务字段仍为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
@@ -91,6 +92,28 @@ export interface RunSentimentAgentOptions {
handlers: SentimentAgentRunHandlers;
}
/** 把 Gateway / FastAPI 错误体展开成可展示的完整字符串。 */
async function readProxyError(response: Response): Promise<string> {
const text = await response.text();
if (!text.trim()) {
return `舆情分析服务请求失败(HTTP ${response.status})。`;
}
try {
const parsed = JSON.parse(text) as { detail?: unknown };
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
return parsed.detail;
}
if (parsed.detail != null) {
return typeof parsed.detail === "object"
? JSON.stringify(parsed.detail, null, 2)
: String(parsed.detail);
}
} catch {
// 非 JSON 时把原始响应原文抛给调用方。
}
return text;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
@@ -99,16 +122,22 @@ export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
const endpoint = readSentimentAgentEndpoint();
if (!endpoint.apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
const response = await apiFetch(`${getBackendBaseURL()}/api/sentiment-agent/stream`, {
method: "POST",
headers: buildSentimentAgentHeaders(),
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
signal,
body: JSON.stringify({
apiUrl: endpoint.apiUrl,
authUsername: endpoint.authUsername,
authPassword: endpoint.authPassword,
threadId,
runId,
state: {},
@@ -119,7 +148,7 @@ export async function runSentimentAgent(
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
throw new Error(await readProxyError(response));
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
@@ -1,9 +1,9 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 舆情分析智能体(经 Gateway 转发的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
* dist/runtime-config.js 切换环境,无需重新打包。前端把这三项随请求传给后台转发上游 SSE。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
@@ -35,25 +35,3 @@ export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 不创建后端 thread、不进 LangGraph —— 问答经 Gateway 转发外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
+452 -194
View File
@@ -24,6 +24,12 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
import {
isAbortLikeError,
isTransientStreamTransportError,
@@ -178,6 +184,61 @@ function getRunMetadataStorage(): {
};
}
/**
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
* but **deletes that key on stream onSuccess**. A premature SSE close
* (proxy timeout / false-positive disconnect) looks like success, so the
* reconnect key vanishes while the backend run is still occupying the
* thread. We keep a second key the SDK never touches and only clear it
* after ``runs.get`` confirms a terminal status.
*/
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
function persistActiveRunId(threadId: string, runId: string): void {
if (typeof window === "undefined") return;
const normalized = normalizeStoredRunId(runId);
if (!normalized) return;
try {
window.sessionStorage.setItem(`lg:stream:${threadId}`, normalized);
window.sessionStorage.setItem(
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
normalized,
);
} catch {
/* private-mode / quota */
}
}
function readActiveRunId(threadId: string): string | null {
if (typeof window === "undefined") return null;
try {
return (
normalizeStoredRunId(
window.sessionStorage.getItem(`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`),
) ||
normalizeStoredRunId(window.sessionStorage.getItem(`lg:stream:${threadId}`))
);
} catch {
return null;
}
}
function clearActiveRunId(threadId: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
window.sessionStorage.removeItem(
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
);
} catch {
/* noop */
}
}
function isInflightRunStatus(status: string | undefined): boolean {
return status === "pending" || status === "running";
}
/**
* 显式停止某会话「服务端正在跑」的 run。
*
@@ -192,9 +253,10 @@ export async function stopThreadRun(
if (!threadId || typeof window === "undefined") return;
let runId: string | null = null;
try {
runId = normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
runId =
normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
) || readActiveRunId(threadId);
} catch {
runId = null;
}
@@ -204,11 +266,7 @@ export async function stopThreadRun(
} catch {
/* 已结束 / 不存在 / 网络错误等忽略 */
}
try {
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
} catch {
/* noop */
}
clearActiveRunId(threadId);
}
/**
@@ -222,13 +280,27 @@ export async function stopActiveThreadBeforeClarificationReply(
let storedRunId: string | null = null;
if (threadId && typeof window !== "undefined") {
try {
storedRunId = normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
storedRunId =
normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
) || readActiveRunId(threadId);
} catch {
storedRunId = null;
}
}
// 协助卡出现时图已经 jump_to=end,run 多半已是 terminal。再 stop()/cancel
// 会再触发 onFinish → getState overlay,把刚发出的用户回复冻住、整段攒着出。
if (storedRunId && threadId) {
try {
const run = await getAPIClient().runs.get(threadId, storedRunId);
if (run && !isInflightRunStatus(run.status)) {
clearActiveRunId(threadId);
return;
}
} catch {
/* 404 / 网络:按下面的 best-effort stop 走 */
}
}
if (!thread.isLoading && !storedRunId) return;
try {
await stopThreadRun(threadId);
@@ -511,6 +583,32 @@ export function useThreadStream({
const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0);
const streamReconnectFailureToastShownRef = useRef(false);
const activeRunIdRef = useRef<string | null>(
threadId && typeof window !== "undefined" ? readActiveRunId(threadId) : null,
);
const confirmOrRejoinRef = useRef<
(reason: "finish" | "error" | "poll" | "conflict") => Promise<
"terminal" | "rejoined" | "unknown"
>
>(async () => "unknown");
// Keep the send button in "stop" until we confirm the backend run is
// terminal. A premature SSE close must not look like the turn ended.
const [streamHoldOpen, setStreamHoldOpen] = useState(
() =>
Boolean(
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
),
);
const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0);
// Filled after useStream; onFinish/onError may read it asynchronously.
const threadStreamRef = useRef<{
isLoading: boolean;
stop: () => void | Promise<void>;
joinStream: (runId: string) => Promise<unknown>;
messages: Message[];
values?: AgentThreadState;
} | null>(null);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
@@ -564,9 +662,6 @@ export function useThreadStream({
const runMetadataStorageRef = useRef<
ReturnType<typeof getRunMetadataStorage> | undefined
>(undefined);
const joinStreamRef = useRef<(runId: string) => Promise<unknown>>(
async () => undefined,
);
if (
typeof window !== "undefined" &&
@@ -586,6 +681,11 @@ export function useThreadStream({
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
if (meta.run_id) {
activeRunIdRef.current = meta.run_id;
persistActiveRunId(meta.thread_id, meta.run_id);
setStreamHoldOpen(true);
}
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
@@ -731,21 +831,9 @@ export function useThreadStream({
// A dropped SSE while the backend run is still alive: rejoin instead of
// toasting "Failed to fetch" / 网络波动 and leaving the answer half-done.
if (isTransientStreamTransportError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
const runId = staleThreadId
? runMetadataStorageRef.current?.getItem(`lg:stream:${staleThreadId}`)
: null;
if (
runId &&
streamReconnectAttemptsRef.current < STREAM_RECONNECT_MAX_ATTEMPTS
) {
streamReconnectAttemptsRef.current += 1;
lastStreamActivityAtRef.current = Date.now();
void joinStreamRef.current(runId).catch(() => {
// Reconciliation will retry while the run stays non-terminal.
});
return;
}
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error");
return;
}
setOptimisticMessages([]);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
@@ -754,22 +842,22 @@ export function useThreadStream({
// silently — there is no live run to rejoin.
if (isRunNotFoundError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (staleThreadId && runMetadataStorageRef.current) {
runMetadataStorageRef.current.removeItem(
`lg:stream:${staleThreadId}`,
);
if (staleThreadId) {
clearActiveRunId(staleThreadId);
}
activeRunIdRef.current = null;
setStreamHoldOpen(false);
return;
}
// A "thread not found" 404 means this route cannot accept another turn.
// Move to a fresh chat without a full browser reload; the old reload path
// made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
);
// A "thread not found" 404 means this route cannot accept another turn.
// Move to a fresh chat without a full browser reload; the old reload path
// made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
);
// Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : "";
@@ -780,22 +868,53 @@ export function useThreadStream({
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
navigate(target, { replace: true });
navigate(target, { replace: true });
return;
}
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
const friendly = friendlySendLimitMessage(message);
if (friendly) {
if (isActiveRunConflictError(message)) {
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("conflict").then((result) => {
toast.info(
result === "rejoined"
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
: friendly,
);
});
return;
}
toast.info(friendly);
return;
}
toast.error(message);
},
onFinish(state) {
markStreamActivity();
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
lastStreamActivityAtRef.current = Date.now();
// Do not trust a clean SSE close as "the run is done". streamResumable
// chats keep the backend run alive on disconnect; the SDK still fires
// onFinish and would otherwise restore the send button.
//
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would
// steal the live SSE and dump tokens only at the end.
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("finish").then((result) => {
if (result === "rejoined") {
return;
}
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(false);
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
});
},
});
@@ -805,7 +924,6 @@ export function useThreadStream({
const [threadValueOverrides, setThreadValueOverrides] = useState<
Partial<AgentThreadState>
>({});
const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
// message id shows up, so we can bind the timestamp to the real id (see the
// reconciliation effect below). ``known`` is the set of human ids that
@@ -813,15 +931,192 @@ export function useThreadStream({
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
null,
);
// Human ids known at optimistic-send time; used to clear optimistic only when
// a *new* server human appears (not when length bumps from tool/checkpoint).
const optimisticKnownHumanIdsRef = useRef<Set<string>>(new Set());
// Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length);
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
joinStreamRef.current = (runId: string) => thread.joinStream(runId);
const confirmOrRejoin = useCallback(
async (
reason: "finish" | "error" | "poll" | "conflict",
): Promise<"terminal" | "rejoined" | "unknown"> => {
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
if (!tid) {
setStreamHoldOpen(false);
return "unknown";
}
if (streamReconciliationInFlightRef.current && reason === "poll") {
return "unknown";
}
// Stale finish/error from a prior interrupt must not joinStream over a
// turn the user already started (协助卡提交后的实时流会被打断并攒批)。
if (
(reason === "finish" || reason === "error") &&
(sendInFlightRef.current || threadStreamRef.current?.isLoading)
) {
return "rejoined";
}
streamReconciliationInFlightRef.current = true;
const epochAtStart = sendEpochRef.current;
try {
const client = getAPIClient(isMock);
let runId = activeRunIdRef.current ?? readActiveRunId(tid);
const terminalRunId = runId;
const applyTerminalState = async (status: string) => {
const finalState = await client.threads.getState(tid);
if (
shouldDiscardTerminalOverlay({
epochAtStart,
currentEpoch: sendEpochRef.current,
sendInFlight: sendInFlightRef.current,
terminalRunId,
currentRunId: activeRunIdRef.current,
})
) {
return;
}
clearActiveRunId(tid);
activeRunIdRef.current = null;
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
setThreadValueOverrides(
finalState.values as Partial<AgentThreadState>,
);
if (status === "success") {
setOptimisticMessages([]);
if (reason !== "finish") {
listeners.current.onFinish?.(
finalState.values as AgentThreadState,
);
void queryClient.invalidateQueries({
queryKey: ["threads", "search"],
});
}
} else if (reason !== "finish") {
toast.error(
"本轮生成未能完成,已同步当前对话内容,可重新发起请求。",
);
}
};
if (runId) {
try {
const run = await client.runs.get(tid, runId);
if (TERMINAL_RUN_STATUSES.has(run.status)) {
await applyTerminalState(run.status);
return "terminal";
}
} catch {
runId = null;
}
}
if (!runId) {
try {
const runs = await client.runs.list(tid, { limit: 20 });
const inflight = runs.find((item) =>
isInflightRunStatus(item.status),
);
runId = inflight?.run_id ?? null;
if (runId) {
activeRunIdRef.current = runId;
}
} catch {
runId = null;
}
}
if (!runId) {
setStreamHoldOpen(false);
activeRunIdRef.current = null;
return "unknown";
}
persistActiveRunId(tid, runId);
setStreamHoldOpen(true);
const streamAboutToEnd = reason === "finish" || reason === "error";
if (
threadStreamRef.current?.isLoading &&
!streamAboutToEnd &&
reason !== "conflict"
) {
if (
Date.now() - lastStreamActivityAtRef.current <
STREAM_RECONNECT_IDLE_MS
) {
return "rejoined";
}
// Local SSE reader stalled while the backend run is still alive.
// SDK stop() cancels the server run if lg:stream is set — hide it
// first, abort only the browser reader, then join the live run.
if (reason === "poll") {
if (
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error(
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
);
}
return "rejoined";
}
streamReconnectAttemptsRef.current += 1;
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
if (!stream) {
return "unknown";
}
runMetadataStorageRef.current?.removeItem(`lg:stream:${tid}`);
void Promise.resolve(stream.stop())
.catch(() => undefined)
.finally(() => {
if (threadIdRef.current !== tid) return;
persistActiveRunId(tid, runId);
void stream.joinStream(runId).catch(() => undefined);
});
return "rejoined";
}
}
if (reason !== "conflict") {
if (
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error(
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
);
}
return "rejoined";
}
streamReconnectAttemptsRef.current += 1;
}
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
if (!stream) {
return "unknown";
}
void stream.joinStream(runId).catch(() => {
/* poll retries while the run stays non-terminal */
});
return "rejoined";
} finally {
streamReconciliationInFlightRef.current = false;
}
},
[isMock, onStreamThreadId, queryClient],
);
confirmOrRejoinRef.current = confirmOrRejoin;
useEffect(() => {
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
@@ -830,121 +1125,24 @@ export function useThreadStream({
}
let disposed = false;
let refreshTimer: number | null = null;
const reconcileRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
) {
if (disposed) return;
const dropped = !threadStreamRef.current?.isLoading;
const idleFor = Date.now() - lastStreamActivityAtRef.current;
if (!dropped && idleFor < STREAM_RECONCILIATION_IDLE_MS) {
return;
}
const storage = runMetadataStorageRef.current;
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
if (!runId) return;
streamReconciliationInFlightRef.current = true;
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed) {
return;
}
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
// ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend
// continuing the run.
//
// SDK ``stop()`` is *not* a local-only abort: its onStop hook calls
// ``runs.cancel`` and deletes ``lg:stream:{threadId}``, which is
// exactly "答一半就不答了". Hide the run id first so stop() only
// aborts the browser reader, then join the still-running server run.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
return;
}
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
}
return;
}
streamReconnectAttemptsRef.current += 1;
// Avoid enqueueing another reconnect before this new reader has had
// enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
storage?.removeItem(`lg:stream:${activeThreadId}`);
void Promise.resolve(stream.stop())
.catch(() => {
// Stopping a stale local reader is best-effort; joining is still
// useful if the SDK already considers the stream stopped.
})
.finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return;
try {
storage?.setItem(`lg:stream:${activeThreadId}`, runId);
} catch {
/* noop */
}
void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt.
});
});
return;
}
// The server is already terminal, so the existing SSE listener cannot
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event or already reported a transport
// error and dropped ``isLoading``.
const finalState = await client.threads.getState(activeThreadId);
if (disposed) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") {
setOptimisticMessages([]);
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else {
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
}
// Changing the controlled thread id clears only the local SDK stream
// (it does not cancel the already-terminal backend run). Switching it
// back immediately makes useStream fetch the persisted messages, so
// the page exits the false loading state without a manual refresh.
setOnStreamThreadId(null);
refreshTimer = window.setTimeout(() => {
if (!disposed && threadIdRef.current === activeThreadId) {
setOnStreamThreadId(activeThreadId);
}
}, 0);
} catch {
// A transient status lookup failure must not disrupt a real stream;
// the next polling interval will try again if it is still quiet.
} finally {
if (!disposed) {
streamReconciliationInFlightRef.current = false;
}
if (dropped && idleFor < 800) {
return;
}
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId);
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) {
return;
}
if (!stored && !streamHoldOpen) {
return;
}
await confirmOrRejoin("poll");
};
const interval = window.setInterval(
@@ -955,11 +1153,8 @@ export function useThreadStream({
return () => {
disposed = true;
window.clearInterval(interval);
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
}
};
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
}, [confirmOrRejoin, onStreamThreadId, streamHoldOpen, thread.isLoading]);
// Reset thread-local pending UI state when the caller actually switches AWAY
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
@@ -989,7 +1184,11 @@ export function useThreadStream({
}
startedRef.current = false;
sendInFlightRef.current = false;
sendEpochRef.current += 1;
prevMsgCountRef.current = 0;
optimisticKnownHumanIdsRef.current = new Set();
activeRunIdRef.current = null;
setStreamHoldOpen(false);
setOptimisticMessages([]);
setIsUploading(false);
setThreadValueOverrides({});
@@ -1074,15 +1273,22 @@ export function useThreadStream({
threadValueOverrides.title_provisional,
]);
// Clear optimistic when server messages arrive (count increases)
// Clear optimistic only when the server echoes a *new* human message.
// After 用户协助, a late checkpoint / tool result can bump length without
// the new answer yet — clearing then makes the bubble flash and vanish.
useEffect(() => {
if (optimisticMessages.length === 0) {
return;
}
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
hasServerEchoedOptimisticHuman(
thread.messages,
optimisticKnownHumanIdsRef.current,
)
) {
setOptimisticMessages([]);
}
}, [thread.messages.length, optimisticMessages.length]);
}, [thread.messages, optimisticMessages.length]);
// Bind a just-sent question's ask time to the server-assigned human message
// id once it appears. Persists to localStorage so the time shows on every
@@ -1115,22 +1321,36 @@ export function useThreadStream({
return;
}
sendInFlightRef.current = true;
sendEpochRef.current += 1;
// Drop frozen checkpoint messages so the new SSE can paint live tokens.
// Keep title / title_provisional overlays from the prior reconcile.
setThreadValueOverrides((prev) => {
if (!("messages" in prev)) {
return prev;
}
const { messages: _dropped, ...rest } = prev;
return rest;
});
setStreamHoldOpen(false);
const text = message.text.trim();
// Wall-clock time the question was asked. Stored on the human message so
// the chat can show "几点提问" and have it survive a reload (it round-trips
// through the checkpoint inside additional_kwargs, same as `files`).
const clientTs = new Date().toISOString();
const knownHumanIds = collectHumanMessageIds(
thread.messages,
Array.isArray(thread.values?.messages)
? (thread.values.messages as Message[])
: undefined,
);
// Remember which human messages already exist so the reconciliation
// effect can bind ``clientTs`` to the new one once the server returns it.
pendingAskTimeRef.current = {
ts: clientTs,
known: new Set(
thread.messages
.filter((m) => m.type === "human" && m.id)
.map((m) => m.id as string),
),
known: knownHumanIds,
};
optimisticKnownHumanIdsRef.current = knownHumanIds;
// Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length;
@@ -1355,6 +1575,18 @@ export function useThreadStream({
error instanceof Error ? error.message : String(error);
const friendly = friendlySendLimitMessage(message);
if (friendly) {
setIsUploading(false);
if (isActiveRunConflictError(message)) {
setOptimisticMessages([]);
setStreamHoldOpen(true);
const result = await confirmOrRejoin("conflict");
toast.info(
result === "rejoined"
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
: friendly,
);
return;
}
setOptimisticMessages((messages) => {
const human = messages.find((item) => item.type === "human");
const kept = human ? [human] : [];
@@ -1368,7 +1600,6 @@ export function useThreadStream({
} as Message,
];
});
setIsUploading(false);
return;
}
setOptimisticMessages([]);
@@ -1378,31 +1609,58 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin],
);
// Merge thread with optimistic messages for display
const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current;
const runId =
activeRunIdRef.current ?? (tid ? readActiveRunId(tid) : null);
setStreamHoldOpen(false);
activeRunIdRef.current = null;
if (tid && runId) {
persistActiveRunId(tid, runId);
}
try {
await Promise.resolve(threadStreamRef.current?.stop());
} catch {
/* local abort is best-effort */
}
if (tid && runId) {
try {
await getAPIClient(isMock).runs.cancel(tid, runId);
} catch {
/* already stopped / missing */
}
clearActiveRunId(tid);
}
}, [isMock]);
// Merge thread with optimistic messages for display. While a live SSE is
// open, never let a stale getState snapshot's `messages` win — that is what
// made 协助卡提交后「发送中」攒到结束才一口气出字。
const mergedValues =
Object.keys(threadValueOverrides).length > 0
? {
...thread.values,
...threadValueOverrides,
}
: thread.values;
mergeStreamDisplayValues(
thread.values as AgentThreadState | undefined,
threadValueOverrides,
thread.isLoading,
) ?? thread.values;
const mergedMessages =
Array.isArray(mergedValues.messages)
Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const mergedThread =
optimisticMessages.length > 0 || mergedValues !== thread.values
? ({
...thread,
values: mergedValues,
messages: [...mergedMessages, ...optimisticMessages],
} as typeof thread)
: thread;
const mergedThread = {
...thread,
values: mergedValues,
messages:
optimisticMessages.length > 0
? [...mergedMessages, ...optimisticMessages]
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop,
} as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
test("live streaming ignores frozen checkpoint messages so tokens can paint", () => {
const live = {
title: "live",
messages: [{ type: "human", id: "h1", content: "A" }],
};
const overrides = {
title: "frozen",
messages: [{ type: "human", id: "h1", content: "A" }],
};
const duringStream = mergeStreamDisplayValues(live, overrides, true);
assert.deepEqual(duringStream?.messages, live.messages);
assert.equal(duringStream?.title, "frozen");
const afterStream = mergeStreamDisplayValues(live, overrides, false);
assert.equal(afterStream?.title, "frozen");
assert.deepEqual(afterStream?.messages, overrides.messages);
});
test("a later send discards the previous run's getState overlay", () => {
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 1,
currentEpoch: 2,
sendInFlight: false,
terminalRunId: "old-run",
currentRunId: "old-run",
}),
true,
);
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 2,
currentEpoch: 2,
sendInFlight: true,
terminalRunId: "old-run",
currentRunId: "old-run",
}),
true,
);
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 2,
currentEpoch: 2,
sendInFlight: false,
terminalRunId: "old-run",
currentRunId: "new-run",
}),
true,
);
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 1,
currentEpoch: 1,
sendInFlight: false,
terminalRunId: "run-1",
currentRunId: "run-1",
}),
false,
);
});
test("optimistic human stays until a new server human id appears", () => {
const known = collectHumanMessageIds([
{ type: "human", id: "h1", content: "first" } as Message,
]);
const afterToolOnly = [
{ type: "human", id: "h1", content: "first" },
{ type: "tool", id: "t1", content: "OK" },
] as Message[];
assert.equal(hasServerEchoedOptimisticHuman(afterToolOnly, known), false);
const afterEcho = [
...afterToolOnly,
{ type: "human", id: "h2", content: "按方案 A" },
] as Message[];
assert.equal(hasServerEchoedOptimisticHuman(afterEcho, known), true);
});
@@ -0,0 +1,98 @@
import type { Message } from "@langchain/langgraph-sdk";
/**
* After a run ends (especially an interrupt / 用户协助卡), `confirmOrRejoin`
* snapshots `threads.getState()` into an overlay so a premature SSE close can
* still show the checkpoint. That overlay must never win over a *new* live
* stream: `overrides.messages` would freeze the previous turn and dump the
* next answer only when the next `getState()` lands.
*/
export function mergeStreamDisplayValues<T extends Record<string, unknown>>(
live: T | undefined,
overrides: Partial<T>,
isLiveStreaming: boolean,
): T | undefined {
const overrideKeys = Object.keys(overrides);
if (overrideKeys.length === 0) {
return live;
}
if (isLiveStreaming) {
const { messages: _ignored, ...rest } = overrides as Partial<T> & {
messages?: unknown;
};
if (Object.keys(rest).length === 0) {
return live;
}
return { ...(live as T), ...rest };
}
return { ...(live as T), ...overrides };
}
export function shouldDiscardTerminalOverlay(opts: {
epochAtStart: number;
currentEpoch: number;
sendInFlight: boolean;
terminalRunId: string | null;
currentRunId: string | null;
}): boolean {
if (opts.currentEpoch !== opts.epochAtStart) {
return true;
}
if (opts.sendInFlight) {
return true;
}
if (
opts.currentRunId &&
opts.terminalRunId &&
opts.currentRunId !== opts.terminalRunId
) {
return true;
}
return false;
}
export function collectHumanMessageIds(
...groups: Array<Message[] | undefined>
): Set<string> {
const ids = new Set<string>();
for (const messages of groups) {
if (!Array.isArray(messages)) continue;
for (const message of messages) {
if (
message?.type === "human" &&
typeof message.id === "string" &&
message.id &&
!message.id.startsWith("opt-")
) {
ids.add(message.id);
}
}
}
return ids;
}
/**
* Drop the optimistic human bubble only when the server has echoed a *new*
* human message. A late checkpoint / tool result that merely increases
* `messages.length` must not make the user's input vanish.
*/
export function hasServerEchoedOptimisticHuman(
serverMessages: Message[] | undefined,
knownHumanIds: Set<string>,
): boolean {
if (!Array.isArray(serverMessages)) {
return false;
}
for (const message of serverMessages) {
if (
message?.type === "human" &&
typeof message.id === "string" &&
message.id &&
!message.id.startsWith("opt-") &&
!knownHumanIds.has(message.id)
) {
return true;
}
}
return false;
}