From 37517c27783ccb5fd99cda1e49ee49ba1752dc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 28 Aug 2026 13:01:46 +0800 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20=E5=B5=8C=E5=A5=97=E6=B7=B1?= =?UTF-8?q?=E9=93=BE=E6=94=B9=E4=B8=BA=E6=8C=89=E8=B7=AF=E7=94=B1=E5=8E=BB?= =?UTF-8?q?=20chrome=EF=BC=8C=E9=A1=B6=E5=B1=82=E4=B8=8D=E5=86=8D=E6=B8=85?= =?UTF-8?q?=E9=99=A4=E5=85=B1=E7=94=A8=20embed.session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免全量父页清掉老版内层深链仍依赖的 embed.session;深链问答/会商/大屏绘制由 isHostNestedSurfacePath 控制 chrome,并抽取对话历史 Tab 共享行组件。 Co-authored-by: Cursor --- .../src/core/embed/embed-session.test.ts | 51 +-- frontend-web/src/core/embed/embed-session.ts | 297 ++++++------------ .../page-layout/host-nested-surface.test.ts | 70 +++++ .../core/page-layout/host-nested-surface.ts | 21 ++ frontend-web/src/pages/PageRoutes.tsx | 18 +- .../chats-history/RoundtableHistoryTab.tsx | 179 +++++++++++ .../pages/chats-history/WritingHistoryTab.tsx | 149 +++++++++ .../chats-history/history-tab-shared.tsx | 160 ++++++++++ 8 files changed, 695 insertions(+), 250 deletions(-) create mode 100644 frontend-web/src/core/page-layout/host-nested-surface.test.ts create mode 100644 frontend-web/src/core/page-layout/host-nested-surface.ts create mode 100644 frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx create mode 100644 frontend-web/src/pages/chats-history/WritingHistoryTab.tsx create mode 100644 frontend-web/src/pages/chats-history/history-tab-shared.tsx diff --git a/frontend-web/src/core/embed/embed-session.test.ts b/frontend-web/src/core/embed/embed-session.test.ts index d3e867be..28638e42 100644 --- a/frontend-web/src/core/embed/embed-session.test.ts +++ b/frontend-web/src/core/embed/embed-session.test.ts @@ -1,6 +1,5 @@ /** * 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 @@ -82,14 +81,11 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () => 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); @@ -97,11 +93,9 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () => }); }); -test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => { +test("top ignores old nested embed.session without clearing it", () => { withSharedTab(({ storage, asTop }) => { asTop(); - syncEmbedSessionFromSearch(""); - // 模拟未升级的老版内层:仍写共用键且没有 topOwned。 storage.setItem( "embed.session", JSON.stringify({ @@ -113,15 +107,14 @@ test("top parent reclaims pollution from an old nested iframe (no topOwned)", () isolate: false, }), ); - - // 外层点菜单重算布局时会读 session —— 必须立刻自愈。 assert.equal(isEmbedActive(), false); assert.equal(isChromeHidden(), false); - assert.equal(storage.getItem("embed.session"), null); + // 内层老版仍要读到 embed=true + assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true); }); }); -test("top sync after old nested pollution does not mark owner", () => { +test("top sync does not wipe unowned embed.session", () => { withSharedTab(({ storage, asTop }) => { asTop(); storage.setItem( @@ -137,12 +130,11 @@ test("top sync after old nested pollution does not mark owner", () => { ); syncEmbedSessionFromSearch(""); assert.equal(isEmbedActive(), false); - assert.equal(isChromeHidden(), false); - assert.equal(storage.getItem("embed.session"), null); + assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true); }); }); -test("nested iframe reclaims a polluted shared embed.session key", () => { +test("nested iframe leaves the shared embed.session for old inner pages", () => { withSharedTab(({ storage, asTop, asIframe }) => { asTop(); storage.setItem( @@ -159,11 +151,11 @@ test("nested iframe reclaims a polluted shared embed.session key", () => { asIframe(); syncEmbedSessionFromSearch("?embed=1"); + assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true); + assert.ok(storage.getItem("embed.session.nested")); asTop(); assert.equal(isEmbedActive(), false); - assert.equal(isChromeHidden(), false); - assert.equal(storage.getItem("embed.session"), null); }); }); @@ -184,31 +176,7 @@ test("nested iframe keeps the top window's own embed session", () => { }); }); -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", () => { +test("top ignore strips chrome in memory but keeps isolate", () => { withSharedTab(({ storage, asTop }) => { asTop(); storage.setItem( @@ -224,5 +192,6 @@ test("top reclaim strips embed but keeps isolate without nested help", () => { ); assert.equal(isEmbedActive(), false); assert.equal(isIsolatedSession(), true); + assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true); }); }); diff --git a/frontend-web/src/core/embed/embed-session.ts b/frontend-web/src/core/embed/embed-session.ts index cb44090b..721d6954 100644 --- a/frontend-web/src/core/embed/embed-session.ts +++ b/frontend-web/src/core/embed/embed-session.ts @@ -16,34 +16,17 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs"; * 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate` * 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。 * - * 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏; - * `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。 - * - * 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响): - * - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage - * (跨标签共享 → 会污染正常页)。 - * - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、 - * 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。 - * - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等 - * localStorage 偏好。 - * - * 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」 - * 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署) - * **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层 - * 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉, - * 路由看起来却没变。因此: - * - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`; - * - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时, - * 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。 + * 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。 + * 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。 + * **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时 + * 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。 + * 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome + * (见 `isHostNestedSurfacePath`),不依赖这份会话。 */ 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 }; @@ -61,7 +44,6 @@ function getSessionStorage(): Storage | null { } } -/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */ function isTopLevelWindow(): boolean { const w = getWindow(); if (!w) return true; @@ -78,28 +60,14 @@ function embedSessionKey(): string { export type EmbedTheme = "light" | "dark"; -/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */ export interface EmbedSession { - /** 嵌入模式:去全局 chrome + 强制主题。 */ embed: boolean; - /** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */ theme: EmbedTheme; - /** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */ hideJump: boolean; - /** Only hide the global Header while retaining PageSidebar and normal app behavior. */ hideHeader: boolean; - /** - * 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。 - * 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要 - * 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。 - */ hideChrome: boolean; - /** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */ isolate: boolean; - /** - * 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe - * (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。 - */ + /** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */ topOwned?: boolean; } @@ -142,49 +110,12 @@ function emptySession(): EmbedSession { }; } -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; @@ -192,31 +123,25 @@ function markTopChromeOwner(session: EmbedSession): void { w[TOP_CHROME_OWNER_FLAG] = true; } -/** - * 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。 - * 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。 - */ -function reclaimUnownedTopChrome(): void { - if (!isTopLevelWindow()) return; +function readStoredSession(): EmbedSession | null { 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)); + return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null); } catch { - /* noop */ + return null; + } +} + +function readSharedTopSession(): EmbedSession | null { + try { + return parseEmbedSession(getSessionStorage()?.getItem(EMBED_SESSION_KEY) ?? null); + } catch { + return null; } } /** - * 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份 - * 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。 + * 旧版嵌套页写过共用键时,新版嵌套页复制一份到 nested 键自己用;**不删除**共用键, + * 避免老版内层丢掉 embed 后把导航栏放出来。 */ function migrateLegacySharedSessionIfNested(): void { if (isTopLevelWindow()) return; @@ -230,30 +155,12 @@ function migrateLegacySharedSessionIfNested(): void { } } -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 布尔参数的三态:显式开 / 显式关 / 未带。 */ type FlagParam = "on" | "off" | null; function toSearchParams(search: string): URLSearchParams { return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); } -/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */ function readFlagParam(search: string, key: string): FlagParam { const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase(); if (!v) return null; @@ -262,32 +169,26 @@ function readFlagParam(search: string, key: string): FlagParam { return null; } -/** 读 URL 里的 `embed` 三态。 */ export function readEmbedParam(search: string): FlagParam { return readFlagParam(search, "embed"); } -/** 读 URL 里的 `hideJump` 三态。 */ export function readHideJumpParam(search: string): FlagParam { return readFlagParam(search, "hideJump"); } -/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */ export function readHideHeaderParam(search: string): FlagParam { return readFlagParam(search, "hideHeader"); } -/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */ export function readHideChromeParam(search: string): FlagParam { return readFlagParam(search, "hideChrome"); } -/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */ export function readIsolateParam(search: string): FlagParam { return readFlagParam(search, "isolate"); } -/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */ export function readEmbedThemeParam(search: string): string { return (toSearchParams(search).get("theme") ?? "").trim(); } @@ -301,34 +202,32 @@ function searchEnablesChromeHide(search: string): boolean { ); } -/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */ +/** + * 读本浏览上下文的嵌入会话。顶层若看到无主(非 topOwned)的 chrome 标志,视为同源 + * 嵌套页写的,**读取时忽略、不写回**,以免清掉内层老版仍在用的 `embed.session`。 + */ export function readEmbedSession(): EmbedSession | null { - try { - // 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。 - reclaimUnownedTopChrome(); - return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null); - } catch { - return null; + const s = readStoredSession(); + if (!s) return null; + if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) { + return { + ...s, + embed: false, + hideJump: false, + hideHeader: false, + hideChrome: false, + topOwned: false, + }; } + return s; } -/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */ function writeEmbedSession(next: EmbedSession): void { try { const store = getSessionStorage(); if (!store) return; const key = embedSessionKey(); - if ( - !next.embed && - !next.hideJump && - !next.hideHeader && - !next.hideChrome && - !next.isolate - ) { - store.removeItem(key); - return; - } - const payload: EmbedSession = { + let payload: EmbedSession = { embed: next.embed, theme: next.theme, hideJump: next.hideJump, @@ -337,37 +236,56 @@ function writeEmbedSession(next: EmbedSession): void { isolate: next.isolate, }; if (next.topOwned) payload.topOwned = true; + + // 顶层未认领时,写 isolate 等字段不得覆盖内层老版仍占用的 chrome 标志。 + if ( + isTopLevelWindow() && + key === EMBED_SESSION_KEY && + !payload.topOwned && + getWindow()?.[TOP_CHROME_OWNER_FLAG] !== true + ) { + const shared = readSharedTopSession(); + if (shared && sessionHidesChrome(shared) && !shared.topOwned) { + payload = { + ...payload, + embed: shared.embed, + theme: shared.theme, + hideJump: shared.hideJump, + hideHeader: shared.hideHeader, + hideChrome: shared.hideChrome, + }; + } + } + + if ( + !payload.embed && + !payload.hideJump && + !payload.hideHeader && + !payload.hideChrome && + !payload.isolate + ) { + store.removeItem(key); + return; + } store.setItem(key, JSON.stringify(payload)); } catch { /* noop */ } } -/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */ export function isEmbedActive(): boolean { return readEmbedSession()?.embed === true; } -/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */ export function isJumpBarHidden(): boolean { return readEmbedSession()?.hideJump === true; } -/** - * Whether this tab should hide its global Header. - * `embed` and `hideChrome` also hide the Header, while `hideHeader` alone - * deliberately leaves PageSidebar visible. - */ export function isHeaderHidden(): boolean { const session = readEmbedSession(); return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true; } -/** - * 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。 - * `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。 - * 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。 - */ export function isChromeHidden(): boolean { const s = readEmbedSession(); return s?.embed === true || s?.hideChrome === true; @@ -377,28 +295,38 @@ export function isHideChromeActive(): boolean { return readEmbedSession()?.hideChrome === true; } -/** - * 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。 - * `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`, - * 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。 - */ export function isIsolatedSession(): boolean { return readEmbedSession()?.isolate === true; } -/** - * 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。 - * **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时, - * 之前置位的开关不会被抹掉。 - * - * 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的 - * chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。 - */ export function syncEmbedSessionFromSearch(search: string): void { - migrateLegacySharedSessionIfNested(); - if (isTopLevelWindow()) reclaimUnownedTopChrome(); + if (!isTopLevelWindow()) { + migrateLegacySharedSessionIfNested(); + applySearchToSession(search, readStoredSession() ?? emptySession(), false); + return; + } - const cur = readEmbedSession() ?? emptySession(); + const stored = readStoredSession() ?? emptySession(); + if (searchEnablesChromeHide(search)) { + applySearchToSession(search, stored, true); + return; + } + if (isTopChromeOwned(stored)) { + applySearchToSession(search, stored, true); + return; + } + // 无主共用键:只处理 isolate URL,绝不把 chrome 标志写成 false 覆盖内层。 + const isolateParam = readIsolateParam(search); + if (isolateParam === null) return; + const isolate = isolateParam === "on"; + writeEmbedSession({ ...stored, isolate, topOwned: false }); +} + +function applySearchToSession( + search: string, + cur: EmbedSession, + topOwned: boolean, +): void { const next: EmbedSession = { ...cur }; const embedParam = readEmbedParam(search); @@ -425,27 +353,11 @@ 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; - } - } - + next.topOwned = topOwned; writeEmbedSession(next); - if (isTopLevelWindow()) markTopChromeOwner(next); - else reclaimPollutedTopChrome(); + if (topOwned) markTopChromeOwner(next); } -/** - * 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。 - * 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入, - * 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。 - */ function currentHashSearch(): string { const w = getWindow(); if (!w) return ""; @@ -454,18 +366,10 @@ function currentHashSearch(): string { return i >= 0 ? hash.slice(i) : ""; } -/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */ export function syncEmbedSessionFromCurrentUrl(): void { syncEmbedSessionFromSearch(currentHashSearch()); } -/** - * 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用: - * - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁); - * - URL 显式 `embed=0` → 不强制(undefined); - * - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。 - * 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。 - */ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined { const param = readEmbedParam(search); if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search)); @@ -474,12 +378,7 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined return session?.embed ? session.theme : undefined; } -/** - * 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。 - * 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]] - * `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。 - */ export function setIsolatedSession(on: boolean): void { - const cur = readEmbedSession() ?? emptySession(); - writeEmbedSession({ ...cur, isolate: on }); + const stored = readStoredSession() ?? emptySession(); + writeEmbedSession({ ...stored, isolate: on }); } diff --git a/frontend-web/src/core/page-layout/host-nested-surface.test.ts b/frontend-web/src/core/page-layout/host-nested-surface.test.ts new file mode 100644 index 00000000..84d28786 --- /dev/null +++ b/frontend-web/src/core/page-layout/host-nested-surface.test.ts @@ -0,0 +1,70 @@ +/** + * host-nested-surface 深链嵌套页路径判定。 + * npx esbuild src/core/page-layout/host-nested-surface.test.ts --bundle --format=esm \ + * --platform=node --outfile=.tmp-tests/host-nested-surface.test.mjs + * node --test .tmp-tests/host-nested-surface.test.mjs + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isHostNestedSurfacePath } from "./host-nested-surface"; + +test("matches deeplink single-agent workspaces", () => { + assert.equal( + isHostNestedSurfacePath( + "/page/strategy/task-management/plan-task-sentiment/agent/abc/chats", + ), + true, + ); + assert.equal( + isHostNestedSurfacePath( + "/page/strategy/task-management/plan-task/plan-task-fx/agent/abc/chats/thread-1", + ), + true, + ); + assert.equal( + isHostNestedSurfacePath( + "/page/strategy/task-management/plan-task/plan-task-action/agent/abc/chats/new", + ), + true, + ); + assert.equal( + isHostNestedSurfacePath( + "/page/strategy/task-management/plan-task-agentfx/agent/agentfx-analyst/chats", + ), + true, + ); +}); + +test("matches deeplink roundtable and dashboard-agent", () => { + assert.equal( + isHostNestedSurfacePath( + "/page/strategy/task-management/plan-task/plan-task-fx/roundtable", + ), + true, + ); + assert.equal( + isHostNestedSurfacePath("/page/strategy/dashboard-agent"), + true, + ); +}); + +test("does not match full-mode pages we use ourselves", () => { + assert.equal( + isHostNestedSurfacePath("/page/strategy/task-management/xk-task-jc"), + false, + ); + assert.equal( + isHostNestedSurfacePath("/page/strategy/task-management/plan-task-sentiment"), + false, + ); + assert.equal( + isHostNestedSurfacePath("/page/strategy/qa/multi-agent-roundtable"), + false, + ); + assert.equal( + isHostNestedSurfacePath("/page/workspace/roundtable/planning"), + false, + ); + assert.equal(isHostNestedSurfacePath("/page/workspace/chats/new"), false); +}); diff --git a/frontend-web/src/core/page-layout/host-nested-surface.ts b/frontend-web/src/core/page-layout/host-nested-surface.ts new file mode 100644 index 00000000..1138699d --- /dev/null +++ b/frontend-web/src/core/page-layout/host-nested-surface.ts @@ -0,0 +1,21 @@ +/** + * 外部系统 iframe 深链套用的页面:单智能体问答、多智能体会商、大屏绘制。 + * 这些路由只给宿主嵌套,不走全量 Header / PageSidebar;chrome 由路径决定, + * 不依赖(也不改写)同源 iframe 共用的 `embed.session`。 + */ + +/** 任务管理下的深链会商(goPath=`*-A`)。 */ +const TASK_ROUNDTABLE_RE = + /\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action)\/roundtable(?:\/|$)/; + +/** 任务管理下的深链单智能体问答(含 agentfx)。 */ +const TASK_AGENT_CHATS_RE = + /\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx)\/agent\/[^/]+\/chats/; + +export function isHostNestedSurfacePath(pathname: string): boolean { + const path = (pathname || "").split("?")[0] || ""; + if (path.includes("/strategy/dashboard-agent")) return true; + if (TASK_ROUNDTABLE_RE.test(path)) return true; + if (TASK_AGENT_CHATS_RE.test(path)) return true; + return false; +} diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index 3901e75a..50bb0dc4 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -16,6 +16,7 @@ import { readHideHeaderParam, readHideChromeParam, } from "@/core/embed/embed-session"; +import { isHostNestedSurfacePath } from "@/core/page-layout/host-nested-surface"; import { useTheme } from "@/contexts/ThemeContext"; import { useLocation } from "react-router-dom"; import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu"; @@ -91,23 +92,20 @@ function PageLayout() { location.pathname.startsWith("/page/workspace/chats/") ); }, [location.pathname, location.search]); - // 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页, - // 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false - // (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** - // (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。 - const forceChromeForDashboardAgent = - !forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent"); + // 深链嵌套页(单智能体问答 / 多智能体会商 / 大屏绘制)只给宿主 iframe 套用, + // 路径上就不带全局 Header / PageSidebar,不靠改写共用 embed.session。 + const hostNestedSurface = isHostNestedSurfacePath(location.pathname); const pageSidebarAllowed = - (forceChromeForDashboardAgent || chromeByPreference) && + chromeByPreference && !isChatsIframeWorkspacePath(location.pathname) && !embedActive && - // `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。 - !chromeHidden; + !chromeHidden && + !hostNestedSurface; const compactToolboxAllowed = !chromeByPreference && - !forceChromeForDashboardAgent && !embedActive && !chromeHidden && + !hostNestedSurface && !isChatsIframeWorkspacePath(location.pathname); // h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100% diff --git a/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx b/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx new file mode 100644 index 00000000..637cb801 --- /dev/null +++ b/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx @@ -0,0 +1,179 @@ +"use client"; + +/** + * 「对话历史」页 —— 多智能体会商 Tab。 + * + * 数据与原侧栏「多智能体会商」列表同源(当前用户的个人草稿 roundtable_drafts, + * useRoundtableDrafts 不带 taskId);点击行为也一致:暂存 sessionStorage + * 「roundtable:pending-draft」后跳转会商页,由页面挂载时消费并加载该草稿。 + * 任务维度会商(task-draft,按 taskId 全任务共享、不分权)不随个人历史走, + * 仍只在对应任务工作区里查看。 + */ + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Share2 } from "lucide-react"; +import { toast } from "sonner"; + +import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs"; +import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; +import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts"; +import type { DraftMeta } from "@/roundtable-planning/api/drafts"; +import { desensitize } from "@/lib/desensitize"; + +import { + DeleteIcon, + HistoryRenameDialog, + HistoryRow, + HistoryRowIconButton, + HistoryStatusPill, + RenameIcon, +} from "./history-tab-shared"; + +const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning"; + +/** 草稿后台作业状态 → 小标签(映射与原侧栏列表一致)。 */ +function statusOf(draft: DraftMeta): { label: string; className: string } { + if (draft.status === "running" || draft.status === "queued") { + return { label: "进行中", className: "text-primary" }; + } + if (draft.status === "awaiting_input") { + return { label: "待澄清", className: "text-amber-500" }; + } + if (draft.status === "error" || draft.status === "cancelled") { + return { label: "出错", className: "text-rose-500" }; + } + // 无进行中作业时展示本次会商所用业务链(无链条 → 自由模式)。 + return { label: draft.chainTitle || "自由模式", className: "text-muted-foreground" }; +} + +export function RoundtableHistoryTab({ + query, + onTotalChange, +}: { + query: string; + onTotalChange?: (total: number) => void; +}) { + const navigate = useNavigate(); + const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts(); + const [renameId, setRenameId] = useState(null); + const [renameTitle, setRenameTitle] = useState(""); + const [shareId, setShareId] = useState(null); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const keyword = query.trim().toLowerCase(); + const filtered = keyword + ? drafts.filter((draft) => + (draft.title || "未命名草稿").toLowerCase().includes(keyword), + ) + : drafts; + + useEffect(() => { + onTotalChange?.(filtered.length); + }, [filtered.length, onTotalChange]); + + const openDraft = (draft: DraftMeta) => { + sessionStorage.setItem("roundtable:pending-draft", draft.id); + navigate(ROUNDTABLE_PATH); + }; + + const handleRename = async (id: string, title: string) => { + try { + await rename(id, title); + toast.success("已重命名"); + } catch { + toast.error("重命名失败,请重试"); + } finally { + setRenameId(null); + } + }; + + const handleDelete = async (draft: DraftMeta) => { + if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return; + try { + await remove(draft.id); + toast.success("已删除会商记录"); + } catch { + toast.error("删除失败,请重试"); + } + }; + + if (loading && drafts.length === 0) { + return ( +
+ 加载中… +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ {keyword ? "没有匹配的会商记录" : "暂无会商记录"} +
+ ); + } + + return ( + <> + {filtered.map((draft) => { + const status = statusOf(draft); + return ( + + + · + {formatRelative(draft.updatedAt)} + + } + onOpen={() => openDraft(draft)} + > + { + setRenameId(draft.id); + setRenameTitle(draft.title || ""); + }} + > + + + setShareId(draft.id)}> + + + void handleDelete(draft)} + > + + + + ); + })} + + setRenameId(null)} + onConfirm={(title) => renameId && void handleRename(renameId, title)} + /> + + { + if (!o) setShareId(null); + }} + /> + + ); +} diff --git a/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx b/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx new file mode 100644 index 00000000..90a3ace0 --- /dev/null +++ b/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx @@ -0,0 +1,149 @@ +"use client"; + +/** + * 「对话历史」页 —— AI写作 Tab。 + * + * 数据与原侧栏「最近写作」列表同源(useAIWritingSessions);点击行为一致: + * 暂存 sessionStorage「ai-writing:pending-session」后跳转 AI 写作页,由 + * AIWritingPanel 挂载时消费并打开该会话。 + */ + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +import { + formatRelative, + STATUS_LABELS, +} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; +import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions"; +import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions"; +import { desensitize } from "@/lib/desensitize"; + +import { + DeleteIcon, + HistoryRenameDialog, + HistoryRow, + HistoryRowIconButton, + HistoryStatusPill, + RenameIcon, +} from "./history-tab-shared"; + +const AI_WRITING_PATH = "/page/workspace/ai-writing"; + +export function WritingHistoryTab({ + query, + onTotalChange, +}: { + query: string; + onTotalChange?: (total: number) => void; +}) { + const navigate = useNavigate(); + const { sessions, loading, refresh, rename, remove } = useAIWritingSessions(); + const [renameId, setRenameId] = useState(null); + const [renameTitle, setRenameTitle] = useState(""); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const keyword = query.trim().toLowerCase(); + const filtered = keyword + ? sessions.filter((session) => session.title.toLowerCase().includes(keyword)) + : sessions; + + useEffect(() => { + onTotalChange?.(filtered.length); + }, [filtered.length, onTotalChange]); + + const openSession = (session: AIWritingSessionSummary) => { + sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session)); + navigate(AI_WRITING_PATH); + }; + + const handleRename = async (id: string, title: string) => { + try { + await rename(id, title); + toast.success("已重命名"); + } catch { + toast.error("重命名失败,请重试"); + } finally { + setRenameId(null); + } + }; + + const handleDelete = async (session: AIWritingSessionSummary) => { + if (!window.confirm("确定删除这条写作记录吗?此操作不可恢复。")) return; + try { + await remove(session.id); + toast.success("已删除写作记录"); + } catch { + toast.error("删除失败,请重试"); + } + }; + + if (loading && sessions.length === 0) { + return ( +
+ 加载中… +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ {keyword ? "没有匹配的写作记录" : "暂无写作记录"} +
+ ); + } + + return ( + <> + {filtered.map((session) => { + const { label, color } = STATUS_LABELS[session.status] ?? { + label: session.status, + color: "text-muted-foreground", + }; + return ( + + + · + {formatRelative(session.updated_at)} + + } + onOpen={() => openSession(session)} + > + { + setRenameId(session.id); + setRenameTitle(session.title); + }} + > + + + void handleDelete(session)} + > + + + + ); + })} + + setRenameId(null)} + onConfirm={(title) => renameId && void handleRename(renameId, title)} + /> + + ); +} diff --git a/frontend-web/src/pages/chats-history/history-tab-shared.tsx b/frontend-web/src/pages/chats-history/history-tab-shared.tsx new file mode 100644 index 00000000..eefa49b3 --- /dev/null +++ b/frontend-web/src/pages/chats-history/history-tab-shared.tsx @@ -0,0 +1,160 @@ +"use client"; + +/** + * 「对话历史」页(/page/workspace/chats)业务历史 Tab(多智能体会商 / AI写作 / + * 深度研究)的共享小组件。行样式与「对话」Tab 保持一致:标题行 + 次行元信息 + + * 悬浮操作按钮,让四个 Tab 在同一页里观感统一。 + */ + +import { useEffect, useState, type ReactNode } from "react"; +import { Pencil, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { isIMEComposing } from "@/lib/ime"; +import { cn } from "@/lib/utils"; + +/** 次行状态小标签(对齐侧栏列表的状态药丸配色)。 */ +export function HistoryStatusPill({ + label, + className, +}: { + label: string; + className?: string; +}) { + return ( + {label} + ); +} + +/** + * 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转 + * 对应工作台,由页面挂载时消费),操作按钮经 children 传入。 + */ +export function HistoryRow({ + title, + meta, + onOpen, + children, +}: { + title: ReactNode; + meta: ReactNode; + onOpen: () => void; + children?: ReactNode; +}) { + return ( +
+ + {/* Row actions — kept out of the open button so clicks don't trigger it. */} +
+ {children} +
+
+ ); +} + +/** 重命名 / 删除图标操作按钮(与「对话」Tab 的收藏/置顶按钮同规格)。 */ +export function HistoryRowIconButton({ + title, + onClick, + children, +}: { + title: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +export function RenameIcon() { + return ; +} + +export function DeleteIcon() { + return ; +} + +/** + * 自包含重命名弹窗:打开时用 initialTitle 初始化输入框,确认时回调新标题。 + * 三个 Tab 的重命名接口都是「(id, title) → Promise」,由调用方闭包绑定 id。 + */ +export function HistoryRenameDialog({ + open, + initialTitle, + dialogTitle, + onCancel, + onConfirm, +}: { + open: boolean; + initialTitle: string; + dialogTitle: string; + onCancel: () => void; + onConfirm: (title: string) => void; +}) { + const [value, setValue] = useState(initialTitle); + + useEffect(() => { + if (open) setValue(initialTitle); + }, [open, initialTitle]); + + return ( + (o ? undefined : onCancel())}> + + + {dialogTitle} + +
+ setValue(e.target.value)} + placeholder="输入新名称" + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + if (value.trim()) onConfirm(value.trim()); + } + }} + /> +
+ + + + +
+
+ ); +} From 36653c53a6099b565f2647b44fdea70d8a5874dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 28 Aug 2026 13:01:58 +0800 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E9=A1=B5=E6=B7=B1=E5=BA=A6=E7=A0=94?= =?UTF-8?q?=E7=A9=B6=20Tab=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../chats-history/ResearchHistoryTab.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx diff --git a/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx b/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx new file mode 100644 index 00000000..5d89b688 --- /dev/null +++ b/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx @@ -0,0 +1,173 @@ +"use client"; + +/** + * 「对话历史」页 —— 深度研究 Tab。 + * + * 数据来自 deep-research 会话列表(useResearchSessions)。点击跳转深度研究页并 + * 带 ``?session=``,由 DeepResearchWorkbench 挂载时读取该参数直接选中并 + * 打开这条研究记录。 + */ + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +import { + useResearchActions, + useResearchSessions, + type DeepResearchSession, +} from "@/strategy-components/api/deep-research"; +import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; +import { desensitize } from "@/lib/desensitize"; + +import { + DeleteIcon, + HistoryRenameDialog, + HistoryRow, + HistoryRowIconButton, + HistoryStatusPill, + RenameIcon, +} from "./history-tab-shared"; + +const DEEP_RESEARCH_PATH = "/page/workspace/deep-research"; + +/** 状态文案与配色对齐 DeepResearchWorkbench 的研究记录侧栏。 */ +const RESEARCH_STATUS: Record< + DeepResearchSession["status"], + { label: string; className: string } +> = { + draft: { label: "待开始", className: "text-muted-foreground" }, + collecting: { label: "收集中", className: "text-primary" }, + awaiting_report: { label: "待撰写", className: "text-amber-500" }, + running: { label: "进行中", className: "text-primary" }, + awaiting_input: { label: "等待确认", className: "text-amber-500" }, + completed: { label: "已完成", className: "text-emerald-600" }, + failed: { label: "失败", className: "text-rose-500" }, + cancelled: { label: "已取消", className: "text-muted-foreground" }, +}; + +export function ResearchHistoryTab({ + query, + onTotalChange, +}: { + query: string; + onTotalChange?: (total: number) => void; +}) { + const navigate = useNavigate(); + const { data, isLoading } = useResearchSessions(100); + const actions = useResearchActions(); + const [renameId, setRenameId] = useState(null); + const [renameTitle, setRenameTitle] = useState(""); + + const sessions = data?.items ?? []; + + const keyword = query.trim().toLowerCase(); + const filtered = keyword + ? sessions.filter((session) => + (session.title || session.query).toLowerCase().includes(keyword), + ) + : sessions; + + useEffect(() => { + onTotalChange?.(filtered.length); + }, [filtered.length, onTotalChange]); + + const openSession = (session: DeepResearchSession) => { + navigate(`${DEEP_RESEARCH_PATH}?session=${encodeURIComponent(session.id)}`); + }; + + const handleRename = async (id: string, title: string) => { + try { + await actions.updateTitle.mutateAsync({ id, title }); + toast.success("已重命名"); + } catch { + toast.error("重命名失败,请重试"); + } finally { + setRenameId(null); + } + }; + + const handleDelete = async (session: DeepResearchSession) => { + if (!window.confirm("确定删除这条研究记录吗?此操作不可恢复。")) return; + try { + await actions.delete.mutateAsync(session.id); + toast.success("已删除研究记录"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除失败,请重试"); + } + }; + + if (isLoading && sessions.length === 0) { + return ( +
+ 加载中… +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ {keyword ? "没有匹配的研究记录" : "暂无研究记录"} +
+ ); + } + + return ( + <> + {filtered.map((session) => { + const status = RESEARCH_STATUS[session.status] ?? { + label: session.status, + className: "text-muted-foreground", + }; + return ( + + + · + {session.sourceCount > 0 && ( + <> + {session.sourceCount} 条来源 + · + + )} + {formatRelative(session.updatedAt ?? session.createdAt ?? "")} + + } + onOpen={() => openSession(session)} + > + { + setRenameId(session.id); + setRenameTitle(session.title || session.query || ""); + }} + > + + + void handleDelete(session)} + > + + + + ); + })} + + setRenameId(null)} + onConfirm={(title) => renameId && void handleRename(renameId, title)} + /> + + ); +} From 875001a8c08345f9a1223ada4eb8c935b681a127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 28 Aug 2026 15:43:02 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E9=A1=B5=E6=94=B6=E7=BA=B3=E4=BC=9A=E5=95=86/?= =?UTF-8?q?=E5=86=99=E4=BD=9C/=E6=B7=B1=E5=BA=A6=E7=A0=94=E7=A9=B6=20Tab?= =?UTF-8?q?=EF=BC=8C=E4=BE=A7=E6=A0=8F=E7=A7=BB=E9=99=A4=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatsPage 新增四类历史 Tab 与统一搜索筛选 UI;简洁模式侧栏不再展示会商与写作列表;深度研究页支持 ?session= 深链打开指定记录。 Co-authored-by: Cursor --- .../workspace/recent-roundtable-list.tsx | 267 --------- .../workspace/recent-writing-list.tsx | 122 ---- .../workspace/workspace-sidebar.tsx | 68 ++- frontend-web/src/pages/ChatsPage.tsx | 526 ++++++++++++++---- .../chats-history/ResearchHistoryTab.tsx | 98 ++-- .../chats-history/RoundtableHistoryTab.tsx | 82 +-- .../pages/chats-history/WritingHistoryTab.tsx | 75 +-- .../chats-history/history-tab-shared.tsx | 35 +- .../deep-research/DeepResearchWorkbench.tsx | 8 +- 9 files changed, 612 insertions(+), 669 deletions(-) delete mode 100644 frontend-web/src/components/workspace/recent-roundtable-list.tsx delete mode 100644 frontend-web/src/components/workspace/recent-writing-list.tsx diff --git a/frontend-web/src/components/workspace/recent-roundtable-list.tsx b/frontend-web/src/components/workspace/recent-roundtable-list.tsx deleted file mode 100644 index 8494a9a0..00000000 --- a/frontend-web/src/components/workspace/recent-roundtable-list.tsx +++ /dev/null @@ -1,267 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuItem, - SidebarMenuButton, -} from "@/components/ui/sidebar"; -import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs"; -import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; -import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts"; -import type { DraftMeta } from "@/roundtable-planning/api/drafts"; -import { isIMEComposing } from "@/lib/ime"; - -const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning"; - -function openDraft( - draft: DraftMeta, - isOnPage: boolean, - router: ReturnType, -) { - if (isOnPage) { - // 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。 - window.dispatchEvent( - new CustomEvent("roundtable:open-draft", { detail: draft.id }), - ); - } else { - // 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。 - sessionStorage.setItem("roundtable:pending-draft", draft.id); - router.push(ROUNDTABLE_PATH); - } -} - -/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */ -function StatusBadge({ draft }: { draft: DraftMeta }) { - if (draft.status === "running") { - return ( - - - 进行中 - - ); - } - if (draft.status === "awaiting_input") { - return 待澄清; - } - if (draft.status === "error") { - return 出错; - } - // 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。 - return ( - - {draft.chainTitle || "自由模式"} - - ); -} - -export function RecentRoundtableList() { - const router = useRouter(); - const pathname = usePathname(); - const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts(); - const [collapsed, setCollapsed] = useState( - () => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true", - ); - - // 重命名对话框状态。 - const [renameId, setRenameId] = useState(null); - const [renameValue, setRenameValue] = useState(""); - // 分享对话框 —— 正在分享的草稿 id。 - const [shareId, setShareId] = useState(null); - - const toggleCollapsed = () => { - const next = !collapsed; - setCollapsed(next); - localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next)); - }; - - useEffect(() => { - void refresh(); - }, [refresh]); - - const handleRenameSubmit = useCallback(async () => { - const id = renameId; - const title = renameValue.trim(); - if (!id || !title) { - setRenameId(null); - return; - } - try { - await rename(id, title); - toast.success("已重命名"); - } catch { - toast.error("重命名失败,请重试"); - } finally { - setRenameId(null); - setRenameValue(""); - } - }, [renameId, renameValue, rename]); - - const handleDelete = useCallback( - async (draft: DraftMeta) => { - if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return; - try { - await remove(draft.id); - toast.success("已删除会商记录"); - } catch { - toast.error("删除失败,请重试"); - } - }, - [remove], - ); - - if (!loading && drafts.length === 0) return null; - - const isOnPage = pathname === ROUNDTABLE_PATH; - - return ( - <> - - - 多智能体会商 - - - - -
- {loading && drafts.length === 0 ? ( -

加载中…

- ) : ( - drafts.slice(0, 10).map((draft) => ( - - openDraft(draft, isOnPage, router)} - > -
- - {draft.title || "未命名草稿"} - -
- - · - - {formatRelative(draft.updatedAt)} - -
-
-
- - - - - 更多 - - - - { - setRenameId(draft.id); - setRenameValue(draft.title || ""); - }} - > - - 重命名 - - setShareId(draft.id)}> - - 分享 - - - void handleDelete(draft)}> - - 删除 - - - -
- )) - )} -
-
-
-
- - {/* 重命名对话框 */} - !o && setRenameId(null)}> - - - 重命名会商 - -
- setRenameValue(e.target.value)} - placeholder="输入新名称" - onKeyDown={(e) => { - if (e.key === "Enter" && !isIMEComposing(e)) { - e.preventDefault(); - void handleRenameSubmit(); - } - }} - /> -
- - - - -
-
- - {/* 分享对话框 */} - { - if (!o) setShareId(null); - }} - /> - - ); -} diff --git a/frontend-web/src/components/workspace/recent-writing-list.tsx b/frontend-web/src/components/workspace/recent-writing-list.tsx deleted file mode 100644 index 11be8295..00000000 --- a/frontend-web/src/components/workspace/recent-writing-list.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, -} from "@/components/ui/sidebar"; -import { - formatRelative, - STATUS_LABELS, -} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; -import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions"; -import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions"; - -const AI_WRITING_PATH = "/page/workspace/ai-writing"; - -function openSession( - session: AIWritingSessionSummary, - isOnPage: boolean, - router: ReturnType, -) { - if (isOnPage) { - // 已在 AI 写作页面,直接通过自定义事件通知面板 - window.dispatchEvent( - new CustomEvent("ai-writing:open-session", { detail: session }), - ); - } else { - // 不在该页面,把 session 存入 sessionStorage 后跳转 - sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session)); - router.push(AI_WRITING_PATH); - } -} - -export function RecentWritingList() { - const router = useRouter(); - const pathname = usePathname(); - const { sessions, loading, refresh } = useAIWritingSessions(); - const [collapsed, setCollapsed] = useState( - () => localStorage.getItem("sidebar:recent-writing-collapsed") === "true", - ); - - const toggleCollapsed = () => { - const next = !collapsed; - setCollapsed(next); - localStorage.setItem("sidebar:recent-writing-collapsed", String(next)); - }; - - useEffect(() => { - void refresh(); - }, [refresh]); - - if (!loading && sessions.length === 0) return null; - - const isOnPage = pathname === AI_WRITING_PATH; - - return ( - - - 最近写作 - - - - -
- {loading && sessions.length === 0 ? ( -

加载中…

- ) : ( - sessions.slice(0, 10).map((session) => { - const { label, color } = STATUS_LABELS[session.status] ?? { - label: session.status, - color: "text-muted-foreground", - }; - return ( - - openSession(session, isOnPage, router)} - > -
- - {session.title} - -
- - {label} - - · - - {formatRelative(session.updated_at)} - -
-
-
-
- ); - }) - )} -
-
-
-
- ); -} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index 331b97b9..7614b40b 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -14,21 +14,19 @@ import { usePathname } from "next/navigation"; import { GeneralQaSidebar } from "./general-qa-sidebar"; import { RecentChatList } from "./recent-chat-list"; -import { RecentRoundtableList } from "./recent-roundtable-list"; -import { RecentWritingList } from "./recent-writing-list"; import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavMenu } from "./workspace-nav-menu"; -export function WorkspaceSidebar({ - compactChrome = false, - forceCollapsed = false, - className, - ...props -}: React.ComponentProps & { - compactChrome?: boolean; - forceCollapsed?: boolean; -}) { +export function WorkspaceSidebar({ + compactChrome = false, + forceCollapsed = false, + className, + ...props +}: React.ComponentProps & { + compactChrome?: boolean; + forceCollapsed?: boolean; +}) { const { open: isSidebarOpen } = useSidebar(); const { theme } = useTheme(); const pathname = usePathname(); @@ -36,26 +34,26 @@ export function WorkspaceSidebar({ // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 - const isGeneralQa = pathname.startsWith("/page/workspace/chats"); - - if (forceCollapsed) { - return ( - - - - - - ); - } - + const isGeneralQa = pathname.startsWith("/page/workspace/chats"); + + if (forceCollapsed) { + return ( + + + + + + ); + } + // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 // collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 // sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从 @@ -123,10 +121,10 @@ export function WorkspaceSidebar({ compact={compactChrome} /> {isSidebarOpen && } - {/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐: - 仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */} - {isSidebarOpen && !compactChrome && } - {isSidebarOpen && !compactChrome && } + {/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页 + (/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支, + 无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组, + 全量模式本就不展示。 */} {!compactChrome && ( diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index b8ac2d0e..d6a8e4d5 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -1,18 +1,24 @@ "use client"; import { + Bot, ChevronLeft, ChevronRight, + Clock, DownloadIcon, + FilePenLine, + MessageSquare, Pin, PinOff, + Search, Star, + type LucideIcon, + X, } from "lucide-react"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; import { @@ -43,8 +49,66 @@ import { formatTimeAgo } from "@/core/utils/datetime"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; +import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab"; +import { WritingHistoryTab } from "./chats-history/WritingHistoryTab"; +import { ResearchHistoryTab } from "./chats-history/ResearchHistoryTab"; + const PAGE_SIZE = 20; +/** + * 「对话历史」页的四个历史 Tab:对话(通用问答/智能体会话)之外,把多智能体 + * 会商、AI写作、深度研究的记录也收纳进来(简洁模式侧栏不再展示后两者的入口, + * 统一从这里查看)。 + */ +type HistoryTab = "chats" | "roundtable" | "writing" | "research"; +type ChatFilter = "all" | "favorites" | "pinned"; + +const HISTORY_TABS: { + id: HistoryTab; + label: string; + description: string; + icon: LucideIcon; +}[] = [ + { + id: "chats", + label: "对话", + description: "普通问答与智能体会话", + icon: MessageSquare, + }, + { + id: "roundtable", + label: "多智能体会商", + description: "多角色协作研讨", + icon: Bot, + }, + { + id: "writing", + label: "AI写作", + description: "写作任务与草稿", + icon: FilePenLine, + }, + { + id: "research", + label: "深度研究", + description: "研究任务与报告", + icon: Search, + }, +]; + +/** 非对话 Tab 的搜索占位文案(对话 Tab 沿用 i18n 的 t.chats.searchChats)。 */ +const TAB_SEARCH_PLACEHOLDER: Record, string> = { + roundtable: "搜索会商记录…", + writing: "搜索写作记录…", + research: "搜索研究记录…", +}; + +const TAB_TOTAL_UNIT: Record = { + chats: "条对话", + roundtable: "条会商记录", + writing: "篇写作", + research: "项研究", +}; + export default function ChatsPage({ compactChrome = false, }: { @@ -54,11 +118,14 @@ export default function ChatsPage({ const { t } = useI18n(); const { toggle: togglePin } = useToggleThreadPin(); const { toggle: toggleFavorite } = useToggleThreadFavorite(); + const [activeTab, setActiveTab] = useState("chats"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); - const [onlyFavorites, setOnlyFavorites] = useState(false); + const [chatFilter, setChatFilter] = useState("all"); const [page, setPage] = useState(1); const [importOpen, setImportOpen] = useState(false); + // 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。 + const [tabTotal, setTabTotal] = useState(null); useEffect(() => { document.title = `${t.pages.chats} - ${t.pages.appName}`; @@ -73,26 +140,41 @@ export default function ChatsPage({ // Reset to the first page whenever the filtered result set changes. useEffect(() => { setPage(1); - }, [debouncedSearch, onlyFavorites]); + }, [debouncedSearch, chatFilter]); + + // 切换 Tab 时回到第一页并清空旧 Tab 上报的计数。 + useEffect(() => { + setPage(1); + setTabTotal(null); + }, [activeTab]); + + const handleTabTotalChange = useCallback((total: number) => { + setTabTotal(total); + }, []); // Conversations are paginated on the backend: each page is one bounded // query (title search + favorites filter included), so a long history is // never fetched in one go. Pinned conversations come from their own small // query and are surfaced in a dedicated section on the first page only, // and dropped from the paginated rows so they don't repeat on every page. - const favoriteFilter = useMemo( - () => (onlyFavorites ? { is_favorite: true } : {}), - [onlyFavorites], + const threadFilter = useMemo( + () => + chatFilter === "favorites" + ? { is_favorite: true } + : chatFilter === "pinned" + ? { is_pinned: true } + : {}, + [chatFilter], ); const pinnedFilter = useMemo( - () => ({ ...favoriteFilter, is_pinned: true }), - [favoriteFilter], + () => ({ ...threadFilter, is_pinned: true }), + [threadFilter], ); const { data: pageData } = useThreadsPage({ page, pageSize: PAGE_SIZE, search: debouncedSearch, - metadata: favoriteFilter, + metadata: threadFilter, }); const { data: pinnedData } = useThreadsPage({ page: 1, @@ -102,13 +184,28 @@ export default function ChatsPage({ }); const { data: totalCount } = useThreadsCount({ search: debouncedSearch, - metadata: favoriteFilter, + metadata: threadFilter, + }); + const { data: allThreadsCount } = useThreadsCount({ + search: debouncedSearch, + metadata: {}, + }); + const { data: favoriteThreadsCount } = useThreadsCount({ + search: debouncedSearch, + metadata: { is_favorite: true }, + }); + const { data: pinnedThreadsCount } = useThreadsCount({ + search: debouncedSearch, + metadata: { is_pinned: true }, }); - const pinnedThreads = pinnedData?.threads ?? []; + const pinnedThreads = chatFilter === "pinned" ? [] : (pinnedData?.threads ?? []); const pagedThreads = useMemo( - () => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)), - [pageData?.threads], + () => + (pageData?.threads ?? []).filter( + (thread) => chatFilter === "pinned" || !isThreadPinned(thread), + ), + [chatFilter, pageData?.threads], ); const currentPage = page; const hasMore = pageData?.hasMore ?? false; @@ -123,46 +220,67 @@ export default function ChatsPage({ return (
- -
+ + +
{pinned && ( - + + + 置顶 + )} - + + + 对话 + + {desensitize(titleOfThread(thread, t.pages.newChat))}
{thread.updated_at && ( -
+
+ {formatTimeAgo(thread.updated_at)}
)} - {/* Row actions — kept out of the Link so clicks don't navigate. */} -
+ {thread.updated_at && ( +
+ + {formatTimeAgo(thread.updated_at)} +
+ )} +
- - -
- -
- setSearch(e.target.value)} - /> - {totalCount != null && ( -
- 共 {totalCount} 条对话 + {HISTORY_TABS.map((tab) => { + const isActive = activeTab === tab.id; + const Icon = tab.icon; + + return ( + + ); + })}
- )} + {activeTab === "chats" && ( + + )} +
- -
- {currentPage === 1 && pinnedThreads.length > 0 && ( - <> -
- {t.chats.pinnedSection} + +
+
+
+
+ {activeTab === "chats" ? ( +
+ + + {pinnedThreadsCount != null && pinnedThreadsCount > 0 && ( + + )} +
+ ) : ( +
+ {activeTabConfig.description} +
+ )} + +
+
+ {displayTotal != null && ( + + 共 {displayTotal}{" "} + {TAB_TOTAL_UNIT[activeTab]} + + )}
- {pinnedThreads.map(renderThread)} - - )} - {pagedThreads.map(renderThread)} - {isEmpty && ( -
- {onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
- )} - {(currentPage > 1 || hasMore) && ( -
- - - {totalPages != null - ? `${currentPage} / ${totalPages}` - : currentPage} - - -
- )} + {activeTab === "chats" && ( +
+ {currentPage === 1 && pinnedThreads.map(renderThread)} + {pagedThreads.map(renderThread)} + {isEmpty && ( +
+ {chatFilter === "favorites" + ? t.chats.noFavorites + : chatFilter === "pinned" + ? "暂无置顶对话" + : t.chats.noChats} +
+ )} + {(currentPage > 1 || hasMore) && ( +
+ + + {totalPages != null + ? `${currentPage} / ${totalPages}` + : currentPage} + + +
+ )} +
+ )} + {activeTab === "roundtable" && ( +
+ +
+ )} + {activeTab === "writing" && ( +
+ +
+ )} + {activeTab === "research" && ( +
+ +
+ )} +
diff --git a/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx b/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx index 5d89b688..41608b03 100644 --- a/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx +++ b/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { Search } from "lucide-react"; import { toast } from "sonner"; import { @@ -26,6 +27,7 @@ import { HistoryRow, HistoryRowIconButton, HistoryStatusPill, + HistoryTypeBadge, RenameIcon, } from "./history-tab-shared"; @@ -115,51 +117,59 @@ export function ResearchHistoryTab({ return ( <> - {filtered.map((session) => { - const status = RESEARCH_STATUS[session.status] ?? { - label: session.status, - className: "text-muted-foreground", - }; - return ( - - - · - {session.sourceCount > 0 && ( - <> - {session.sourceCount} 条来源 - · - - )} - {formatRelative(session.updatedAt ?? session.createdAt ?? "")} - - } - onOpen={() => openSession(session)} - > - { - setRenameId(session.id); - setRenameTitle(session.title || session.query || ""); - }} +
+ {filtered.map((session) => { + const status = RESEARCH_STATUS[session.status] ?? { + label: session.status, + className: "text-muted-foreground", + }; + const timestamp = session.updatedAt ?? session.createdAt ?? null; + return ( + } + title={desensitize(session.title || session.query || "未命名研究")} + meta={ + <> + + {session.sourceCount > 0 && ( + <> + · + {session.sourceCount} 条来源 + + )} + {timestamp && ( + <> + · + {formatRelative(timestamp)} + + )} + + } + onOpen={() => openSession(session)} > - - - void handleDelete(session)} - > - - - - ); - })} + { + setRenameId(session.id); + setRenameTitle(session.title || session.query || ""); + }} + > + + + void handleDelete(session)} + > + + + + ); + })} +
- {filtered.map((draft) => { - const status = statusOf(draft); - return ( - - - · - {formatRelative(draft.updatedAt)} - - } - onOpen={() => openDraft(draft)} - > - { - setRenameId(draft.id); - setRenameTitle(draft.title || ""); - }} +
+ {filtered.map((draft) => { + const status = statusOf(draft); + return ( + } + title={desensitize(draft.title || "未命名草稿")} + meta={ + <> + + · + {formatRelative(draft.updatedAt)} + + } + onOpen={() => openDraft(draft)} > - - - setShareId(draft.id)}> - - - void handleDelete(draft)} - > - - - - ); - })} + { + setRenameId(draft.id); + setRenameTitle(draft.title || ""); + }} + > + + + setShareId(draft.id)}> + + + void handleDelete(draft)} + > + + + + ); + })} +
- {filtered.map((session) => { - const { label, color } = STATUS_LABELS[session.status] ?? { - label: session.status, - color: "text-muted-foreground", - }; - return ( - - - · - {formatRelative(session.updated_at)} - - } - onOpen={() => openSession(session)} - > - { - setRenameId(session.id); - setRenameTitle(session.title); - }} +
+ {filtered.map((session) => { + const { label, color } = STATUS_LABELS[session.status] ?? { + label: session.status, + color: "text-muted-foreground", + }; + return ( + } + title={desensitize(session.title)} + meta={ + <> + + · + {formatRelative(session.updated_at)} + + } + onOpen={() => openSession(session)} > - - - void handleDelete(session)} - > - - - - ); - })} + { + setRenameId(session.id); + setRenameTitle(session.title); + }} + > + + + void handleDelete(session)} + > + + + + ); + })} +
+ + {label} + + ); +} + /** * 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转 * 对应工作台,由页面挂载时消费),操作按钮经 children 传入。 */ export function HistoryRow({ + badge, title, meta, onOpen, children, }: { + badge?: ReactNode; title: ReactNode; meta: ReactNode; onOpen: () => void; children?: ReactNode; }) { return ( -
+
{/* Row actions — kept out of the open button so clicks don't trigger it. */} -
+
{children}
diff --git a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx index 18fdf1b1..cf073925 100644 --- a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx +++ b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx @@ -20,6 +20,7 @@ */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import type { Message } from "@langchain/langgraph-sdk"; import ReactMarkdown from "react-markdown"; import { Select as TSelect } from "tdesign-react"; @@ -444,7 +445,12 @@ export default function DeepResearchWorkbench() { } function DeepResearchWorkbenchInner() { - const [selectedId, setSelectedId] = useState(null); + // 「?session=」深链:对话历史页「深度研究」Tab 等外部入口直接定位到某条 + // 研究记录。仅作初始选中(首帧读取一次);之后的切换仍走页内状态,不回写 URL。 + const [searchParams] = useSearchParams(); + const [selectedId, setSelectedId] = useState(() => + searchParams.get("session"), + ); const [mode, setMode] = useState("basic"); const [generateImages, setGenerateImages] = useState(false); const [advancedOptions, setAdvancedOptions] = useState(DEFAULT_ADVANCED_OPTIONS); From 832e180c52a2a8e70d536d2a12d28fbd61a4dc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 28 Aug 2026 16:21:39 +0800 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=20write=5Ffile=20=E6=B2=99=E7=AE=B1=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E4=B8=8E=20Mermaid=20=E6=97=A7=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同轮次非末尾 write_file 也能自动打开沙箱并跳过 JSON 大文件预览;Mermaid 改用 SVG 标签;agentfx 强制先写 md 再写 json。 Co-authored-by: Cursor --- frontend-web/scripts/check-chrome109-css.mjs | 5 + .../components/workspace/chats/chat-box.tsx | 22 +++++ .../workspace/messages/message-group.tsx | 9 +- .../artifacts/write-file-in-flight.test.ts | 94 ++++++++++++++++++- .../core/artifacts/write-file-in-flight.ts | 66 +++++++++++++ frontend-web/src/core/utils/mermaid.ts | 40 +++++++- .../agentfx-analyst/SOUL.md | 10 +- 7 files changed, 236 insertions(+), 10 deletions(-) diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index e1c5325a..2f32b8ed 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -31,6 +31,11 @@ const requiredLegacyFallbacks = [ pattern: //, description: "Prompt textarea must not rely on :has() for its parent height", }, + { + file: "src/core/utils/mermaid.ts", + pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/, + description: "Sandbox flowcharts must use native SVG labels in legacy browsers", + }, ]; for (const file of cssFiles) { diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index e2a6c51a..6985e5d5 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -18,6 +18,7 @@ import { listThreadArtifacts, mergeArtifactPaths, } from "@/core/artifacts/listing"; +import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; @@ -174,6 +175,7 @@ const ChatBox: React.FC<{ deselect, selectedArtifact, selectedArtifactKey, + openStreamedArtifact, } = useArtifacts(); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); @@ -424,6 +426,26 @@ const ChatBox: React.FC<{ disableArtifacts, ]); + // Live sandbox: open as soon as any in-flight write_file has a path or + // the first content bytes — not only when that tool is the last CoT step. + // Agentfx (and other agents) often emit write_file + present_files/bash in + // the same turn; the collapsed earlier write would otherwise never auto-open. + const streamingWritePreview = useMemo( + () => + disableArtifacts || !thread.isLoading + ? null + : latestInFlightWriteFilePreview(thread.messages ?? []), + [disableArtifacts, thread.isLoading, thread.messages], + ); + useEffect(() => { + if (!streamingWritePreview) return; + openStreamedArtifact( + streamingWritePreview.artifactPath, + streamingWritePreview.previewKey, + ); + setSideTab("document"); + }, [openStreamedArtifact, streamingWritePreview]); + const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled); useEffect(() => { const wasEnabled = directoryModeWasEnabledRef.current; diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 41a81777..996f9675 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -42,6 +42,7 @@ import { import { buildWriteFileVirtualUrl, hasCompleteArtifactExtension, + languageOfStreamingArtifact, normalizeStreamingArtifactPath, provisionalWriteFilePath, } from "@/core/artifacts/streaming-write-preview"; @@ -754,7 +755,6 @@ function ArtifactWriteToolCall({ args, result, description, - isLast, isLoading, }: { id?: string; @@ -793,19 +793,20 @@ function ArtifactWriteToolCall({ !artifactPath || !previewKey || !isLoading || - !isLast || - result !== undefined + result !== undefined || + !sourcePath || + !languageOfStreamingArtifact(sourcePath) ) { return; } openStreamedArtifact(artifactPath, previewKey); }, [ artifactPath, - isLast, isLoading, openStreamedArtifact, previewKey, result, + sourcePath, ]); return ( diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts index 68686482..3638d77f 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts @@ -1,7 +1,99 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight"; +import { + isWriteFileInFlight, + latestInFlightWriteFilePreview, + resolveWriteFilePath, +} from "./write-file-in-flight"; + +test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# 标题\n", + }, + }, + { id: "p1", name: "present_files", args: { paths: ["/mnt/user-data/outputs/report.md"] } }, + ], + }, + ]); + assert.ok(preview); + assert.equal(preview?.previewKey, "m1:w1"); + assert.match(preview?.artifactPath ?? "", /write-file:/); + assert.match(preview?.artifactPath ?? "", /report\.md/); +}); + +test("latestInFlightWriteFilePreview keeps markdown when a later json write is in flight", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# 标题\n", + }, + }, + { + id: "w2", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/task-reports.json", + content: "[{", + }, + }, + ], + }, + ]); + assert.ok(preview); + assert.equal(preview?.previewKey, "m1:w1"); + assert.match(preview?.artifactPath ?? "", /report\.md/); +}); + +test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/task-reports.json", + content: "[{}]", + }, + }, + ], + }, + ]); + assert.equal(preview, null); +}); + +test("latestInFlightWriteFilePreview skips write_file that already has a tool result", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }, + ], + }, + { type: "tool", tool_call_id: "w1" }, + ]); + assert.equal(preview, null); +}); test("resolveWriteFilePath reads the virtual URL pathname", () => { assert.equal( diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.ts b/frontend-web/src/core/artifacts/write-file-in-flight.ts index 3aae5083..3d1fefef 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.ts @@ -1,5 +1,13 @@ import { isImageFile } from "../utils/files"; +import { + buildWriteFileVirtualUrl, + hasCompleteArtifactExtension, + languageOfStreamingArtifact, + normalizeStreamingArtifactPath, + provisionalWriteFilePath, +} from "./streaming-write-preview"; + export function isWriteFileStreamingUrl(filepath: string): boolean { if (!filepath.startsWith("write-file:")) { return false; @@ -27,6 +35,7 @@ export function resolveWriteFilePath(filepath: string): string { type WriteFileThreadLike = { messages: Array<{ + id?: string; type?: string; tool_call_id?: string; tool_calls?: Array<{ @@ -75,3 +84,60 @@ export function isWriteFileInFlight( } return false; } + +/** + * Latest in-flight `write_file` that the sandbox can live-preview. + * Scans all messages (not just the last CoT step) so a write that is + * followed by `present_files` / bash in the same turn still opens the panel + * while arguments stream in — same idea as position-roundtable's + * `latestStreamingWriteFile`. + */ +export function latestInFlightWriteFilePreview( + messages: WriteFileThreadLike["messages"], +): { artifactPath: string; previewKey: string } | null { + const thread = { messages }; + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const message = messages[messageIndex]; + if (message?.type !== "ai" || !message.id || !message.tool_calls) { + continue; + } + for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) { + const toolCall = message.tool_calls[toolIndex]; + if (toolCall?.name !== "write_file" || !toolCall.id) { + continue; + } + if (hasToolResult(thread, toolCall.id)) { + continue; + } + const args = toolCall.args as { path?: unknown; content?: unknown } | undefined; + const rawPath = typeof args?.path === "string" ? args.path : ""; + const content = typeof args?.content === "string" ? args.content : ""; + const normalizedPath = normalizeStreamingArtifactPath(rawPath); + const hasUsablePath = + Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath); + const sourcePath = hasUsablePath + ? normalizedPath + : content.trim() + ? provisionalWriteFilePath(content) + : null; + if (!sourcePath) { + continue; + } + // JSON (e.g. agentfx task-reports.json) is too large to live-preview: + // switching the sandbox onto it freezes the main thread, then the rest + // of the turn appears to dump at once. Keep showing markdown instead. + if (!languageOfStreamingArtifact(sourcePath)) { + continue; + } + return { + artifactPath: buildWriteFileVirtualUrl({ + path: sourcePath, + messageId: message.id, + toolCallId: toolCall.id, + }), + previewKey: `${message.id}:${toolCall.id}`, + }; + } + } + return null; +} diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts index 01fa3e5f..f5f71a7a 100644 --- a/frontend-web/src/core/utils/mermaid.ts +++ b/frontend-web/src/core/utils/mermaid.ts @@ -2,6 +2,41 @@ import { getFileName } from "./files"; let mermaidInitialized = false; +/** + * Keep sandbox diagrams on the SVG-only Mermaid rendering path. + * + * Mermaid's default HTML labels are implemented with SVG `foreignObject`. + * Although that looks a little richer in current browsers, many Windows 7 + * Chromium shells either do not support it or calculate its dimensions + * incorrectly, leaving nodes blank or overlapping. Native SVG text works in + * the Chrome 100 baseline used by this application and in older Win7 browser + * shells as well. + * + * `htmlLabels` is marked secure so a diagram's front matter/directive cannot + * switch this preview back to `foreignObject` rendering. + */ +const SANDBOX_MERMAID_CONFIG = { + startOnLoad: false, + securityLevel: "strict" as const, + htmlLabels: false, + fontFamily: '"Microsoft YaHei", "Microsoft YaHei UI", SimSun, Arial, sans-serif', + flowchart: { + // A simple path is more reliable than the spline renderer in older + // Chromium SVG implementations and is easier to read at small sizes. + curve: "linear" as const, + useMaxWidth: false, + }, + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "htmlLabels", + ], +}; + export function isMermaidFile(filepath: string): boolean { return /\.mmd$/i.test(filepath); } @@ -14,10 +49,7 @@ async function getMermaidModule() { const mod = await import("mermaid"); const mermaid = mod.default; if (!mermaidInitialized) { - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - }); + mermaid.initialize(SANDBOX_MERMAID_CONFIG); mermaidInitialized = true; } return mermaid; diff --git a/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md index 80b01f1b..7f3a5fe1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md @@ -14,7 +14,14 @@ ### 2. 生成报告文件 -信息足够后,用 `write_file` 一次性写两份文件(路径固定,覆盖旧版): +信息足够后,**必须用 `write_file` 分两次写文件**(路径固定,覆盖旧版)。目的是让用户在沙箱里**边写边看**,而不是等两份文件全部生成完才一次性弹出。 + +**顺序(违反即失败):** + +1. **先**只调用一次 `write_file` 写 `/mnt/user-data/outputs/report.md`。这一次模型回复里禁止再夹带第二个 `write_file`、禁止夹带 `bash`。工具返回 OK 后立刻 `present_files`,参数只含这一份 md。 +2. 等第一步工具都返回后,再单独一次 `write_file` 写 `/mnt/user-data/outputs/task-reports.json`。写完后再 `present_files` 两份文件。 +3. **禁止**用 `bash` / `python -c` / 重定向 / `echo` / `cat <<` 创建或覆盖这两份交付文件。`bash` 只允许在入库步骤跑 `task_report_import.py`,或做只读校验(不得写入 `/mnt/user-data/outputs/`)。 +4. **禁止**在同一次模型回复里同时发出两个 `write_file`。 - `/mnt/user-data/outputs/report.md` —— Markdown 研判报告,章节按下方 14 个分类组织,每章给出要点与分析。 - `/mnt/user-data/outputs/task-reports.json` —— 结构化 JSON 数组,**14 条记录一个不落**,每条形如 `{"contentJson": "", "categoryType": "<分类名>"}`。 @@ -68,6 +75,7 @@ python /mnt/skills/public/task-report-import/scripts/task_report_import.py --tas ## 硬性约束 - **先检索后报告**:没有真实检索材料的报告不允许生成。 +- **先 md 后 json、禁止并行两个 write_file**:第一份报告必须先 `write_file` + `present_files` 让沙箱实时打开;json 放在下一轮工具调用。交付文件禁止用 bash/python 写盘。 - `task-reports.json` 必须是合法 JSON 数组:14 个分类一条不落、`categoryType` 逐字匹配、内容全部为非空中文文字、无 `\uXXXX` 转义。 - 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。 - **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。 From 4ac7bc21cd8dd33bde67ca90dccf045e562a5b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 28 Aug 2026 20:25:21 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E4=B8=8E=E4=BC=9A=E5=95=86=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。 Co-authored-by: Cursor --- AGENTS.md | 2 +- .../workspace/agents/agent-gallery.tsx | 6 ++- .../workspace/agents/agent-guide.tsx | 33 ++++++------ .../components/workspace/compact-toolbox.tsx | 32 +++--------- .../chats-history/ResearchHistoryTab.tsx | 26 ++++++++++ .../chats-history/RoundtableHistoryTab.tsx | 51 ++++++++++++++----- .../src/roundtable-planning/api/drafts.ts | 5 +- .../components/BusinessChainListDialog.tsx | 7 +-- .../components/DashboardChatPanel.tsx | 10 ++++ .../components/DispatchChainModal.tsx | 9 ++-- .../components/Step2Panel.tsx | 33 ++++++++++-- .../components/Step3ReportMode.tsx | 14 +++++ .../hooks/useDraftPersistence.ts | 4 +- .../hooks/useStep1Intent.ts | 15 ++++-- .../hooks/useStep2Orchestration.ts | 5 +- .../pages/BusinessChainEditorPage.tsx | 7 +-- .../pages/BusinessChainListPage.tsx | 11 ++-- .../pages/RoundtablePlanningPage.tsx | 2 +- 18 files changed, 184 insertions(+), 88 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fda68ac5..49e22f68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 - **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。 -- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;最后一步按钮文案改为「去创建智能体 →」,点完成即 `router.push` 到创建页衔接下一段;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 +- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 31421cee..be0409e4 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -615,7 +615,11 @@ export function AgentGallery() { )} - +
); } diff --git a/frontend-web/src/components/workspace/agents/agent-guide.tsx b/frontend-web/src/components/workspace/agents/agent-guide.tsx index 78f82f50..2f109b12 100644 --- a/frontend-web/src/components/workspace/agents/agent-guide.tsx +++ b/frontend-web/src/components/workspace/agents/agent-guide.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; import { Guide, type GuideStep } from "tdesign-react"; import { env } from "@/env"; @@ -13,7 +12,8 @@ import { isWujieEnv } from "@/core/embed/wujie"; * 「浏览管理 → 创建与配置 → 发起问答」流程: * * - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、 - * 创建入口;最后一步「去创建智能体 →」直接跳到创建页衔接下一段。 + * 创建入口;完成 / 跳过就地结束,不跳转创建页(创建页有自己的一段 + * 引导,点「创建智能体」进入时自动演示)。 * - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、 * 知识空间、SOUL.md、技能、保存。 * - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与 @@ -23,6 +23,10 @@ import { isWujieEnv } from "@/core/embed/wujie"; * localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开 * 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。 * + * 自动演示范围:只有 create 段(进入新建页)与 chat 段自动弹;list 段挂在 + * 智能体列表页但不自动演示(`autoStart={false}`,避免一进列表页就弹引导), + * 仅由头部「使用引导」按钮手动触发。 + * * 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤 * (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的 * 目标元素会抛错,不能盲传。 @@ -121,7 +125,7 @@ const LIST_STEPS: StepSpec[] = [ mode: "popup", placement: "bottom-left", title: "创建智能体", - body: "准备好了就点这里,开始创建你的专属智能体。点「去创建智能体」直接进入下一站。", + body: "准备好了就点这里,开始创建你的专属智能体;进入创建页后还会有一段配置引导。", }, ]; @@ -260,17 +264,19 @@ function buildSteps(stage: AgentGuideStage): GuideStep[] { } /** - * 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为false 时完全 - * 静默(不监听、不自动弹)。挂在三个页面各自的作用域内。 + * 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为 false 时完全 + * 静默(不监听、不自动弹)。挂在三个页面各自的作用域内;`autoStart` 为 false + * 时仅响应手动触发(列表页不自动弹,进新建页才自动演示)。 */ export function AgentGuide({ stage, eligible, + autoStart = true, }: { stage: AgentGuideStage; eligible: boolean; + autoStart?: boolean; }) { - const router = useRouter(); const [current, setCurrent] = useState(-1); const stepsRef = useRef([]); @@ -285,7 +291,7 @@ export function AgentGuide({ // 「已看过」只由 finish 回调(完成/跳过)写入。 const autoTriedRef = useRef(false); useEffect(() => { - if (!eligible || autoTriedRef.current) return; + if (!eligible || !autoStart || autoTriedRef.current) return; autoTriedRef.current = true; if (isStageFinished(stage)) return; const timer = setTimeout(start, START_DELAY_MS); @@ -294,7 +300,7 @@ export function AgentGuide({ // 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。 autoTriedRef.current = false; }; - }, [eligible, stage, start]); + }, [eligible, autoStart, stage, start]); // 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。 useEffect(() => { @@ -305,15 +311,11 @@ export function AgentGuide({ window.removeEventListener(startEventName(stage), handler); }, [eligible, stage, start]); - // 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」。 - // list 阶段的最后一步按钮文案是「去创建智能体 →」,完成即跳转衔接下一段。 + // 完成 / 跳过都是用户显式结束引导,就地关闭并记忆「已看过」,不跳转任何页面。 const finish = useCallback(() => { markStageFinished(stage); setCurrent(-1); - if (stage === "list") { - router.push("/page/workspace/agents/new"); - } - }, [stage, router]); + }, [stage]); if (!eligible) return null; @@ -324,9 +326,6 @@ export function AgentGuide({ onChange={setCurrent} onFinish={finish} onSkip={finish} - finishButtonProps={ - stage === "list" ? { content: "去创建智能体 →" } : undefined - } /> ); } diff --git a/frontend-web/src/components/workspace/compact-toolbox.tsx b/frontend-web/src/components/workspace/compact-toolbox.tsx index 2a3cce12..ee722d32 100644 --- a/frontend-web/src/components/workspace/compact-toolbox.tsx +++ b/frontend-web/src/components/workspace/compact-toolbox.tsx @@ -19,17 +19,16 @@ const TOOLBOX_ICON_TONES = [ * 简洁模式右侧工具箱。 * * 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目 - * 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。 + * 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc + * 收起后右侧出现竖排按钮,可再次展开。 */ export function CompactToolbox() { const { toolboxItems } = useWorkspaceCompactNavItems(); const { pathname } = useLocation(); const navigate = useNavigate(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(true); const triggerRef = useRef(null); - const closeRef = useRef(null); - useEffect(() => setOpen(false), [pathname]); useEffect(() => { if (!open) return; @@ -41,7 +40,6 @@ export function CompactToolbox() { }; document.addEventListener("keydown", handleKeyDown); - closeRef.current?.focus(); return () => { document.removeEventListener("keydown", handleKeyDown); }; @@ -73,22 +71,8 @@ export function CompactToolbox() { 工具箱 - + )} + {busy && ( + + )} + )} - +
))} diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx index bf79d44a..d7dd7501 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx @@ -22,7 +22,6 @@ * 仍走旧实现,本页不复用它,避免互相牵连。 */ import { - Fragment, useCallback, useEffect, useMemo, @@ -853,8 +852,9 @@ function ChainFlow({ chain }: { chain: BusinessChain }) { style={{ maxHeight: 220 }} >
+ {/* div.contents 代替 :dev tagger 注入 data-lov-id 会触发 React 警告。 */} {stages.map((stage, sIdx) => ( - +
)} - +
))}
@@ -1054,8 +1054,9 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) { const { stages, seatById } = useChainStages(chain); return (
+ {/* div.contents 代替 :dev tagger 注入 data-lov-id 会触发 React 警告。 */} {stages.map((stage, sIdx) => ( - +
→ )} - +
))}
); diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index d145291b..7b6773cd 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -4343,7 +4343,7 @@ function IntentSummaryCard({ )} {showFullDetail && (
    - {intentReady.constraints.map((c, i) => ( + {(intentReady.constraints ?? []).map((c, i) => (
  • • From 17cf8493618514da367734aaa054a08d7cd78709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 01:25:20 +0800 Subject: [PATCH 06/22] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 62 +- AGENTS.md | 2 + CLAUDE.md | 2 + frontend-web/src/core/threads/hooks.ts | 229 +++--- .../src/core/threads/stream-display.test.ts | 25 + .../src/core/threads/stream-display.ts | 19 + frontend-web/src/pages/ChatsPage.tsx | 154 ++-- .../chats-history/RoundtableHistoryTab.tsx | 540 +++++++++---- .../chats-history/history-tab-shared.tsx | 4 +- offline-backend-20260512/backend/CLAUDE.md | 33 + offline-backend-20260512/backend/README.md | 15 + .../backend/app/gateway/app.py | 41 +- .../backend/app/gateway/csrf_middleware.py | 97 ++- .../backend/app/gateway/deps.py | 254 +++--- .../gateway/routers/workflow_data_sources.py | 287 +++++++ .../app/gateway/routers/workflow_embed.py | 186 +++++ .../app/gateway/routers/workflow_resources.py | 236 ++++++ .../app/gateway/routers/workflow_runs.py | 700 +++++++++++++++++ .../app/gateway/routers/workflow_studio.py | 351 +++++++++ .../backend/app/gateway/routers/workflows.py | 313 ++++++++ .../gateway/routers/workflows_coze_compat.py | 368 +++++++++ .../app/gateway/workflow_agent_runner.py | 209 +++++ .../backend/app/gateway/workflow_audit.py | 22 + .../app/gateway/workflow_dispatcher.py | 151 ++++ .../backend/app/gateway/workflow_executor.py | 444 +++++++++++ .../backend/app/gateway/workflow_live_hub.py | 59 ++ .../app/gateway/workflow_resource_checks.py | 303 +++++++ .../backend/app/gateway/workflow_retention.py | 104 +++ offline-backend-20260512/backend/config.yaml | 42 + .../backend/docs/README.md | 10 + .../docs/WORKFLOW_OPENAPI_DRAFT_V1.yaml | 740 ++++++++++++++++++ .../backend/docs/WORKFLOW_SCHEMA_V1_ZH.md | 93 +++ .../backend/docs/WORKFLOW_SSE_V1_ZH.md | 107 +++ .../docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md | 35 +- .../WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md | 674 ++++++++++++++++ ...FLOW_STUDIO_COZE_FRONTEND_ADAPTATION_ZH.md | 486 ++++++++++++ .../docs/adr/0001-workflow-studio-runtime.md | 54 ++ .../harness/deerflow/config/app_config.py | 2 + .../deerflow/config/workflow_config.py | 81 ++ .../20260828_01_workflow_studio_tables.py | 64 ++ .../20260828_02_workflow_run_tables.py | 127 +++ .../20260828_03_workflow_data_sources.py | 49 ++ .../20260828_04_workflow_counter_columns.py | 66 ++ ...260829_01_workflow_http_source_metadata.py | 50 ++ .../20260829_02_workflow_canvas_schema.py | 51 ++ .../deerflow/persistence/models/__init__.py | 15 + .../workflow_data_sources/__init__.py | 19 + .../workflow_data_sources/model.py | 51 ++ .../workflow_data_sources/store.py | 293 +++++++ .../persistence/workflow_events/__init__.py | 22 + .../persistence/workflow_events/base.py | 42 + .../persistence/workflow_events/memory.py | 103 +++ .../persistence/workflow_events/model.py | 34 + .../persistence/workflow_events/sql.py | 157 ++++ .../persistence/workflow_runs/__init__.py | 38 + .../persistence/workflow_runs/base.py | 161 ++++ .../persistence/workflow_runs/memory.py | 388 +++++++++ .../persistence/workflow_runs/model.py | 96 +++ .../deerflow/persistence/workflow_runs/sql.py | 566 ++++++++++++++ .../persistence/workflows/__init__.py | 31 + .../deerflow/persistence/workflows/base.py | 112 +++ .../deerflow/persistence/workflows/memory.py | 216 +++++ .../deerflow/persistence/workflows/model.py | 69 ++ .../deerflow/persistence/workflows/sql.py | 385 +++++++++ .../harness/deerflow/workflows/__init__.py | 64 ++ .../harness/deerflow/workflows/errors.py | 150 ++++ .../harness/deerflow/workflows/events.py | 158 ++++ .../harness/deerflow/workflows/expressions.py | 264 +++++++ .../deerflow/workflows/nodes/__init__.py | 65 ++ .../deerflow/workflows/nodes/agents.py | 361 +++++++++ .../harness/deerflow/workflows/nodes/base.py | 52 ++ .../harness/deerflow/workflows/nodes/core.py | 238 ++++++ .../harness/deerflow/workflows/nodes/human.py | 77 ++ .../deerflow/workflows/nodes/integrations.py | 276 +++++++ .../harness/deerflow/workflows/ports.py | 17 + .../deerflow/workflows/runtime/__init__.py | 23 + .../deerflow/workflows/runtime/code_runner.py | 173 ++++ .../deerflow/workflows/runtime/context.py | 162 ++++ .../deerflow/workflows/runtime/engine.py | 531 +++++++++++++ .../deerflow/workflows/runtime/sink.py | 117 +++ .../deerflow/workflows/runtime/sql_runner.py | 107 +++ .../deerflow/workflows/samples/__init__.py | 54 ++ .../samples/event_envelope_example.json | 15 + .../workflows/samples/nodes/agent.json | 13 + .../workflows/samples/nodes/code.json | 11 + .../workflows/samples/nodes/condition.json | 18 + .../workflows/samples/nodes/http.json | 16 + .../workflows/samples/nodes/human_input.json | 20 + .../workflows/samples/nodes/loop.json | 10 + .../workflows/samples/nodes/merge.json | 9 + .../workflows/samples/nodes/output.json | 11 + .../workflows/samples/nodes/skill.json | 12 + .../workflows/samples/nodes/sql_read.json | 13 + .../workflows/samples/nodes/start.json | 6 + .../workflows/samples/nodes/subworkflow.json | 13 + .../workflows/samples/nodes/transform.json | 20 + .../workflows/samples/report_generation.json | 260 ++++++ .../workflows/samples/start_output.json | 41 + .../deerflow/workflows/schema_validation.py | 73 ++ .../harness/deerflow/workflows/schemas.py | 374 +++++++++ .../deerflow/workflows/security/__init__.py | 18 + .../workflows/security/http_policy.py | 114 +++ .../deerflow/workflows/security/secret_box.py | 97 +++ .../deerflow/workflows/security/sql_policy.py | 136 ++++ .../harness/deerflow/workflows/validator.py | 335 ++++++++ .../tests/test_workflow_concurrency.py | 433 ++++++++++ .../tests/test_workflow_definitions.py | 240 ++++++ .../backend/tests/test_workflow_engine.py | 688 ++++++++++++++++ .../backend/tests/test_workflow_runs.py | 707 +++++++++++++++++ .../backend/tests/test_workflow_schemas.py | 194 +++++ 110 files changed, 17007 insertions(+), 518 deletions(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflow_data_sources.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflow_embed.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflow_resources.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflow_runs.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflow_studio.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflows.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/workflows_coze_compat.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_agent_runner.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_audit.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_dispatcher.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_executor.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_live_hub.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_resource_checks.py create mode 100644 offline-backend-20260512/backend/app/gateway/workflow_retention.py create mode 100644 offline-backend-20260512/backend/docs/WORKFLOW_OPENAPI_DRAFT_V1.yaml create mode 100644 offline-backend-20260512/backend/docs/WORKFLOW_SCHEMA_V1_ZH.md create mode 100644 offline-backend-20260512/backend/docs/WORKFLOW_SSE_V1_ZH.md create mode 100644 offline-backend-20260512/backend/docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md create mode 100644 offline-backend-20260512/backend/docs/WORKFLOW_STUDIO_COZE_FRONTEND_ADAPTATION_ZH.md create mode 100644 offline-backend-20260512/backend/docs/adr/0001-workflow-studio-runtime.md create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/workflow_config.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260828_01_workflow_studio_tables.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260828_02_workflow_run_tables.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260828_03_workflow_data_sources.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260828_04_workflow_counter_columns.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260829_01_workflow_http_source_metadata.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260829_02_workflow_canvas_schema.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_data_sources/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_data_sources/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_data_sources/store.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_events/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_events/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_events/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_events/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_events/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_runs/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_runs/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_runs/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_runs/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflow_runs/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflows/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflows/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflows/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflows/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/workflows/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/errors.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/events.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/expressions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/agents.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/core.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/human.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/integrations.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/ports.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/code_runner.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/context.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/engine.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/sink.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/sql_runner.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/event_envelope_example.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/agent.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/code.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/condition.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/http.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/human_input.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/loop.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/merge.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/output.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/skill.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/sql_read.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/start.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/subworkflow.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/nodes/transform.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/report_generation.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/samples/start_output.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/schema_validation.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/schemas.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/security/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/security/http_policy.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/security/secret_box.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/security/sql_policy.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/workflows/validator.py create mode 100644 offline-backend-20260512/backend/tests/test_workflow_concurrency.py create mode 100644 offline-backend-20260512/backend/tests/test_workflow_definitions.py create mode 100644 offline-backend-20260512/backend/tests/test_workflow_engine.py create mode 100644 offline-backend-20260512/backend/tests/test_workflow_runs.py create mode 100644 offline-backend-20260512/backend/tests/test_workflow_schemas.py diff --git a/.gitignore b/.gitignore index 35e457d6..6ac82388 100644 --- a/.gitignore +++ b/.gitignore @@ -87,39 +87,47 @@ offline-backend-20260512/backend/skill_evolution.json skills/ hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* -!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py -!offline-backend-20260512/backend/tests/test_health_auth_bypass.py -# Keep formal Word OOXML and embedded-font regression coverage versioned. -!offline-backend-20260512/backend/tests/test_word_export.py -# Keep the model configuration regression coverage versioned even though -# this deployment repository otherwise ignores newly added backend tests. -!offline-backend-20260512/backend/tests/test_model_management.py -# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. -!offline-backend-20260512/backend/tests/test_forced_research_agent.py -# Keep fixed-question exact-match and streaming regression coverage versioned. -!offline-backend-20260512/backend/tests/test_fixed_questions.py -# Keep configurable es_query system-prompt routing regression coverage versioned. -!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py -# Keep the local LLMWiki vector-index and external API regression coverage versioned. -!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py -!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py -!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py -!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py -!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py -!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py -# Deep-research reliability regression coverage. -!offline-backend-20260512/backend/tests/test_deep_research_persistence.py -!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py +offline-backend-20260512/backend/tests/* +!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +!offline-backend-20260512/backend/tests/test_health_auth_bypass.py +# Keep formal Word OOXML and embedded-font regression coverage versioned. +!offline-backend-20260512/backend/tests/test_word_export.py +# Keep the model configuration regression coverage versioned even though +# this deployment repository otherwise ignores newly added backend tests. +!offline-backend-20260512/backend/tests/test_model_management.py +# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. +!offline-backend-20260512/backend/tests/test_forced_research_agent.py +# Keep fixed-question exact-match and streaming regression coverage versioned. +!offline-backend-20260512/backend/tests/test_fixed_questions.py +# Keep configurable es_query system-prompt routing regression coverage versioned. +!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py +# Keep the local LLMWiki vector-index and external API regression coverage versioned. +!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py +!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py +!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py +!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py +!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py +!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py +# Deep-research reliability regression coverage. +!offline-backend-20260512/backend/tests/test_deep_research_persistence.py +!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py !offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py !offline-backend-20260512/backend/tests/test_deep_research_start_job.py !offline-backend-20260512/backend/tests/test_summarization_human_message.py !offline-backend-20260512/backend/tests/test_compact_menu_overrides.py # Independent enterprise-research persistence, HTTP and race regressions. !offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py -# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). -!offline-backend-20260512/backend/tests/test_position_artifact_cap.py -deploy +# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). +!offline-backend-20260512/backend/tests/test_position_artifact_cap.py +# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions +# plus the concurrency remediation suite (atomic resume, draft CAS, publish +# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06. +!offline-backend-20260512/backend/tests/test_workflow_schemas.py +!offline-backend-20260512/backend/tests/test_workflow_definitions.py +!offline-backend-20260512/backend/tests/test_workflow_engine.py +!offline-backend-20260512/backend/tests/test_workflow_runs.py +!offline-backend-20260512/backend/tests/test_workflow_concurrency.py +deploy .history # Cloned third-party repos (local only) diff --git a/AGENTS.md b/AGENTS.md index 49e22f68..86220caa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph **Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI). +**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`. + ### Frontend The frontend (`frontend-web/`) is a React 19 + Vite SPA: diff --git a/CLAUDE.md b/CLAUDE.md index 858c9eec..ff99545b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph **Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI). +**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`. + ### Frontend The frontend (`frontend-web/`) is a React 19 + Vite SPA: diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 5f760442..47a81023 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -31,6 +31,7 @@ import { import { collectHumanMessageIds, hasServerEchoedOptimisticHuman, + isStaleStreamFinish, mergeStreamDisplayValues, shouldDiscardTerminalOverlay, } from "./stream-display"; @@ -190,11 +191,9 @@ 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. + * but **deletes that key on stream onSuccess**. We keep a second key the SDK + * never touches so a 409 / dropped-SSE exception can still rejoin. Normal + * SSE close clears it immediately — do not wait for ``runs.get``. */ const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:"; @@ -276,36 +275,18 @@ export async function stopThreadRun( /** * 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run * 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。 + * + * 不在发送前 GET /runs —— 协助卡出现时图多半已经结束,为兜底去查会让每次 + * 点选项都卡住。未在流式则直接发;真遇到 409 再走 confirmOrRejoin。 */ export async function stopActiveThreadBeforeClarificationReply( threadId: string | null | undefined, thread: { isLoading?: boolean; stop: () => void | Promise }, ): Promise { - let storedRunId: string | null = null; - if (threadId && typeof window !== "undefined") { - try { - storedRunId = - normalizeStoredRunId( - window.sessionStorage.getItem(`lg:stream:${threadId}`), - ) || readActiveRunId(threadId); - } catch { - storedRunId = null; - } + if (!thread.isLoading) { + if (threadId) clearActiveRunId(threadId); + return; } - // 协助卡出现时图已经 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); } catch { @@ -320,7 +301,7 @@ export async function stopActiveThreadBeforeClarificationReply( await new Promise((resolve) => setTimeout(resolve, 120)); } -function getStreamErrorMessage(error: unknown): string { +function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; } @@ -340,21 +321,21 @@ function getStreamErrorMessage(error: unknown): string { return nestedError; } } - return "Request failed."; -} - -/** - * React production error #185 is the minified form of "Maximum update depth - * exceeded". It is a client render-loop guard, not a backend/model failure, - * so it must never be surfaced as the answer's stream error. - */ -function isReactMaximumUpdateDepthError(message: string): boolean { - return ( - /maximum update depth exceeded/i.test(message) || - /minified react error\s*#185\b/i.test(message) || - /react\.dev\/errors\/185\b/i.test(message) - ); -} + return "Request failed."; +} + +/** + * React production error #185 is the minified form of "Maximum update depth + * exceeded". It is a client render-loop guard, not a backend/model failure, + * so it must never be surfaced as the answer's stream error. + */ +function isReactMaximumUpdateDepthError(message: string): boolean { + return ( + /maximum update depth exceeded/i.test(message) || + /minified react error\s*#185\b/i.test(message) || + /react\.dev\/errors\/185\b/i.test(message) + ); +} const TERMINAL_RUN_STATUSES = new Set([ "success", @@ -363,12 +344,9 @@ const TERMINAL_RUN_STATUSES = new Set([ "interrupted", ]); -// A completed server run normally sends a terminal SSE event. In local -// development, however, a Vite hot update, a proxy disconnect, or a brief -// network interruption can make the browser miss that final event while the -// backend continues (and successfully completes) the run. Do not probe on -// every render: only treat the stream as suspicious after it has been quiet -// for a while, then reconcile it from the authoritative run/state endpoints. +// Only probe /runs after an *exception* path has already set streamHoldOpen +// (dropped SSE, 409, React #185). Normal Q&A must not GET /runs on finish or +// every 5s — that made every send wait for a status round-trip. const STREAM_RECONCILIATION_IDLE_MS = 12_000; const STREAM_RECONCILIATION_POLL_MS = 5_000; // A run can legitimately be quiet while a long tool call / slow model write is @@ -608,14 +586,9 @@ export function useThreadStream({ "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), - ), - ); + // Held only on exception recovery (dropped SSE / 409). Normal turns rely + // on SDK `isLoading` so the send button unlocks as soon as SSE closes. + const [streamHoldOpen, setStreamHoldOpen] = useState(false); const sendInFlightRef = useRef(false); const sendEpochRef = useRef(0); // Filled after useStream; onFinish/onError may read it asynchronously. @@ -687,17 +660,17 @@ export function useThreadStream({ runMetadataStorageRef.current = getRunMetadataStorage(); } - const thread = useStream({ - client: getAPIClient(isMock), - assistantId: assistantId ?? "lead_agent", - threadId: onStreamThreadId, - // The SDK's current implementation falls back to synchronous external- - // store notifications when this option is omitted. A network chunk can - // contain dozens of token events, causing nested React renders and the - // intermittent production-only React #185 error. Coalesce notifications - // to one visual update per frame; stream data itself is not dropped. - throttle: 16, - reconnectOnMount: runMetadataStorageRef.current + const thread = useStream({ + client: getAPIClient(isMock), + assistantId: assistantId ?? "lead_agent", + threadId: onStreamThreadId, + // The SDK's current implementation falls back to synchronous external- + // store notifications when this option is omitted. A network chunk can + // contain dozens of token events, causing nested React renders and the + // intermittent production-only React #185 error. Coalesce notifications + // to one visual update per frame; stream data itself is not dropped. + throttle: 16, + reconnectOnMount: runMetadataStorageRef.current ? () => runMetadataStorageRef.current! : false, fetchStateHistory: { limit: 1 }, @@ -707,7 +680,6 @@ export function useThreadStream({ 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 = @@ -862,21 +834,21 @@ export function useThreadStream({ toast(e.message); } }, - onError(error) { - markStreamActivity(); - const message = getStreamErrorMessage(error); - // A synchronous SDK store notification used to let React's update-depth - // guard escape into the stream promise. Do not present that internal UI - // exception as a model failure. Reconcile/rejoin on the next task so the - // backend run can continue and the current answer is preserved. - if (isReactMaximumUpdateDepthError(message)) { - window.setTimeout(() => { - setStreamHoldOpen(true); - void confirmOrRejoinRef.current("error"); - }, 0); - return; - } - // Replacing a stalled local SSE reader aborts the in-flight fetch. That + onError(error) { + markStreamActivity(); + const message = getStreamErrorMessage(error); + // A synchronous SDK store notification used to let React's update-depth + // guard escape into the stream promise. Do not present that internal UI + // exception as a model failure. Reconcile/rejoin on the next task so the + // backend run can continue and the current answer is preserved. + if (isReactMaximumUpdateDepthError(message)) { + window.setTimeout(() => { + setStreamHoldOpen(true); + void confirmOrRejoinRef.current("error"); + }, 0); + return; + } + // Replacing a stalled local SSE reader aborts the in-flight fetch. That // is expected and must not look like a user-facing "network" failure. if (isAbortLikeError(error)) { return; @@ -944,34 +916,30 @@ export function useThreadStream({ } toast.error(message); }, - onFinish(state) { + onFinish(state, runMeta) { 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) { + // Trust a clean SSE close. Querying /runs here blocked every send for + // ~5s to paper over a rare premature disconnect; that case is handled + // when the next send hits 409 (or onError for a dropped stream). + if ( + isStaleStreamFinish({ + finishingRunId: runMeta?.run_id, + currentRunId: activeRunIdRef.current, + }) + ) { return; } - setStreamHoldOpen(true); - void confirmOrRejoinRef.current("finish").then((result) => { - if (result === "rejoined") { - return; - } - if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { - return; - } - setStreamHoldOpen(false); - // run 真正终结(非 rejoin):若深度研究报告流仍 active(tool_finished - // 帧丢失/被取消),标记中断,避免进度条永久转圈。 - const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null; - if (finThreadId) finalizeResearchReportStream(finThreadId); + const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + if (finThreadId) { + clearActiveRunId(finThreadId); + finalizeResearchReportStream(finThreadId); + } + activeRunIdRef.current = null; + setStreamHoldOpen(false); + if (state?.values) { listeners.current.onFinish?.(state.values); - void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - }); + } + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); }, }); @@ -1009,14 +977,6 @@ export function useThreadStream({ 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 { @@ -1192,11 +1152,10 @@ export function useThreadStream({ if (dropped && idleFor < 800) { return; } - const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId); - if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) { - return; - } - if (!stored && !streamHoldOpen) { + // Do not probe just because a run id is stored. Only the exception + // path sets streamHoldOpen; polling otherwise made every turn wait + // on GET /runs before the user could send again. + if (!streamHoldOpen) { return; } await confirmOrRejoin("poll"); @@ -1705,20 +1664,20 @@ export function useThreadStream({ thread.isLoading, ) ?? thread.values; - const mergedMessages = + const mergedMessages = Array.isArray(mergedValues?.messages) ? (mergedValues.messages as Message[]) - : thread.messages; - - const visibleThreadError = - thread.error && - isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) - ? undefined - : thread.error; - - const mergedThread = { - ...thread, - error: visibleThreadError, + : thread.messages; + + const visibleThreadError = + thread.error && + isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) + ? undefined + : thread.error; + + const mergedThread = { + ...thread, + error: visibleThreadError, values: mergedValues, messages: optimisticMessages.length > 0 diff --git a/frontend-web/src/core/threads/stream-display.test.ts b/frontend-web/src/core/threads/stream-display.test.ts index bc3216d3..15898caf 100644 --- a/frontend-web/src/core/threads/stream-display.test.ts +++ b/frontend-web/src/core/threads/stream-display.test.ts @@ -5,6 +5,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { collectHumanMessageIds, hasServerEchoedOptimisticHuman, + isStaleStreamFinish, mergeStreamDisplayValues, shouldDiscardTerminalOverlay, } from "./stream-display"; @@ -28,6 +29,30 @@ test("live streaming ignores frozen checkpoint messages so tokens can paint", () assert.deepEqual(afterStream?.messages, overrides.messages); }); +test("late onFinish of a prior run is stale once a newer run is live", () => { + assert.equal( + isStaleStreamFinish({ + finishingRunId: "old-run", + currentRunId: "new-run", + }), + true, + ); + assert.equal( + isStaleStreamFinish({ + finishingRunId: "run-1", + currentRunId: "run-1", + }), + false, + ); + assert.equal( + isStaleStreamFinish({ + finishingRunId: "run-1", + currentRunId: null, + }), + false, + ); +}); + test("a later send discards the previous run's getState overlay", () => { assert.equal( shouldDiscardTerminalOverlay({ diff --git a/frontend-web/src/core/threads/stream-display.ts b/frontend-web/src/core/threads/stream-display.ts index 23690af2..66812cf9 100644 --- a/frontend-web/src/core/threads/stream-display.ts +++ b/frontend-web/src/core/threads/stream-display.ts @@ -51,6 +51,25 @@ export function shouldDiscardTerminalOverlay(opts: { return false; } +/** + * True when an SSE onFinish belongs to a *previous* run, after the user + * already started a newer turn (协助卡提交是典型场景). + * + * Do **not** use `sendInFlight` / `isLoading` for this: `submit()` stays + * pending until the current stream ends, so those flags are still true on a + * normal same-turn finish and would skip unlock for every Q&A. + */ +export function isStaleStreamFinish(opts: { + finishingRunId?: string | null; + currentRunId?: string | null; +}): boolean { + return Boolean( + opts.finishingRunId && + opts.currentRunId && + opts.finishingRunId !== opts.currentRunId, + ); +} + export function collectHumanMessageIds( ...groups: Array ): Set { diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index d6a8e4d5..36e92345 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -1,21 +1,23 @@ "use client"; import { - Bot, ChevronLeft, ChevronRight, + Compass, Clock, DownloadIcon, - FilePenLine, MessageSquare, + PenTool, Pin, PinOff, Search, Star, + Users2, type LucideIcon, X, } from "lucide-react"; import Link from "next/link"; +import { motion } from "motion/react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -48,6 +50,9 @@ import { import { formatTimeAgo } from "@/core/utils/datetime"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; +import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions"; +import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts"; +import { useResearchSessions } from "@/strategy-components/api/deep-research"; import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab"; import { WritingHistoryTab } from "./chats-history/WritingHistoryTab"; @@ -79,19 +84,19 @@ const HISTORY_TABS: { id: "roundtable", label: "多智能体会商", description: "多角色协作研讨", - icon: Bot, + icon: Users2, }, { id: "writing", label: "AI写作", description: "写作任务与草稿", - icon: FilePenLine, + icon: PenTool, }, { id: "research", label: "深度研究", description: "研究任务与报告", - icon: Search, + icon: Compass, }, ]; @@ -126,11 +131,20 @@ export default function ChatsPage({ const [importOpen, setImportOpen] = useState(false); // 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。 const [tabTotal, setTabTotal] = useState(null); + // 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。 + const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts(); + const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions(); + const { data: researchTabData } = useResearchSessions(100); useEffect(() => { document.title = `${t.pages.chats} - ${t.pages.appName}`; }, [t.pages.chats, t.pages.appName]); + useEffect(() => { + void refreshRoundtableTabDrafts(); + void refreshWritingTabSessions(); + }, [refreshRoundtableTabDrafts, refreshWritingTabSessions]); + // Debounce the search box so each keystroke doesn't fire a backend query. useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 300); @@ -224,7 +238,7 @@ export default function ChatsPage({ "group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5", pinned ? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm" - : "border-neutral-200/80 bg-white hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs", + : "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs", )} > - ); - })} + className={cn( + "relative z-10 flex items-center gap-2", + isActive + ? "font-semibold text-red-700" + : "text-neutral-600", + )} + > + + {count > 0 && ( + + {count} + + )} + + ); + })} +
{activeTab === "chats" && ( + + + + + ); } export function RoundtableHistoryTab({ - query, onTotalChange, }: { - query: string; onTotalChange?: (total: number) => void; }) { const navigate = useNavigate(); - const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts(); - const [renameId, setRenameId] = useState(null); - const [renameTitle, setRenameTitle] = useState(""); - const [shareId, setShareId] = useState(null); + const { drafts, loading, refresh, remove } = useRoundtableDrafts(); + const [records, setRecords] = useState>({}); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTag, setSelectedTag] = useState(null); + const [sortType, setSortType] = useState("updated_desc"); useEffect(() => { void refresh(); }, [refresh]); - const keyword = query.trim().toLowerCase(); - const filtered = keyword - ? drafts.filter((draft) => - (draft.title || "未命名草稿").toLowerCase().includes(keyword), - ) - : drafts; + useEffect(() => { + let cancelled = false; + if (drafts.length === 0) { + setRecords({}); + return () => { + cancelled = true; + }; + } + + void Promise.all( + drafts.map(async (draft) => { + try { + return await getDraft(draft.id); + } catch { + return null; + } + }), + ).then((fullDrafts) => { + if (cancelled) return; + setRecords( + Object.fromEntries( + fullDrafts + .filter((record): record is DraftRecord => record !== null) + .map((record) => [record.id, record]), + ), + ); + }); + + return () => { + cancelled = true; + }; + }, [drafts]); + + const allItems = useMemo( + () => + drafts.map((draft) => { + const record = records[draft.id]; + return { + draft, + record, + status: progressOf(draft), + businessChainName: businessChainNameOf(draft, record), + messageCount: interactionCount(record), + model: modelOf(record), + tags: tagsOf(draft, record), + }; + }), + [drafts, records], + ); + const availableTags = useMemo( + () => [...new Set(allItems.flatMap((item) => item.tags))], + [allItems], + ); + const filteredItems = useMemo(() => { + const keyword = searchQuery.trim().toLowerCase(); + return allItems + .filter((item) => { + if (selectedTag && !item.tags.includes(selectedTag)) return false; + return !keyword || (item.draft.title || "未命名会商").toLowerCase().includes(keyword); + }) + .sort((left, right) => { + if (sortType === "messages_desc") return right.messageCount - left.messageCount; + const delta = timestamp(right.draft.updatedAt) - timestamp(left.draft.updatedAt); + return sortType === "updated_desc" ? delta : -delta; + }); + }, [allItems, searchQuery, selectedTag, sortType]); useEffect(() => { - onTotalChange?.(filtered.length); - }, [filtered.length, onTotalChange]); + onTotalChange?.(filteredItems.length); + }, [filteredItems.length, onTotalChange]); const openDraft = (draft: DraftMeta) => { sessionStorage.setItem("roundtable:pending-draft", draft.id); navigate(ROUNDTABLE_PATH); }; - const handleRename = async (id: string, title: string) => { - try { - await rename(id, title); - toast.success("已重命名"); - } catch { - toast.error("重命名失败,请重试"); - } finally { - setRenameId(null); - } - }; - - const handleDelete = async (draft: DraftMeta) => { + const deleteDraft = async (draft: DraftMeta) => { if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return; try { await remove(draft.id); @@ -119,90 +332,135 @@ export function RoundtableHistoryTab({ } }; - if (loading && drafts.length === 0) { - return ( -
- 加载中… -
- ); - } - - if (filtered.length === 0) { - return ( -
- {keyword ? "没有匹配的会商记录" : "暂无会商记录"} -
- ); - } - return ( - <> -
- {filtered.map((draft) => { - const progress = progressOf(draft); - return ( - } - title={desensitize(draft.title || "未命名草稿")} - meta={ - <> - - · - - {progress.stepLabel} - - · - - {draft.chainTitle || "自由模式"} - - · - {formatRelative(draft.updatedAt)} - - } - onOpen={() => openDraft(draft)} +
+
+
+
+ + {selectedTag && ( +
+ + {selectedTag} + +
+ )} +
+ +
+ + setSearchQuery(event.target.value)} + placeholder="搜索会商记录…" + className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20" + /> + {searchQuery ? ( + + ) : ( +
+ ⌘K +
+ )} +
+
+ +
+
+ + 共 {filteredItems.length} 条会商记录 + {searchQuery && (匹配 “{searchQuery}”)} + + {availableTags.length > 0 && ( +
+ 快捷标签: + {availableTags.slice(0, 4).map((tag) => ( + + ))} +
+ )} +
+ +
+
+ + +
+
+
- setRenameId(null)} - onConfirm={(title) => renameId && void handleRename(renameId, title)} - /> - - { - if (!o) setShareId(null); - }} - /> - + {loading && allItems.length === 0 ? ( +
加载中…
+ ) : filteredItems.length === 0 ? ( +
+ {searchQuery || selectedTag ? "没有匹配的会商记录" : "暂无会商记录"} +
+ ) : ( +
+ {filteredItems.map((item) => ( + void deleteDraft(item.draft)} + onOpen={() => openDraft(item.draft)} + /> + ))} +
+ )} +
); } diff --git a/frontend-web/src/pages/chats-history/history-tab-shared.tsx b/frontend-web/src/pages/chats-history/history-tab-shared.tsx index b693d240..cce68560 100644 --- a/frontend-web/src/pages/chats-history/history-tab-shared.tsx +++ b/frontend-web/src/pages/chats-history/history-tab-shared.tsx @@ -43,7 +43,7 @@ export function HistoryTypeBadge({ label: string; }) { return ( - + {label} @@ -68,7 +68,7 @@ export function HistoryRow({ children?: ReactNode; }) { return ( -
+
)} - )} - + {!hideFollowupActions && ( + + )} - + {!hideFollowupActions && ( + + )} {footerAction ?? (!hideEnterRoundtable && ( diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 7b6773cd..d11b67d1 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -2166,6 +2166,8 @@ export default function RoundtablePlanningPage({ onStopIntent: step1.stopIntentStream, onSkipQuestion: handleSkipQuestion, onFollowUp: handleAgentFollowUp, + // 多智能体会商页去掉「继续追问」「跳过此项」快捷键 —— 干预走输入框直接发送即可。 + hideFollowupActions: true, onEnterRoundtable: () => { if (!step1.intentReady) { triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入"); From dd560ceb467bbb368a3495ddb54f73b01e5dd74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 20:02:21 +0800 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=E4=BC=9A=E5=95=86=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E9=A2=84=E8=A7=88=E5=A4=B4=E4=BB=85=E5=9C=A8=E5=8F=AF?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=9A=84=20Markdown=20=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=8F=90=E4=BA=A4=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Step2SandboxLayout.tsx | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx index 509700f8..f87c424a 100644 --- a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx @@ -144,6 +144,25 @@ export function Step2SandboxLayout({ }, [artifacts, isStreaming, seatMessages]); const previewThreadId = activeThreadId ?? ""; + const selectedArtifactTarget = useMemo(() => { + const target = { + threadId: previewThreadId, + path: selectedArtifact ?? "", + content: selectedArtifactContent, + }; + return taskArtifactSubmission?.targetFor(target) ?? target; + }, [ + previewThreadId, + selectedArtifact, + selectedArtifactContent, + taskArtifactSubmission, + ]); + // 8BF 等接入任务过程产物提交的页面,用提交按钮替换文件类型;其余普通/任务深链 + // 会商仍保留原有文件类型徽标。非 Markdown 产物本身不能提交,也应显示类型。 + const showTaskArtifactSubmit = Boolean( + taskArtifactSubmission?.enabled + && taskArtifactSubmission.isMarkdown(selectedArtifactTarget), + ); const artifactPanelBody = (
- )} + ) : undefined} + headerLeadingActionBare={showTaskArtifactSubmit} /> ) : (
From 74fc8492412fb5ac46ef30e9aa9318f4a9783dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 21:24:08 +0800 Subject: [PATCH 18/22] =?UTF-8?q?fix(=E4=BC=9A=E5=95=86=E6=BB=9A=E5=8A=A8)?= =?UTF-8?q?:=20=E8=AE=A9=E7=AC=AC=E4=BA=8C=E3=80=81=E4=B8=89=E6=AD=A5?= =?UTF-8?q?=E6=96=B0=E6=B6=88=E6=81=AF=E4=B8=8E=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=8D=B3=E6=97=B6=E5=AE=9A=E4=BD=8D=E5=88=B0?= =?UTF-8?q?=E5=BA=95=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为圆桌滚动钩子增加强制落底能力,阶段切换和布局变化不再被平滑滚动的旧高度截断。\n- 第二步用户发送消息后立即下滑到底部,不再等待模型首个输出。\n- 经典结果绘制与大屏对话面板接入内容高度观察,新增气泡、Markdown 和工具卡继续增高时保持跟随。\n- 保留用户手动上翻时的正常阅读行为,仅在新一轮发起或关键阶段切换时强制定位。 --- .../components/DashboardChatPanel.tsx | 20 +++++++++-- .../components/Step2Panel.tsx | 35 +++++++++++++++++-- .../components/Step3ReportMode.tsx | 20 +++++++++-- .../hooks/useAutoScroll.ts | 20 ++++++++++- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx index 4baecc2a..8c6d052b 100644 --- a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx +++ b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx @@ -6,7 +6,7 @@ * 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用 * `stripReportFence` 替换成一句提示。 */ -import { useMemo, useRef, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { CheckCircle, Loader2, @@ -64,7 +64,19 @@ export function DashboardChatPanel({ }: DashboardChatPanelProps) { const inputLocked = phase === "creating"; const scrollRef = useRef(null); - const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const contentRef = useRef(null); + const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll( + scrollRef, + [dialogues, phase], + { contentRef }, + ); + const previousDialogueCountRef = useRef(dialogues.length); + useEffect(() => { + if (dialogues.length > previousDialogueCountRef.current) { + forceScrollToBottom(); + } + previousDialogueCountRef.current = dialogues.length; + }, [dialogues.length, forceScrollToBottom]); const [localSettings] = useLocalSettings(); const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const dialogueMetrics = useDialogueSpeedMetrics(dialogues); @@ -156,7 +168,8 @@ export function DashboardChatPanel({ {/* 聊天记录 */}
-
+
+
{phase === "creating" && dialogues.length === 0 && (
@@ -209,6 +222,7 @@ export function DashboardChatPanel({ )}
)} +
{!atBottom && } diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 7e6ed06f..d5ef201a 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -225,11 +225,40 @@ export function Step2Panel(props: Step2PanelProps) { // contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在 // 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。 const step2ContentRef = useRef(null); - const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = + const { + atBottom: step2AtBottom, + scrollToBottom: scrollStep2ToBottom, + forceScrollToBottom: forceStep2ToBottom, + } = useAutoScroll(step2ScrollRef, [step2RoundtableDialogues], { contentRef: step2ContentRef, }); + const latestUserDialogueId = useMemo(() => { + for (let index = step2RoundtableDialogues.length - 1; index >= 0; index -= 1) { + const dialogue = step2RoundtableDialogues[index]; + if (dialogue.avatarType === "human") { + return dialogue.id; + } + } + return null; + }, [step2RoundtableDialogues]); + const previousUserDialogueIdRef = useRef(latestUserDialogueId); + + // 用户消息入列后立即定位到底部,不等待后续模型流式消息触发滚动。 + useEffect(() => { + if ( + latestUserDialogueId === null || + latestUserDialogueId === previousUserDialogueIdRef.current + ) { + return; + } + + previousUserDialogueIdRef.current = latestUserDialogueId; + const frameId = window.requestAnimationFrame(forceStep2ToBottom); + return () => window.cancelAnimationFrame(frameId); + }, [forceStep2ToBottom, latestUserDialogueId]); + // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 // 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。 const [localSettings] = useLocalSettings(); @@ -270,8 +299,8 @@ export function Step2Panel(props: Step2PanelProps) { // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 useEffect(() => { - scrollStep2ToBottom(); - }, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]); + forceStep2ToBottom(); + }, [forceStep2ToBottom, isParallelRunning, selectedParallelSeats]); const resolveAgentName = useCallback( (idOrName: string) => diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index 7ced1f4d..f387f9c5 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -545,7 +545,21 @@ function AgentProgressPanel({ // 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。 const inputLocked = phase === "creating"; const scrollRef = useRef(null); - const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const contentRef = useRef(null); + const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll( + scrollRef, + [dialogues, phase], + { contentRef }, + ); + // 新的绘制/修改气泡一出现就定位到最新进度;后续 Markdown、工具卡高度变化 + // 由 contentRef 的 ResizeObserver 持续跟随。 + const previousDialogueCountRef = useRef(dialogues.length); + useEffect(() => { + if (dialogues.length > previousDialogueCountRef.current) { + forceScrollToBottom(); + } + previousDialogueCountRef.current = dialogues.length; + }, [dialogues.length, forceScrollToBottom]); // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 const [localSettings] = useLocalSettings(); const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; @@ -605,8 +619,9 @@ function AgentProgressPanel({
+
{phase === "creating" && dialogues.length === 0 && (
@@ -678,6 +693,7 @@ function AgentProgressPanel({
)} +
{!atBottom && }
( atBottom: boolean; /** Force-scroll to the bottom and resume auto-follow. */ scrollToBottom: () => void; + /** Immediately jump to the bottom and resume auto-follow (for phase transitions). */ + forceScrollToBottom: () => void; } { const threshold = options.threshold ?? 32; const contentRef = options.contentRef; @@ -187,5 +189,21 @@ export function useAutoScroll( setAtBottom(true); }, [ref]); - return { atBottom, scrollToBottom }; + // 阶段切换/用户刚发送消息时不能依赖 smooth 动画:下一帧可能还会插入校验卡、 + // Markdown 或工具步骤,动画目标会停在旧高度。这里直接写入 scrollTop,由内容 + // ResizeObserver 继续接管后续增高,保证关键阶段始终落在最新底部。 + const forceScrollToBottom = useCallback(() => { + const el = ref.current; + if (!el) return; + programmaticScrollTargetRef.current = Math.max( + 0, + el.scrollHeight - el.clientHeight, + ); + el.scrollTop = el.scrollHeight; + lastObservedScrollTopRef.current = el.scrollTop; + atBottomRef.current = true; + setAtBottom(true); + }, [ref]); + + return { atBottom, scrollToBottom, forceScrollToBottom }; } From 2254841822652e28d387cb2e91815839442673e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 21:24:30 +0800 Subject: [PATCH 19/22] =?UTF-8?q?fix(=E4=BC=9A=E5=95=86=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E6=AD=A5):=20=E6=81=A2=E5=A4=8D=E6=80=BB=E6=8E=A7=E6=B4=BE?= =?UTF-8?q?=E6=B4=BB=E5=AE=9E=E6=97=B6=E5=B1=95=E7=A4=BA=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=B2=99=E7=AE=B1=E9=A2=84=E8=A7=88=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流式解析改为跟踪当前 tool call id,连续多次 agent_orchestration 派活时前端即时展示最新席位和任务,不再等模型完整回复后一次性出现。 - 总控派活步骤按 tool call 独立归档并合并增量参数,避免同名工具调用互相覆盖或只显示半截任务。 - 沙箱自动打开改为按单个产物预览键管理:用户关闭当前文件后不反复弹出,但后续新文件仍可自动预览。 - 为圆桌虚拟 write_file 补齐工具完成结果,并在席位收尾和历史草稿恢复时兜底收口,防止完成文件长期显示“正在生成”。 --- .../roundtable-planning/api/multi-agent.ts | 38 ++- .../hooks/useStep2Orchestration.ts | 232 ++++++++++++++---- .../roundtable-planning/lib/step-display.tsx | 2 +- 3 files changed, 214 insertions(+), 58 deletions(-) diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index d4509999..9c0df9b4 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -290,12 +290,14 @@ export interface StreamMultiAgentRequest { * show progressive status messages during the inevitable first-token wait. * `name` is the tool name when phase is `tool_calling` (or empty otherwise). * `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple - * 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。 + * 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。 + * `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。 */ onPhaseChange?: ( phase: StreamPhase, name?: string, args?: Record, + toolCallId?: string, ) => void; /** * 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个 @@ -619,27 +621,32 @@ export async function streamMultiAgent( let currentPhase: StreamPhase = "connecting"; let currentPhaseName = ""; let currentPhaseArgsSignature = ""; + let currentPhaseToolCallId = ""; req.onPhaseChange?.("connecting"); const setPhase = ( next: StreamPhase, name?: string, args?: Record, + toolCallId?: string, ) => { const nextName = name ?? ""; const nextArgsSignature = phaseArgsSignature(args); + const nextToolCallId = toolCallId ?? ""; // 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths // 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。 if ( next === currentPhase && nextName === currentPhaseName && - nextArgsSignature === currentPhaseArgsSignature + nextArgsSignature === currentPhaseArgsSignature && + nextToolCallId === currentPhaseToolCallId ) { return; } currentPhase = next; currentPhaseName = nextName; currentPhaseArgsSignature = nextArgsSignature; - req.onPhaseChange?.(next, name, args); + currentPhaseToolCallId = nextToolCallId; + req.onPhaseChange?.(next, name, args, toolCallId); }; /** @@ -811,24 +818,29 @@ export async function streamMultiAgent( if (chunkType === "tool") { setPhase("tool_result"); } else if (hasToolCall) { - // Pick the most informative name + args we have so far. + // Pick the active (latest) call's name + args. A leader can dispatch + // several seats with the same `agent_orchestration` tool in one run; + // choosing the first accumulated call would leave the UI stuck on an + // earlier assignment until the final response arrives. // 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候, // 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。 - const firstCall = + const activeCall = effectiveToolCalls && effectiveToolCalls.length > 0 - ? (effectiveToolCalls[0] as Record) + ? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record) : Array.isArray(toolCallChunks) && toolCallChunks.length > 0 - ? (toolCallChunks[0] as Record) + ? (toolCallChunks[toolCallChunks.length - 1] as Record) : null; const toolName = - firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; + activeCall && typeof activeCall.name === "string" ? activeCall.name : ""; const args = - firstCall && - typeof firstCall.args === "object" && - firstCall.args !== null - ? (firstCall.args as Record) + activeCall && + typeof activeCall.args === "object" && + activeCall.args !== null + ? (activeCall.args as Record) : undefined; - setPhase("tool_calling", toolName, args); + const toolCallId = + activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined; + setPhase("tool_calling", toolName, args, toolCallId); } else if ( (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 42ec7d56..5b70d648 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -16,7 +16,10 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useArtifacts } from "@/components/workspace/artifacts"; +import { + artifactPreviewKey, + useArtifacts, +} from "@/components/workspace/artifacts"; import { initMultiAgent, streamMultiAgent, @@ -37,6 +40,21 @@ type RunFileRef = NonNullable[number]; type SeatDelivery = { name: string; content: string }; +/** + * `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活, + * 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。 + */ +function streamStepKey( + phase: StreamPhase, + toolName?: string, + toolCallId?: string, +): string { + if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) { + return `${phase}:${toolName}:${toolCallId}`; + } + return `${phase}:${toolName ?? ""}`; +} + /** In-memory cursor for a business-chain human-validation pause. */ type HumanValidationContinuation = | { mode: "chain"; nextSeatIndex: number } @@ -179,7 +197,7 @@ export interface ParallelSeatView { } /** - * Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext + * Stub thread message —— 用于把 special seat SSE 拿到的工具调用(及其合成 tool result)注入到 ThreadContext * 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL * `write-file:?message_id=...&tool_call_id=...` 直接读 `args.content`, * 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。 @@ -191,9 +209,59 @@ export interface StubToolCall { } export interface StubAiMessage { id: string; - type: "ai"; + type: "ai" | "tool"; content: string; tool_calls: StubToolCall[]; + /** tool 类型消息对应的原始 tool call;供沙箱判定 write_file 已完成。 */ + tool_call_id?: string; +} + +/** + * 圆桌流为了实时预览会合成一条带 write_file 的 ai 消息。主聊天真实流在工具执行结束后 + * 还会收到对应的 tool 消息;圆桌 special 流此前漏了这一步,导致沙箱永久认为文件仍在写。 + * + * 这里只补齐已经存在的 write_file 调用,绝不根据整条会商/其它并行流的 running 状态推断, + * 因而每个席位、报告、流程图和大屏预览的完成态彼此独立。 + */ +export function completeStubWriteFile( + messages: StubAiMessage[], + toolCallId: string, +): StubAiMessage[] { + if (!toolCallId || messages.some((message) => message.type === "tool" && message.tool_call_id === toolCallId)) { + return messages; + } + const source = messages.find( + (message) => + message.type === "ai" && + message.tool_calls.some( + (toolCall) => toolCall.id === toolCallId && toolCall.name === "write_file", + ), + ); + if (!source) return messages; + return [ + ...messages, + { + id: `roundtable-stub-tool-result-${source.id}-${toolCallId}`, + type: "tool", + content: "", + tool_calls: [], + tool_call_id: toolCallId, + }, + ]; +} + +/** 补齐一段已正常收尾的圆桌 stub 中全部 write_file 的工具返回。 */ +export function completeAllStubWriteFiles(messages: StubAiMessage[]): StubAiMessage[] { + let next = messages; + for (const message of messages) { + if (message.type !== "ai") continue; + for (const toolCall of message.tool_calls) { + if (toolCall.name === "write_file") { + next = completeStubWriteFile(next, toolCall.id); + } + } + } + return next; } /** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */ @@ -515,19 +583,12 @@ export function useStep2Orchestration( select: selectArtifact, setOpen: setArtifactsOpen, deselect: deselectArtifact, - autoOpen, - autoSelect, + openStreamedArtifact, } = useArtifacts(); const artifactPathsRef = useRef([]); useEffect(() => { artifactPathsRef.current = artifacts ?? []; }, [artifacts]); - // 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref - // 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。 - const autoOpenFlagsRef = useRef({ autoOpen, autoSelect }); - useEffect(() => { - autoOpenFlagsRef.current = { autoOpen, autoSelect }; - }, [autoOpen, autoSelect]); /** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */ const hasAutoOpenedRef = useRef>(new Set()); @@ -562,6 +623,9 @@ export function useStep2Orchestration( const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState< Step2Dialogue[] >([]); + // 由气泡 id 反查真实席位线程。完成一条气泡时只收口该线程的虚拟 write_file, + // 不会误影响仍在并行运行的其它席位。 + const dialogueThreadIdRef = useRef>({}); const [pendingClarification, setPendingClarification] = useState(null); const [clarificationDraft, setClarificationDraft] = useState(""); @@ -837,13 +901,42 @@ export function useStep2Orchestration( [scheduleSeatStubMessagesFlush], ); + /** 将某次真实 tool 返回同步到对应的虚拟 write_file 预览。 */ + const completeSeatStubWriteFile = useCallback( + (seatThreadId: string, toolCallId: string) => { + const current = seatStubMessagesRef.current[seatThreadId] ?? []; + const nextMessages = completeStubWriteFile(current, toolCallId); + if (nextMessages === current) return; + const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages }; + seatStubMessagesRef.current = nextMap; + pendingSeatStubMessagesRef.current = nextMap; + scheduleSeatStubMessagesFlush(); + }, + [scheduleSeatStubMessagesFlush], + ); + + /** special 流正常收尾的兜底:补齐该席位尚未透传出来的 write_file tool 返回。 */ + const completeSeatStubWriteFiles = useCallback( + (seatThreadId: string | undefined) => { + if (!seatThreadId) return; + const current = seatStubMessagesRef.current[seatThreadId] ?? []; + const nextMessages = completeAllStubWriteFiles(current); + if (nextMessages === current) return; + const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages }; + seatStubMessagesRef.current = nextMap; + pendingSeatStubMessagesRef.current = nextMap; + scheduleSeatStubMessagesFlush(); + }, + [scheduleSeatStubMessagesFlush], + ); + /** * 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content, * 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。 * * auto=true 流式期间: * - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule); - * - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。 + * - 用户手动关闭过**当前文件**时不反复弹开;后续新的 write_file 调用仍允许自动预览。 * auto=false(用户点击步骤 / 文件卡)始终强制打开。 */ const openSeatVirtualArtifact = useCallback( @@ -861,8 +954,6 @@ export function useStep2Orchestration( const manual = opts?.manual ?? !auto; if (auto) { if (hasAutoOpenedRef.current.has(virtualUrl)) return; - const flags = autoOpenFlagsRef.current; - if (!flags.autoOpen || !flags.autoSelect) return; // 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写 // 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。 if ( @@ -882,6 +973,11 @@ export function useStep2Orchestration( return; } hasAutoOpenedRef.current.add(virtualUrl); + setActiveArtifactThreadId(seatThreadId); + // 由 ArtifactsProvider 以 tool-call 维度管理「手动关闭后不再弹同一份文件」: + // 当前文件被关闭过 → false;下一次 write_file 的 previewKey 不同 → 仍会自动打开。 + openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl)); + return; } setActiveArtifactThreadId(seatThreadId); const open = () => { @@ -889,13 +985,9 @@ export function useStep2Orchestration( selectArtifact(virtualUrl, !manual); setArtifactsOpen(true); }; - if (auto) { - window.setTimeout(open, 100); - } else { - open(); - } + open(); }, - [selectArtifact, setArtifactsOpen], + [openStreamedArtifact, selectArtifact, setArtifactsOpen], ); const buildWriteFileVirtualUrl = useCallback( @@ -1156,6 +1248,13 @@ export function useStep2Orchestration( const chunk = frame[0]; if (!chunk || typeof chunk !== "object") return; const obj = chunk as Record; + // special 流透传真实工具返回时,必须同步收口虚拟 write_file;否则右侧沙箱会把 + // 已完成的文件永久当成「正在生成」。 + if (String(obj.type ?? "").toLowerCase() === "tool") { + const toolCallId = typeof obj.tool_call_id === "string" ? obj.tool_call_id : ""; + if (toolCallId) completeSeatStubWriteFile(seatThreadId, toolCallId); + return; + } const toolCalls = obj.tool_calls; if (!Array.isArray(toolCalls) || toolCalls.length === 0) return; for (const tc of toolCalls) { @@ -1171,7 +1270,7 @@ export function useStep2Orchestration( upsertSeatToolCall(seatThreadId, name, argsObj); } }, - [upsertSeatToolCall], + [completeSeatStubWriteFile, upsertSeatToolCall], ); // ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)────────── @@ -1402,15 +1501,28 @@ export function useStep2Orchestration( let nextDialogue: Step2Dialogue; if (last && last.key === key) { - // 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到 - // 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个 - // content chunk 更新对话 state。 - const hasVisibleArgs = (value?: Record) => - !!value && Object.keys(value).some((argKey) => argKey !== "content"); - if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev; - const nextSteps = [...steps]; - nextSteps[nextSteps.length - 1] = { ...last, args }; - nextDialogue = { ...dialogue, steps: nextSteps }; + // 总控派活的 task 是 tool_call JSON 的流式增量。不能沿用普通工具的 + // 「首次可见参数即停」策略,否则只显示 agent_name,整段任务会在收尾时 + // 才被最终消息一次性补上。 + if (key.startsWith("tool_calling:agent_orchestration:") && args) { + const mergedArgs = { ...(last.args ?? {}), ...args }; + if (JSON.stringify(last.args ?? {}) === JSON.stringify(mergedArgs)) { + return prev; + } + const nextSteps = [...steps]; + nextSteps[nextSteps.length - 1] = { ...last, args: mergedArgs }; + nextDialogue = { ...dialogue, steps: nextSteps }; + } else { + // 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到 + // 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个 + // content chunk 更新对话 state。 + const hasVisibleArgs = (value?: Record) => + !!value && Object.keys(value).some((argKey) => argKey !== "content"); + if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev; + const nextSteps = [...steps]; + nextSteps[nextSteps.length - 1] = { ...last, args }; + nextDialogue = { ...dialogue, steps: nextSteps }; + } } else { nextDialogue = { ...dialogue, @@ -1438,6 +1550,9 @@ export function useStep2Orchestration( agentThreadId?: string; }): number => { const id = Date.now() + Math.random(); + if (card.agentThreadId) { + dialogueThreadIdRef.current[id] = card.agentThreadId; + } setStep2RoundtableDialogues((prev) => [ ...prev, { @@ -1641,10 +1756,8 @@ export function useStep2Orchestration( const opened = openSeatLatestArtifact(tid); if (!opened) { // 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→ - // 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen); - // 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false - // (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的 - // `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。 + // 用 deselect 收起并清空旧预览,不把别的席位当前文件标记为「手动关闭」。这样该席位 + // 随后的新 write_file 仍能按自己的 previewKey 自动打开。 setActiveArtifactThreadId(null); deselectArtifact(); } @@ -1658,6 +1771,9 @@ export function useStep2Orchestration( const finalizeDialogue = useCallback( (id: number, finalContent: string, confidence?: string) => { + // 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。 + // 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。 + completeSeatStubWriteFiles(dialogueThreadIdRef.current[id]); // 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。 directStreamDeltaRef.current.delete(id); if ( @@ -1693,7 +1809,7 @@ export function useStep2Orchestration( return changed ? next : prev; }); }, - [], + [completeSeatStubWriteFiles], ); const highlightSpeaker = useCallback( @@ -1991,13 +2107,13 @@ export function useStep2Orchestration( signal: leaderAborter.signal, onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(leaderBubbleId, label); appendStepIfNew( leaderBubbleId, label, - `${phase}:${toolName ?? ""}`, + streamStepKey(phase, toolName, toolCallId), args, ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); @@ -2274,10 +2390,15 @@ export function useStep2Orchestration( signal: leaderAborter.signal, onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(leaderBubbleId, label); - appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew( + leaderBubbleId, + label, + streamStepKey(phase, toolName, toolCallId), + args, + ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -2460,10 +2581,15 @@ export function useStep2Orchestration( signal: closeoutAborter.signal, onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false), onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(closeoutBubbleId, label); - appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew( + closeoutBubbleId, + label, + streamStepKey(phase, toolName, toolCallId), + args, + ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -2639,10 +2765,15 @@ export function useStep2Orchestration( signal: leaderAborter.signal, onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(leaderBubbleId, label); - appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew( + leaderBubbleId, + label, + streamStepKey(phase, toolName, toolCallId), + args, + ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -3431,8 +3562,20 @@ export function useStep2Orchestration( setHasConsensus(snapshot.hasConsensus); setConsensusPercentage(snapshot.consensusPercentage); setBudgetLimit(snapshot.budgetLimit); - const restoredStubs = snapshot.seatStubMessages ?? {}; + // 历史草稿是静态完成记录;旧版本未持久化 tool result 的 write_file 也必须收口, + // 否则从「最终方案展示」回看时会错误显示仍在生成。 + const restoredStubs = Object.fromEntries( + Object.entries(snapshot.seatStubMessages ?? {}).map(([threadId, messages]) => [ + threadId, + completeAllStubWriteFiles(messages), + ]), + ); replaceSeatStubMessages(restoredStubs); + dialogueThreadIdRef.current = Object.fromEntries( + (snapshot.step2RoundtableDialogues ?? []) + .filter((dialogue) => Boolean(dialogue.agentThreadId)) + .map((dialogue) => [dialogue.id, dialogue.agentThreadId!]), + ); // 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。 hasAutoOpenedRef.current = new Set(); // 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上, @@ -3499,6 +3642,7 @@ export function useStep2Orchestration( setCoordinatorName(null); setInitError(null); setStep2RoundtableDialogues([]); + dialogueThreadIdRef.current = {}; setLastLeaderContent(""); setHasConsensus(false); setConsensusPercentage(0); diff --git a/frontend-web/src/roundtable-planning/lib/step-display.tsx b/frontend-web/src/roundtable-planning/lib/step-display.tsx index 36ffa9dd..e30310dd 100644 --- a/frontend-web/src/roundtable-planning/lib/step-display.tsx +++ b/frontend-web/src/roundtable-planning/lib/step-display.tsx @@ -29,7 +29,7 @@ import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble" /** * 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。 - * 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。 + * 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧中当前工具调用的 args。 * * 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边 * 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。 From fbf9031e25a6b62c14e79b025b2d409c027b1e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 21:24:47 +0800 Subject: [PATCH 20/22] =?UTF-8?q?feat(=E4=BC=9A=E5=95=86=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=AD=A5):=20=E6=81=A2=E5=A4=8D=E6=99=AE=E9=80=9A=E4=BC=9A?= =?UTF-8?q?=E5=95=86=E5=AE=8C=E6=95=B4=E6=88=90=E6=9E=9C=E5=B9=B6=E9=9A=94?= =?UTF-8?q?=E7=A6=BB8BF=E6=8A=A5=E5=91=8A=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为普通多智能体会商恢复报告完成后的结构化流程图、最终方案展示、经典结果绘制和大屏多页,并保持三个生成流独立并行。 - 8BF 入口显式采用 report-only 模式:只生成和编辑 Markdown 总结报告,不再启动或露出流程图、结果绘制及大屏功能。 - 结构化流程图增加抽取、校验、暂停、失败重试和歧义选择交互;报告完成后状态文案准确切换,自动滚动定位到抽取与校验进度。 - 为总结报告、经典 HTML 绘制和大屏虚拟 write_file 补齐完成结果及历史恢复处理,避免并行分支或旧草稿让完成产物误显示为生成中。 - 最终方案展示中的已落盘文件直接按完成态预览,并恢复 MD、HTML 等文件类型徽标,不再被提交按钮覆盖。 --- .../pages/EightBfRoundtablePage.tsx | 1 + .../components/HighFidelityReport.tsx | 76 ++++-- .../components/Step3Panel.tsx | 4 + .../components/Step3SummaryMode.tsx | 253 ++++++++++++++++-- .../hooks/useStep3Dashboard.ts | 40 ++- .../hooks/useStep3Report.ts | 41 +++ .../hooks/useStep3Summary.ts | 72 ++++- .../pages/RoundtablePlanningPage.tsx | 58 ++-- 8 files changed, 469 insertions(+), 76 deletions(-) diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index 4bd4f5ed..ef5dbf95 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -273,6 +273,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa hideHeaderSaveDraft hideHeaderSettings newTaskLabel="新建会话" + step3Mode="report-only" hideDefaultStep1RightSidebar step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`} step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`} diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 34d98be8..124a5637 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -61,6 +61,8 @@ interface SeatDelivery { interface HighFidelityReportProps { useStep1Chrome?: boolean; headerTitle?: string; + /** `report-only` is used by 8BF; normal roundtables retain every Step 3 view. */ + step3Mode?: "full" | "report-only"; threadIds: ThreadIdMap | null; coordinatorName: string | null; /** The most recent leader-agent text — rendered as the final consensus. */ @@ -121,9 +123,26 @@ function formatGeneratedAt(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +/** 最终成果区打开的是已落盘文件,不走实时流;独立给工具栏一个稳定的文件类型徽标。 */ +function artifactTypeLabel(fileName: string): string { + const normalized = fileName.trim().toLowerCase(); + if (/\.(md|markdown)$/.test(normalized)) return "MD"; + if (/\.html?$/.test(normalized)) return "HTML"; + if (/\.(mmd|mermaid)$/.test(normalized)) return "MMD"; + if (/\.json$/.test(normalized)) return "JSON"; + if (/\.csv$/.test(normalized)) return "CSV"; + if (/\.ya?ml$/.test(normalized)) return "YAML"; + if (/\.tsx?$/.test(normalized)) return "TS"; + if (/\.js$/.test(normalized)) return "JS"; + if (/\.py$/.test(normalized)) return "PY"; + if (/\.txt$/.test(normalized)) return "TXT"; + return "FILE"; +} + export const HighFidelityReport: React.FC = ({ useStep1Chrome = false, headerTitle, + step3Mode = "full", threadIds, coordinatorName, consensusText, @@ -156,7 +175,9 @@ export const HighFidelityReport: React.FC = ({ return `圆桌总控结论-${short}`; }, [intentReady?.objective]); - // 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。 + const hasFullStep3Outputs = step3Mode === "full"; + // 第 3 步主页为总结报告。普通会商可继续打开最终方案、经典可视化和大屏多页; + // 8BF 明确停在报告工作台,不启动或露出这些后续交付。 const [summaryMode, setSummaryMode] = useState(true); const [drawMode, setDrawMode] = useState(false); // 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起 @@ -398,6 +419,9 @@ export const HighFidelityReport: React.FC = ({ const fileSandboxVirtualUrl = fileSandboxTarget ? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0` : ""; + const fileSandboxTypeLabel = fileSandboxTarget + ? artifactTypeLabel(fileSandboxTarget.file.name) + : "FILE"; const fileSandboxThreadStub = useMemo(() => { const messages = fileSandboxTarget && fileSandboxContent != null @@ -414,6 +438,14 @@ export const HighFidelityReport: React.FC = ({ }, ], }, + // 最终产物仅借用 write-file 虚拟 URL 来复用预览器;内容已经通过下载接口完整读回, + // 不是流式写入。因此要同时提供 tool result,避免 ArtifactFileDetail 误显示「正在生成」。 + { + id: "hfr-file-tool-result", + type: "tool", + content: "", + tool_call_id: "tc-hfr-0", + }, ] : []; return { @@ -434,7 +466,7 @@ export const HighFidelityReport: React.FC = ({ [], ); - if (summaryMode) { + if (summaryMode || !hasFullStep3Outputs) { return ( = ({ intentReady={intentReady} selectedModel={selectedModel} availableModels={availableModels} + modelsLoading={modelsLoading} setSelectedModel={setSelectedModel} coordinatorPrompt={coordinatorPrompt} savedSummary={savedSummary} summary={summaryFlow} + enableFlow={hasFullStep3Outputs} + onViewStatic={hasFullStep3Outputs ? () => setSummaryMode(false) : undefined} taskArtifactSubmission={taskArtifactSubmission} /> ); } - if (drawMode) { + if (drawMode && hasFullStep3Outputs) { return ( = ({ allowVirtualMarkdownEdit headerLeadingActionBare headerLeadingAction={( - +
+ + {fileSandboxTypeLabel} + + +
)} /> diff --git a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx index 36b271c9..12612f87 100644 --- a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx @@ -26,6 +26,8 @@ export interface Step3PanelProps { /** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */ useStep1Chrome?: boolean; headerTitle?: string; + /** 普通会商提供完整交付;8BF 仅保留 Markdown 总结报告。 */ + step3Mode?: "full" | "report-only"; threadIds: ThreadIdMap | null; coordinatorName: string | null; consensusText: string; @@ -65,6 +67,7 @@ export function Step3Panel(props: Step3PanelProps) { const { useStep1Chrome = false, headerTitle, + step3Mode = "full", threadIds, coordinatorName, consensusText, @@ -115,6 +118,7 @@ export function Step3Panel(props: Step3PanelProps) { ; + modelsLoading: boolean; setSelectedModel: (model: string) => void; /** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */ coordinatorPrompt?: string | null; @@ -81,6 +90,10 @@ interface Step3SummaryModeProps { savedSummary?: DraftStep3SummarySnapshot | null; /** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */ summary: UseStep3SummaryReturn; + /** 普通会商保留流程图交付;8BF 为 false。 */ + enableFlow?: boolean; + /** 普通会商进入完整的最终方案展示;8BF 不提供该入口。 */ + onViewStatic?: () => void; taskArtifactSubmission?: TaskArtifactSubmissionController; } @@ -98,10 +111,13 @@ export function Step3SummaryMode({ intentReady, selectedModel, availableModels, + modelsLoading, setSelectedModel, coordinatorPrompt, savedSummary, summary, + enableFlow = false, + onViewStatic, taskArtifactSubmission, }: Step3SummaryModeProps) { void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容 @@ -135,6 +151,36 @@ export function Step3SummaryMode({ const { open: artifactsOpen } = useArtifacts(); + // 普通会商的结构化流程图:优先使用后端抽取+校验的结果,兼容旧草稿里 flow-json。 + const flowGraph = useMemo(() => { + if (!enableFlow) return null; + if (summary.flowPhase === "done" && summary.flowGraph) { + return { graph: summary.flowGraph, streaming: false }; + } + if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") { + return null; + } + for (let index = dialogues.length - 1; index >= 0; index -= 1) { + const dialogue = dialogues[index]; + if (dialogue.avatarType === "human") continue; + const graph = extractFlowGraph(dialogue.content ?? ""); + if (graph && (dialogue.content ?? "").includes(FLOW_FENCE_OPEN)) { + return { graph, streaming: dialogue.streaming === true }; + } + } + return null; + }, [dialogues, enableFlow, summary.flowGraph, summary.flowPhase]); + + const [flowView, setFlowView] = useState(false); + const flowAutoOpenedRef = useRef(false); + useEffect(() => { + if (!enableFlow || !flowGraph || flowGraph.graph.nodes.length === 0) return; + if (flowAutoOpenedRef.current) return; + flowAutoOpenedRef.current = true; + // 自动生成的图优先打开;若用户已在报告沙箱内,保留当前阅读位置并露出切换入口。 + if (summary.autoFlow || !artifactsOpen) setFlowView(true); + }, [artifactsOpen, enableFlow, flowGraph, summary.autoFlow]); + // 沙箱里已写出的 Markdown 总结报告。 const mdArtifact = useMemo(() => { for (const [threadId, msgs] of Object.entries(seatStubMessages)) { @@ -156,16 +202,22 @@ export function Step3SummaryMode({ return null; }, [seatStubMessages]); - const handleAsk = (message: string, files?: File[]) => ask(message, files); + const handleAsk = (message: string, files?: File[]) => { + if (enableFlow && detectFlowIntent(message)) { + flowAutoOpenedRef.current = true; + setFlowView(true); + } + ask(message, files); + }; // 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染, // 保持与第二步一致的流式过程可见性。 const displayDialogues = dialogues; const [leftCollapsed, setLeftCollapsed] = useState(false); - // 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 + // 右侧沙箱或流程图露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 // 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true), // 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。 - const rightPanelVisible = artifactsOpen; + const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView); const prevRightPanelVisibleRef = useRef(false); useEffect(() => { if (rightPanelVisible !== prevRightPanelVisibleRef.current) { @@ -195,16 +247,23 @@ export function Step3SummaryMode({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const isFlowRunning = + enableFlow && + (summary.flowPhase === "extracting" || summary.flowPhase === "validating"); const isStreaming = phase === "creating" || phase === "streaming" || + isFlowRunning || dialogues.some((d) => d.streaming === true); const activeSeatMessages = activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined; - const statusLabel = - phase === "creating" + const statusLabel = isFlowRunning + ? summary.flowPhase === "extracting" + ? "结构化流程图抽取中…" + : "流程图校验 / 修复中…" + : phase === "creating" ? "正在初始化总结线程…" : phase === "streaming" ? "方案总结报告智能体撰写中…" @@ -225,7 +284,7 @@ export function Step3SummaryMode({

方案总结 · 报告

- {(phase === "creating" || phase === "streaming") && ( + {isStreaming && ( )} {phase === "paused" && } @@ -237,12 +296,22 @@ export function Step3SummaryMode({ + {onViewStatic && ( + + )}
)} @@ -347,6 +416,24 @@ export function Step3SummaryMode({ isStreaming={isStreaming} seatMessages={activeSeatMessages} taskArtifactSubmission={taskArtifactSubmission} + sidePanel={ + enableFlow && flowGraph && flowView ? ( + setFlowView(false)} + onSwitchToReport={ + mdArtifact + ? () => { + setFlowView(false); + openArtifactPreview(mdArtifact.threadId, mdArtifact.path); + } + : undefined + } + onChange={summary.setFlowGraph} + /> + ) : undefined + } > + {enableFlow && flowGraph && !flowView && ( + + )} +
@@ -481,6 +593,11 @@ function AgentProgressPanel({ error, busy, mdAvailable, + enableFlow, + flowPhase, + onRegenerateFlow, + pendingFlowClarification, + onResolveFlowClarification, onRetry, onAsk, onPause, @@ -488,6 +605,7 @@ function AgentProgressPanel({ onArtifactPreview, selectedModel, availableModels, + modelsLoading, setSelectedModel, taskArtifactSubmission, }: { @@ -499,6 +617,12 @@ function AgentProgressPanel({ busy: boolean; /** 沙箱里是否已有 Markdown 总结报告。 */ mdAvailable: boolean; + /** 普通会商才会生成、展示和编辑流程图。 */ + enableFlow: boolean; + flowPhase: UseStep3SummaryReturn["flowPhase"]; + onRegenerateFlow: () => void; + pendingFlowClarification: string | null; + onResolveFlowClarification: UseStep3SummaryReturn["resolveFlowClarification"]; onRetry: () => void; onAsk: (message: string, files?: File[]) => void; onPause: () => void; @@ -506,21 +630,46 @@ function AgentProgressPanel({ onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"]; selectedModel: string; availableModels: ReadonlyArray; + modelsLoading: boolean; setSelectedModel: (model: string) => void; taskArtifactSubmission?: TaskArtifactSubmissionController; }) { + // 进度面板独立根据 props 判断流程图阶段,不能引用外层 Step3SummaryMode 的局部变量。 + const isFlowRunning = + enableFlow && (flowPhase === "extracting" || flowPhase === "validating"); const scrollRef = useRef(null); - const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const contentRef = useRef(null); + const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll( + scrollRef, + [dialogues, flowPhase], + { contentRef }, + ); // 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底, // 让用户看到新阶段。仅在气泡**条数**增加时触发, // 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。 const prevDialogueCountRef = useRef(dialogues.length); useEffect(() => { if (dialogues.length > prevDialogueCountRef.current) { - scrollToBottom(); + forceScrollToBottom(); } prevDialogueCountRef.current = dialogues.length; - }, [dialogues.length, scrollToBottom]); + }, [dialogues.length, forceScrollToBottom]); + // 结构化抽取可能复用前一张「调度中」气泡,导致消息条数不变;只依赖上面的 + // dialogues.length 时就不会触发定位。阶段切换后等 DOM 提交一帧再强制下滑, + // 保证用户能立刻看到抽取/校验进度;后续增量仍由 useAutoScroll 按用户是否上翻决定。 + useEffect(() => { + if (!enableFlow || (flowPhase !== "extracting" && flowPhase !== "validating")) return; + let followUpFrameId: number | null = null; + const frameId = window.requestAnimationFrame(() => { + forceScrollToBottom(); + // 校验卡及其中的流式内容可能在第一帧后才完成布局,再确认一次最终高度。 + followUpFrameId = window.requestAnimationFrame(forceScrollToBottom); + }); + return () => { + window.cancelAnimationFrame(frameId); + if (followUpFrameId !== null) window.cancelAnimationFrame(followUpFrameId); + }; + }, [enableFlow, flowPhase, forceScrollToBottom]); // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 const [localSettings] = useLocalSettings(); const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; @@ -598,10 +747,12 @@ function AgentProgressPanel({ id="step3-summary-model-select" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} - disabled={busy || availableModels.length === 0} + disabled={busy || modelsLoading || availableModels.length === 0} title={ - availableModels.length === 0 - ? "未在 config.yaml 中配置任何模型" + modelsLoading + ? "正在加载模型列表…" + : availableModels.length === 0 + ? "未在 config.yaml 中配置任何模型" : busy ? "撰写中不可切换;完成后或重新生成时生效" : "切换撰写使用的推理模型(下次生成生效)" @@ -609,7 +760,7 @@ function AgentProgressPanel({ className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border" > {availableModels.length === 0 && ( - + )} {availableModels.map((m) => (