diff --git a/.gitignore b/.gitignore index 35e457d6..371449b7 100644 --- a/.gitignore +++ b/.gitignore @@ -87,39 +87,57 @@ 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). +# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). !offline-backend-20260512/backend/tests/test_position_artifact_cap.py -deploy +# Roundtable coordinator/seat capability isolation regressions. +!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.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 +!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py +# Writing-mode first-report pipeline enforcement regressions. +!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py +!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py +# LLM call timeout retry classification regression coverage. +!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py +# Writing-mode pipeline conversation-material regressions. +!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py +deploy .history # Cloned third-party repos (local only) diff --git a/AGENTS.md b/AGENTS.md index a4969d93..bdab561a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,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/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 5687a78f..19fad244 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -18,6 +18,8 @@ import { listThreadArtifacts, mergeArtifactPaths, } from "@/core/artifacts/listing"; +import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight"; +import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; @@ -104,6 +106,9 @@ const ChatBox: React.FC<{ const [pinnedReferenceId, setPinnedReferenceId] = useState(null); const [backendReferenceBatches, setBackendReferenceBatches] = useState([]); const [settings] = useLocalSettings(); + // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 + // 原有行为(新产物自动打开 + in-flight 预览不过滤)。 + const writingModeEnabled = !!settings.context.writing_mode; const artifactDirectoryModeEnabled = settings.display.artifactDirectoryModeEnabled ?? false; const { data: citationDisplay } = useCitationDisplaySettings(); @@ -184,6 +189,7 @@ const ChatBox: React.FC<{ deselect, selectedArtifact, selectedArtifactKey, + openStreamedArtifact, } = useArtifacts(); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); @@ -398,9 +404,12 @@ const ChatBox: React.FC<{ ((artifactDirectoryModeEnabled && threadChanged) || ((hasNewArtifact || !hadArtifacts) && !selectedArtifact)) ) { - selectArtifact(nextArtifacts[nextArtifacts.length - 1]!); - setArtifactsOpen(true); - setSideTab("document"); + const newest = nextArtifacts[nextArtifacts.length - 1]!; + if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) { + selectArtifact(newest); + setArtifactsOpen(true); + setSideTab("document"); + } } // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. @@ -432,8 +441,30 @@ const ChatBox: React.FC<{ setArtifacts, availableArtifacts, disableArtifacts, + thread.messages, + writingModeEnabled, ]); + // 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 ?? [], writingModeEnabled), + [disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled], + ); + 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/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() { 工具箱 - - - - - - - {/* 分享对话框 */} - { - 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/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts index 68686482..827504e6 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,246 @@ 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 skips intercepted custom outputs markdown in writing mode", () => { + const messages = [ + { + id: "m1", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md", + content: "# 草稿\n", + }, + }, + ], + }, + ]; + assert.equal(latestInFlightWriteFilePreview(messages, true), null); +}); + +test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md", + content: "# 草稿\n", + }, + }, + ], + }, + ]); + assert.ok(preview); + assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/); +}); + +test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => { + const preview = latestInFlightWriteFilePreview( + [ + { type: "human", content: "写一篇论文" }, + { + id: "m-pipe", + type: "ai", + tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }], + }, + { + id: "m-write", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# 我自己写\n", + }, + }, + ], + }, + ], + true, + ); + assert.equal(preview, null); +}); + +test("latestInFlightWriteFilePreview still opens the injected research stub", () => { + const preview = latestInFlightWriteFilePreview( + [ + { + id: "research-report-j1", + type: "ai", + tool_calls: [ + { + id: "research-report-j1-write", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# 报告\n", + }, + }, + ], + }, + ], + true, + ); + assert.ok(preview); + assert.match(preview?.artifactPath ?? "", /report\.md/); +}); + +test("writing mode still previews follow-up-turn writes after a later user message", () => { + // 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。 + const preview = latestInFlightWriteFilePreview( + [ + { type: "human", content: "写一篇论文" }, + { + id: "m-pipe", + type: "ai", + tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }], + }, + { + id: "m-result", + type: "tool", + tool_call_id: "p1", + name: "deep_research_report", + content: "研究报告已完成:已写入 report.md 并展示给用户。", + }, + { type: "human", content: "把第二章改写得更学术一些" }, + { + id: "m-edit", + type: "ai", + tool_calls: [ + { + id: "w2", + name: "write_file", + args: { + path: "/mnt/user-data/outputs/report.md", + content: "# 修订后的报告\n", + }, + }, + ], + }, + ], + true, + ); + assert.ok(preview); + assert.equal(preview?.previewKey, "m-edit:w2"); +}); + +test("normal mode keeps previewing pathless streaming drafts", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } }, + ], + }, + ]); + assert.ok(preview); + assert.match(preview?.artifactPath ?? "", /\.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..477af28e 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,14 @@ +import { isInterceptedWritingReportWrite } from "../threads/writing-mode"; 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,8 +36,12 @@ export function resolveWriteFilePath(filepath: string): string { type WriteFileThreadLike = { messages: Array<{ + id?: string; type?: string; + name?: string; + content?: unknown; tool_call_id?: string; + additional_kwargs?: Record; tool_calls?: Array<{ id?: string; name?: string; @@ -75,3 +88,67 @@ 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"], + writingMode = false, +): { 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; + } + // Writing mode rewrites custom outputs/*.md to deep_research_report. + // Opening that filename would 404-poll until report.md lands. Normal + // mode never skips: the write is a real deliverable, not a rewrite. + if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) { + 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/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/messages/utils-group.test.ts b/frontend-web/src/core/messages/utils-group.test.ts index e984a338..8d2e212d 100644 --- a/frontend-web/src/core/messages/utils-group.test.ts +++ b/frontend-web/src/core/messages/utils-group.test.ts @@ -56,6 +56,61 @@ test("write_file tool after 用户协助 opens a processing group instead of err ]); }); +test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => { + const types = groupTypes([ + { type: "human", id: "h1", content: "写一篇论文" } as Message, + { + type: "ai", + id: "a1", + content: "", + tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }], + } as Message, + { + type: "tool", + id: "t1", + name: "deep_research_report", + content: "ok", + tool_call_id: "c1", + } as Message, + { + type: "ai", + id: "progress", + content: "", + tool_calls: [ + { id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } }, + ], + } as Message, + { + type: "ai", + id: "write", + content: "", + tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }], + } as Message, + { + type: "ai", + id: "summary", + content: "已生成多智能体论文,详见 report.md。", + } as Message, + ]); + + assert.deepEqual(types, ["human", "assistant:processing", "assistant"]); +}); + +test("a lone deep-research progress bar still opens its own group", () => { + const types = groupTypes([ + { + type: "ai", + id: "progress", + content: "", + tool_calls: [ + { id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } }, + ], + } as Message, + ]); + + assert.deepEqual(types, ["assistant:processing"]); +}); + test("tool messages still attach to an open processing group", () => { const types = groupTypes([ { diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 04970806..5e3369b8 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -166,14 +166,19 @@ export function groupMessages( messages: [message], }); } else if (hasOnlyDeepResearchProgress(message)) { - // Each deep-research writing bar is its own group. Merging consecutive - // processing messages would pin a new report's in-progress steps onto - // the parent bar, which sits above the original file card. - groups.push({ - id: message.id, - type: "assistant:processing", - messages: [message], - }); + // 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进 + // 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面 + // 会有 human/assistant 隔开,不会误并到上一轮的条上。 + const lastGroup = groups[groups.length - 1]; + if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) { + lastGroup.messages.push(message); + } else { + groups.push({ + id: message.id, + type: "assistant:processing", + messages: [message], + }); + } } else if (hasSubagent(message)) { groups.push({ id: message.id, @@ -426,6 +431,14 @@ export function hasOnlyDeepResearchProgress(message: Message) { ); } +function groupHasDeepResearchReport(group: MessageGroup): boolean { + return group.messages.some( + (message) => + message.type === "ai" && + Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")), + ); +} + export function isClarificationToolMessage(message: Message) { return message.type === "tool" && message.name === "ask_clarification"; } 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/core/threads/use-research-report-stream.test.ts b/frontend-web/src/core/threads/use-research-report-stream.test.ts index 197cee59..b482f76d 100644 --- a/frontend-web/src/core/threads/use-research-report-stream.test.ts +++ b/frontend-web/src/core/threads/use-research-report-stream.test.ts @@ -6,6 +6,7 @@ import { buildResearchFeedMessages, buildResearchProgressToolCalls, buildResearchReportVirtualUrl, + composeResearchDisplayMessages, clearResearchReportStream, finalizeResearchReportStream, ingestResearchReportEvent, @@ -139,15 +140,33 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => { sourceCount: 3, }); const calls = buildResearchProgressToolCalls(active); - assert.equal(calls.length, 2); // 拆解已完成 + 检索进行中 + assert.equal(calls.length, 2); // 梳理思路已完成 + 整理资料进行中 assert.equal(calls[0].args.status, "complete"); + assert.equal(calls[0].args.label, "梳理写作思路"); assert.equal(calls[1].args.status, "active"); + assert.equal(calls[1].args.label, "整理对话资料"); assert.match(calls[1].args.detail, /1\/2/); const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" }); assert.equal(buildResearchProgressToolCalls(writing).length, 3); assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active"); + const completedByPhase = streamOf({ jobId: "j7b", phase: "done", reportText: "# x" }); + const completedCalls = buildResearchProgressToolCalls(completedByPhase); + assert.equal(completedCalls.length, 3); + assert.ok(completedCalls.every((call) => call.args.status === "complete")); + assert.match(completedCalls[2].args.detail, /report\.md/); + + const completedByStatus = streamOf({ + jobId: "j7c", + phase: "writing", + status: "success", + reportText: "# x", + }); + assert.ok( + buildResearchProgressToolCalls(completedByStatus).every((call) => call.args.status === "complete"), + ); + const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" }); const failedCalls = buildResearchProgressToolCalls(failed); assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/); @@ -157,15 +176,73 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => { test("feed messages attach the write_file stub only once report text exists", () => { const noText = streamOf({ jobId: "j9", phase: "collecting" }); assert.equal(buildResearchFeedMessages(noText).length, 1); - assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress"); + assert.equal(buildResearchFeedMessages(noText)[0].tool_calls?.[0]?.name, "deep_research_progress"); + + const writingEmpty = streamOf({ jobId: "j9b", phase: "writing" }); + assert.equal(buildResearchFeedMessages(writingEmpty).length, 1); const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" }); const feed = buildResearchFeedMessages(withText); assert.equal(feed.length, 2); - const stub = feed[1].tool_calls[0]; - assert.equal(stub.name, "write_file"); - assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md"); - assert.equal(stub.args.content, "# 报告正文"); + const stub = feed[1].tool_calls?.[0]; + assert.equal(stub?.name, "write_file"); + assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md"); + assert.equal(stub?.args.content, "# 报告正文"); + + const done = streamOf({ jobId: "j10b", phase: "done", reportText: "# 报告正文" }); + const doneFeed = buildResearchFeedMessages(done); + assert.equal(doneFeed.length, 3); + assert.equal(doneFeed[2].type, "tool"); + assert.equal(doneFeed[2].tool_call_id, "research-report-j10b-write"); +}); + +test("report_completed flips phase to done and write step completes before tool_finished", () => { + const tid = nextThread(); + ingestAll(tid, [ + { type: "report_delta", jobId: "jdone", payload: { delta: "正文", offset: 0 } }, + { type: "report_completed", jobId: "jdone", payload: { sourceCount: 2 } }, + ]); + const state = peekResearchReportStream(tid); + assert.equal(state?.phase, "done"); + assert.equal(state?.status, "active"); + const calls = buildResearchProgressToolCalls(state!); + assert.ok(calls.every((call) => call.args.status === "complete")); +}); + +test("composeResearchDisplayMessages inserts the bar before the summary and drops leaked prose", () => { + const feed = buildResearchFeedMessages(streamOf({ + jobId: "jsplice", + phase: "done", + reportText: "# 报告", + })); + const composed = composeResearchDisplayMessages( + [ + { id: "h", type: "human", content: "写论文" }, + { + id: "ai-tool", + type: "ai", + content: "", + tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }], + }, + { id: "leak", type: "ai", content: "# 报告\n\n泄漏到消息列表的正文" }, + { id: "tool", type: "tool", tool_call_id: "c1", content: "ok" }, + { id: "summary", type: "ai", content: "已生成论文,详见 report.md。" }, + ], + feed, + ); + + assert.deepEqual( + composed.map((message) => message.id), + [ + "h", + "ai-tool", + "tool", + "research-progress-jsplice", + "research-report-jsplice", + "research-report-jsplice-write-result", + "summary", + ], + ); }); test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => { diff --git a/frontend-web/src/core/threads/use-research-report-stream.ts b/frontend-web/src/core/threads/use-research-report-stream.ts index 487ffff5..ea965bc2 100644 --- a/frontend-web/src/core/threads/use-research-report-stream.ts +++ b/frontend-web/src/core/threads/use-research-report-stream.ts @@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry { * 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按 * token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理 * (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多 - * 10 次通知,流式观感不受影响。终态帧(tool_finished)与 - * finalize/clear 立即 flush,结束态不滞后。 + * 10 次通知,流式观感不受影响。终态帧(tool_finished / + * report_completed)与 finalize/clear 立即 flush,结束态不滞后。 */ const EMIT_COALESCE_MS = 100; @@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor case "report_completed": next = { ...base, - phase: "writing", + // 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。 + phase: "done", sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0 ? payload.sourceCount : base.sourceCount, @@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor if (next !== previous) { store.state = next; // 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。 - if (frame.type === "tool_finished") { + if (frame.type === "tool_finished" || frame.type === "report_completed") { emitNow(store); } else { emitCoalesced(store); @@ -280,33 +281,41 @@ export type ResearchProgressToolCall = { export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] { const rank = phaseRank(state.phase); const finished = state.status !== "active"; - const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank; + // success 时必须用 length(越过 write 的 boundary),length-1 永远完不成最后一步。 + // report_completed 会先把 phase 置 done,此时工具可能还没返回,也视为写完。 + const succeeded = + state.status === "success" || (state.status === "active" && state.phase === "done"); + const effectiveRank = succeeded ? PHASE_ORDER.length : rank; const stages = [ { id: "plan", - label: "拆解检索问题", + // 写作管线不检索:只按课题拆论述角度、汇总对话已收集的资料再成稿。 + // 措辞避免「拆解检索问题/检索参考资料」,用户会误会管线在重新检索。 + label: "梳理写作思路", boundary: phaseRank("collecting"), detail: state.queries.length > 0 - ? `已拆出 ${state.queries.length} 个检索子问题` - : "正在围绕课题拆解检索子问题", + ? `已梳理出 ${state.queries.length} 个论述角度` + : "正在围绕课题梳理写作思路", }, { id: "collect", - label: "检索参考资料", + label: "整理对话资料", boundary: phaseRank("compressing"), detail: state.queries.length > 0 - ? `已检索 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个子问题 · 累计 ${state.sourceCount} 条资料` + ? `已整理 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个论述角度的资料 · 累计 ${state.sourceCount} 条` : state.sourceCount > 0 - ? `已检索到 ${state.sourceCount} 条资料` - : "正在逐个子问题检索资料", + ? `已汇总 ${state.sourceCount} 条对话资料` + : "正在汇总对话中已收集的资料", }, { id: "write", label: "撰写研究报告", boundary: PHASE_ORDER.length, - detail: state.reportText - ? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览" - : "正在整理资料并开始撰写报告", + detail: succeeded + ? "已按「通用分析结构」写完并保存为 report.md" + : state.reportText + ? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览" + : "正在整理资料并开始撰写报告", }, ]; const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary); @@ -330,19 +339,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState) export type ResearchFeedMessage = { id: string; - type: "ai"; + type: "ai" | "tool"; content: string; - tool_calls: { + name?: string; + tool_call_id?: string; + tool_calls?: { id: string; name: string; args: Record; }[]; }; +function isResearchWriteFinished(state: ResearchReportStreamState): boolean { + return state.status === "success" || state.phase === "done"; +} + /** - * 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file - * stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在 - * 流式时输出 stub —— 完成后由真实 artifacts 接管。 + * 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub + * (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result, + * 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。 */ export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] { const messages: ResearchFeedMessage[] = [ @@ -353,23 +368,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res tool_calls: buildResearchProgressToolCalls(state), }, ]; - if (state.reportText.trim()) { + // 必须等正文首 token:思考阶段挂 stub 会提前显示「写入文件 report.md」。 + if (!state.reportText.trim()) { + return messages; + } + const writeCallId = `research-report-${state.jobId}-write`; + messages.push({ + id: `research-report-${state.jobId}`, + type: "ai", + content: "", + tool_calls: [ + { + id: writeCallId, + name: "write_file", + args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText }, + }, + ], + }); + if (isResearchWriteFinished(state)) { messages.push({ - id: `research-report-${state.jobId}`, - type: "ai", - content: "", - tool_calls: [ - { - id: `research-report-${state.jobId}-write`, - name: "write_file", - args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText }, - }, - ], + id: `research-report-${state.jobId}-write-result`, + type: "tool", + name: "write_file", + tool_call_id: writeCallId, + content: "ok", }); } return messages; } +function isDeepResearchReportAi(message: { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }): boolean { + return ( + message.type === "ai" && + Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")) + ); +} + +function deepResearchReportCallIds(message: { tool_calls?: Array<{ name?: string; id?: string }> }): Set { + return new Set( + (message.tool_calls ?? []) + .filter((toolCall) => toolCall.name === "deep_research_report" && toolCall.id) + .map((toolCall) => toolCall.id as string), + ); +} + +function messageHasVisibleText(message: { type?: string; content?: unknown; tool_calls?: unknown[] }): boolean { + if (message.type !== "ai") return false; + if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false; + return typeof message.content === "string" && message.content.trim().length > 0; +} + +/** + * 丢掉管线内部 LLM 泄漏到父图 messages 流的正文:它们夹在 + * `deep_research_report` 调用与其 tool result 之间,会让左侧气泡和沙箱一起写。 + */ +export function omitLeakedReportTokens; tool_call_id?: string; content?: unknown }>( + messages: T[], +): T[] { + const hidden = new Set(); + let pendingIds: Set | null = null; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (isDeepResearchReportAi(message)) { + pendingIds = deepResearchReportCallIds(message); + continue; + } + if (!pendingIds) continue; + if ( + message.type === "tool" && + typeof message.tool_call_id === "string" && + pendingIds.has(message.tool_call_id) + ) { + pendingIds = null; + continue; + } + if (messageHasVisibleText(message)) { + hidden.add(i); + } + } + return hidden.size === 0 ? messages : messages.filter((_, index) => !hidden.has(index)); +} + +/** + * 把进度条 / write_file stub 插到 `deep_research_report` 及其结果之后、 + * 模型总结之前,这样总结气泡永远在步骤条下面。 + */ +export function insertResearchFeedMessages(messages: T[], feed: T[]): T[] { + if (feed.length === 0) return messages; + let insertAt = -1; + let callIds = new Set(); + for (let i = 0; i < messages.length; i++) { + const message = messages[i] as { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }; + if (isDeepResearchReportAi(message)) { + insertAt = i + 1; + callIds = deepResearchReportCallIds(message); + } + } + if (insertAt === -1) { + return [...messages, ...feed]; + } + while (insertAt < messages.length) { + const message = messages[insertAt] as { type?: string; tool_call_id?: string }; + if ( + message.type === "tool" && + typeof message.tool_call_id === "string" && + callIds.has(message.tool_call_id) + ) { + insertAt += 1; + continue; + } + break; + } + return [...messages.slice(0, insertAt), ...feed, ...messages.slice(insertAt)]; +} + +export function composeResearchDisplayMessages; tool_call_id?: string; content?: unknown }>( + messages: T[], + feed: T[], +): T[] { + return insertResearchFeedMessages(omitLeakedReportTokens(messages), feed); +} + /** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */ export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string { return new URL( diff --git a/frontend-web/src/core/threads/use-research-writing-feed.ts b/frontend-web/src/core/threads/use-research-writing-feed.ts index 4c50bbf4..fe7c5366 100644 --- a/frontend-web/src/core/threads/use-research-writing-feed.ts +++ b/frontend-web/src/core/threads/use-research-writing-feed.ts @@ -3,8 +3,8 @@ /** * 聊天页「写作模式」深度研究进度注入。 * - * 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的 - * 虚拟消息(进度步骤条 + report.md 的 write_file stub): + * 把 `useResearchReportStream` 的流式状态插到 `deep_research_report` + * 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub): * * - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰); * - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出), @@ -26,13 +26,16 @@ import { artifactPreviewKey, useArtifactsOptional, } from "@/components/workspace/artifacts/context"; +import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight"; import { buildResearchFeedMessages, buildResearchReportVirtualUrl, + composeResearchDisplayMessages, RESEARCH_REPORT_OUTPUT_PATH, useResearchReportStream, } from "./use-research-report-stream"; +import { isInterceptedWritingReportWrite } from "./writing-mode"; type FeedThreadLike = { messages: Message[]; @@ -55,10 +58,15 @@ export function useResearchWritingFeed( [thread.values?.artifacts], ); - // 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟 - // 预览,报告在沙箱里边写边流式渲染。 + // 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开, + // 避免「写入文件 report.md」出现在模型还在想的时候。 useEffect(() => { - if (!research || research.status !== "active" || !research.reportText.trim()) { + if ( + !research || + research.status !== "active" || + research.phase !== "writing" || + !research.reportText.trim() + ) { return; } if (autoOpenedJobRef.current === research.jobId) return; @@ -77,7 +85,15 @@ export function useResearchWritingFeed( if (!artifacts) return; handedOverJobRef.current = research.jobId; const current = artifacts.selectedArtifact; - if (current !== null && current !== buildResearchReportVirtualUrl(research)) { + const virtualUrl = buildResearchReportVirtualUrl(research); + const currentPath = current ? resolveWriteFilePath(current) : ""; + const keepUserSelection = + current !== null && + current !== virtualUrl && + current !== RESEARCH_REPORT_OUTPUT_PATH && + // research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。 + !isInterceptedWritingReportWrite(currentPath, undefined, undefined, true); + if (keepUserSelection) { return; } artifacts.select(RESEARCH_REPORT_OUTPUT_PATH); @@ -91,11 +107,11 @@ export function useResearchWritingFeed( }, [hasRealArtifact, research]); const displayThread = useMemo(() => { - if (feedMessages.length === 0) return thread; - const messages = [ - ...thread.messages, - ...(feedMessages as unknown as Message[]), - ]; + const messages = composeResearchDisplayMessages( + thread.messages, + feedMessages as unknown as Message[], + ); + if (messages === thread.messages) return thread; return { ...thread, values: { ...thread.values, messages }, diff --git a/frontend-web/src/core/threads/writing-mode.test.ts b/frontend-web/src/core/threads/writing-mode.test.ts new file mode 100644 index 00000000..5be6cae4 --- /dev/null +++ b/frontend-web/src/core/threads/writing-mode.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isInterceptedWritingReportWrite, + isWritingFirstReportTurn, + shouldAutoOpenWritingArtifact, +} from "./writing-mode"; + +test("custom outputs markdown is always intercepted except the research stub", () => { + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true), + true, + ); + assert.equal( + isInterceptedWritingReportWrite( + "/mnt/user-data/outputs/report.md", + "research-report-j1", + undefined, + true, + ), + false, + ); +}); + +test("normal mode never intercepts outputs markdown writes", () => { + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"), + false, + ); + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m0", [ + { type: "human", content: "写一篇论文" }, + ]), + false, + ); +}); + +test("report.md is intercepted for the whole first-report turn", () => { + const beforePipeline = [{ type: "human", content: "写一篇论文" }]; + assert.equal(isWritingFirstReportTurn(beforePipeline), true); + assert.equal( + isInterceptedWritingReportWrite( + "/mnt/user-data/outputs/report.md", + "m1", + beforePipeline, + true, + ), + true, + ); + + const inFlight = [ + { type: "human", content: "写一篇论文" }, + { + type: "ai", + tool_calls: [{ name: "deep_research_report" }], + }, + ]; + assert.equal(isWritingFirstReportTurn(inFlight), true); + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true), + true, + ); + + const succeeded = [ + ...inFlight, + { + type: "tool", + name: "deep_research_report", + content: "研究报告已完成:已写入 report.md。", + }, + ]; + assert.equal(isWritingFirstReportTurn(succeeded), true); + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true), + true, + ); +}); + +test("sandbox auto-open waits for report.md on the first-report turn", () => { + const firstTurn = [ + { type: "human", content: "写一篇论文" }, + { + type: "ai", + tool_calls: [{ name: "deep_research_report" }], + }, + ]; + assert.equal( + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true), + false, + ); + assert.equal( + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn, true), + true, + ); +}); + +test("normal mode auto-opens every new artifact like before writing mode existed", () => { + const firstTurn = [ + { type: "human", content: "写一篇论文" }, + { + type: "ai", + tool_calls: [{ name: "deep_research_report" }], + }, + ]; + assert.equal( + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn), + true, + ); + assert.equal( + shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn), + true, + ); +}); + +test("a later user turn releases report.md live edits", () => { + const followUp = [ + { type: "human", content: "写一篇论文" }, + { + type: "ai", + tool_calls: [{ name: "deep_research_report" }], + }, + { + type: "tool", + name: "deep_research_report", + content: "研究报告已完成:已写入 report.md。", + }, + { type: "human", content: "把第二节改短一点" }, + ]; + assert.equal(isWritingFirstReportTurn(followUp), false); + assert.equal( + isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true), + false, + ); +}); diff --git a/frontend-web/src/core/threads/writing-mode.ts b/frontend-web/src/core/threads/writing-mode.ts new file mode 100644 index 00000000..29888f61 --- /dev/null +++ b/frontend-web/src/core/threads/writing-mode.ts @@ -0,0 +1,127 @@ +/** Only the writing-mode deliverable counts as a follow-up edit target. */ + +const OUTPUTS_PREFIX = "/mnt/user-data/outputs"; + +export function normalizeWritingPath(path: string | null | undefined): string { + if (!path) return ""; + return path.replace(/\\/g, "/").split("?")[0]?.split("#")[0]?.replace(/\/+$/, "") ?? ""; +} + +export function isWritingModeReportPath( + path: string | null | undefined, +): boolean { + const normalized = normalizeWritingPath(path); + return /(?:^|\/)report\.md$/i.test(normalized); +} + +/** Markdown under the sandbox outputs dir — writing mode intercepts these as the report. */ +export function isOutputsMarkdownPath(path: string | null | undefined): boolean { + const normalized = normalizeWritingPath(path); + if (!normalized || !/\.(?:md|markdown)$/i.test(normalized)) return false; + return ( + normalized === OUTPUTS_PREFIX || + normalized.startsWith(`${OUTPUTS_PREFIX}/`) || + normalized === "outputs" || + normalized.startsWith("outputs/") + ); +} + +type WritingPipelineMessage = { + type?: string; + name?: string; + content?: unknown; + additional_kwargs?: Record; + tool_calls?: Array<{ name?: string }>; +}; + +/** + * First-report turn: no successful pipeline yet, or the latest visible user + * message is still the one that triggered that pipeline. Follow-up edits + * (a later visible human after success) keep live `report.md` writes visible. + */ +export function isWritingFirstReportTurn( + messages: readonly WritingPipelineMessage[] | null | undefined, +): boolean { + if (!messages?.length) return false; + let lastVisibleHuman = -1; + let lastSuccess = -1; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if ( + message?.type === "human" && + message.additional_kwargs?.hide_from_ui !== true + ) { + lastVisibleHuman = index; + } + if (message?.type === "tool" && message.name === "deep_research_report") { + const content = typeof message.content === "string" ? message.content : ""; + if (content && !content.toLowerCase().startsWith("error")) { + lastSuccess = index; + } + } + } + return lastVisibleHuman >= 0 && (lastSuccess < 0 || lastVisibleHuman <= lastSuccess); +} + +/** + * Hand-written outputs/*.md writes the backend rewrites or drops: + * custom filenames always, and `report.md` while the first-turn pipeline is + * in-flight or just succeeded. The injected research stub stays visible. + * + * Everything above only exists in writing mode — normal mode writes md files + * by hand as always, so `writingMode=false` (the default) never intercepts. + */ +export function isInterceptedWritingReportWrite( + path: string | null | undefined, + messageId?: string | null, + messages?: readonly WritingPipelineMessage[] | null, + writingMode = false, +): boolean { + if (!writingMode) { + return false; + } + if (typeof messageId === "string" && messageId.startsWith("research-report-")) { + return false; + } + if (!isOutputsMarkdownPath(path)) { + return false; + } + if (!isWritingModeReportPath(path)) { + return true; + } + return isWritingFirstReportTurn(messages); +} + +/** + * Auto-open the sandbox only for the real report during the first-report + * turn. Normal mode keeps the pre-writing-mode behavior: always auto-open + * the newest artifact. + */ +export function shouldAutoOpenWritingArtifact( + path: string | null | undefined, + messages?: readonly WritingPipelineMessage[] | null, + writingMode = false, +): boolean { + if (!path) { + return false; + } + if (!writingMode) { + return true; + } + if (isWritingFirstReportTurn(messages)) { + return isWritingModeReportPath(path); + } + return !isInterceptedWritingReportWrite(path, undefined, messages, true); +} + +export function resolveWritingModeReportPath( + artifacts: readonly string[] | null | undefined, + selectedArtifact?: string | null, +): string | undefined { + if (isWritingModeReportPath(selectedArtifact)) { + return selectedArtifact ?? undefined; + } + return [...(artifacts ?? [])] + .reverse() + .find((path) => isWritingModeReportPath(path)); +} 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/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 80f9595b..54f67752 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -45,6 +45,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { textOfMessage } from "@/core/threads/utils"; +import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { OptType, writeYLog } from "@/core/log/y-log"; import { Select, @@ -612,16 +613,13 @@ export default function AgentChatPage({ } as typeof displayThread; }, [displayThread, showReportImportCard, taskId, threadId]); - // 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path, - // 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与 - // ChatPage 的回传逻辑对齐)。 - const writingArtifactPath = useMemo(() => { - const isMarkdown = (path: string | null | undefined) => - !!path && /\.(md|markdown)(?:$|[?#])/i.test(path); - return [...(thread.values.artifacts ?? [])] - .reverse() - .find((path) => isMarkdown(path)); - }, [thread.values.artifacts]); + // 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path, + // 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover + // .md 不得跳过管线(与 ChatPage 对齐)。 + const writingArtifactPath = useMemo( + () => resolveWritingModeReportPath(thread.values.artifacts), + [thread.values.artifacts], + ); // ── 「模板数据转换器」对话页专属调试入口(仅此 agent)────────────────────────── // 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板, diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 2a7a93a7..33819cf9 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -63,6 +63,7 @@ import { import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; +import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag"; @@ -354,16 +355,11 @@ export default function ChatPage() { researchDisplayThread, ]); - const writingArtifactPath = useMemo(() => { - const isMarkdown = (path: string | null | undefined) => - !!path && /\.(md|markdown)(?:$|[?#])/i.test(path); - if (isMarkdown(selectedArtifact)) { - return selectedArtifact ?? undefined; - } - return [...(thread.values.artifacts ?? [])] - .reverse() - .find((path) => isMarkdown(path)); - }, [selectedArtifact, thread.values.artifacts]); + const writingArtifactPath = useMemo( + () => + resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact), + [selectedArtifact, thread.values.artifacts], + ); const handleSubmit = useCallback( (message: PromptInputMessage) => { diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index b8ac2d0e..36e92345 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -3,16 +3,24 @@ import { ChevronLeft, ChevronRight, + Compass, + Clock, DownloadIcon, + MessageSquare, + PenTool, Pin, PinOff, + Search, Star, + Users2, + type LucideIcon, + X, } from "lucide-react"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { motion } from "motion/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 { @@ -42,9 +50,70 @@ 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"; +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: Users2, + }, + { + id: "writing", + label: "AI写作", + description: "写作任务与草稿", + icon: PenTool, + }, + { + id: "research", + label: "深度研究", + description: "研究任务与报告", + icon: Compass, + }, +]; + +/** 非对话 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,16 +123,28 @@ 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); + // 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。 + 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); @@ -73,26 +154,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 +198,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 +234,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)} +
+ )} +
- - + {HISTORY_TABS.map((tab) => { + const isActive = activeTab === tab.id; + const Icon = tab.icon; + const count = historyTabCounts[tab.id]; + + return ( + + ); + })} +
- + {activeTab === "chats" && ( + + )}
- setSearch(e.target.value)} - /> - {totalCount != null && ( -
- 共 {totalCount} 条对话 -
- )}
- -
- {currentPage === 1 && pinnedThreads.length > 0 && ( - <> -
- {t.chats.pinnedSection} + +
+
+ {activeTab !== "roundtable" &&
+
+ {activeTab === "chats" ? ( +
+ + + {pinnedThreadsCount != null && pinnedThreadsCount > 0 && ( + + )} +
+ ) : ( +
+ {activeTabConfig.description} +
+ )} +
- {pinnedThreads.map(renderThread)} - - )} - {pagedThreads.map(renderThread)} - {isEmpty && ( -
- {onlyFavorites ? t.chats.noFavorites : t.chats.noChats} -
- )} - {(currentPage > 1 || hasMore) && ( -
- - - {totalPages != null - ? `${currentPage} / ${totalPages}` - : currentPage} - - -
- )} +
+ {displayTotal != null && ( + + 共 {displayTotal}{" "} + {TAB_TOTAL_UNIT[activeTab]} + + )} +
+
} + {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/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index 11851cfd..fbf72c3e 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -48,6 +48,7 @@ import { useThreadStream, } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; +import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { env } from "@/env"; import { cn } from "@/lib/utils"; import { @@ -291,16 +292,11 @@ export default function IframeChatPage() { threadMetadataAgentId, ]); - const writingArtifactPath = useMemo(() => { - const isMarkdown = (path: string | null | undefined) => - !!path && /\.(md|markdown)(?:$|[?#])/i.test(path); - if (isMarkdown(selectedArtifact)) { - return selectedArtifact ?? undefined; - } - return [...(thread.values.artifacts ?? [])] - .reverse() - .find((path) => isMarkdown(path)); - }, [selectedArtifact, thread.values.artifacts]); + const writingArtifactPath = useMemo( + () => + resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact), + [selectedArtifact, thread.values.artifacts], + ); const handleSubmit = useCallback( ( 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/ResearchHistoryTab.tsx b/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx new file mode 100644 index 00000000..f6de44b6 --- /dev/null +++ b/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx @@ -0,0 +1,209 @@ +"use client"; + +/** + * 「对话历史」页 —— 深度研究 Tab。 + * + * 数据来自 deep-research 会话列表(useResearchSessions)。点击跳转深度研究页并 + * 带 ``?session=``,由 DeepResearchWorkbench 挂载时读取该参数直接选中并 + * 打开这条研究记录。 + */ + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Search } from "lucide-react"; +import { toast } from "sonner"; + +import { + useResearchActions, + useResearchSessions, + type DeepResearchSession, +} from "@/strategy-components/api/deep-research"; +import { useReportStructures } from "@/strategy-components/api/report-structures"; +import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; +import { desensitize } from "@/lib/desensitize"; + +import { + DeleteIcon, + HistoryRenameDialog, + HistoryRow, + HistoryRowIconButton, + HistoryStatusPill, + HistoryTypeBadge, + 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" }, +}; + +/** + * 深度研究会话把选中的报告模板快照为 custom_outline;用结构库里的内容反查标题。 + * 内容不再存在或用户输入了自定义大纲时,明确展示为「自定义报告结构」,避免冒充模板名。 + */ +function reportTemplateLabel( + session: DeepResearchSession, + structures: { title: string; content: string }[], +): string { + const config = session.config ?? {}; + const outline = typeof config.custom_outline === "string" + ? config.custom_outline.trim() + : typeof config.customOutline === "string" + ? config.customOutline.trim() + : ""; + + if (!outline) return "默认报告结构"; + return structures.find((structure) => structure.content.trim() === outline)?.title ?? "自定义报告结构"; +} + +export function ResearchHistoryTab({ + query, + onTotalChange, +}: { + query: string; + onTotalChange?: (total: number) => void; +}) { + const navigate = useNavigate(); + const { data, isLoading } = useResearchSessions(100); + const structuresQuery = useReportStructures(); + 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", + }; + const timestamp = session.updatedAt ?? session.createdAt ?? null; + const template = reportTemplateLabel(session, structuresQuery.data ?? []); + return ( + } + title={desensitize(session.title || session.query || "未命名研究")} + meta={ + <> + + {session.sourceCount > 0 && ( + <> + · + {session.sourceCount} 条来源 + + )} + · + + 模板:{template} + + {timestamp && ( + <> + · + {formatRelative(timestamp)} + + )} + + } + onOpen={() => openSession(session)} + > + { + setRenameId(session.id); + setRenameTitle(session.title || session.query || ""); + }} + > + + + void handleDelete(session)} + > + + + + ); + })} +
+ + setRenameId(null)} + onConfirm={(title) => renameId && void handleRename(renameId, title)} + /> + + ); +} 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..a88af774 --- /dev/null +++ b/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx @@ -0,0 +1,466 @@ +"use client"; + +/** + * 「对话历史」页 —— 多智能体会商 Tab。 + * + * 卡片与筛选区按最新「智能工作区管理 (1)」的 ConversationRow / + * FilterAndSearchBar 适配。会商步骤、状态、模式、互动数和模型都来自实际草稿快照。 + */ + +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowUpDown, + Clock, + Cpu, + ExternalLink, + Search, + Tag, + Trash2, + Users2, + X, +} from "lucide-react"; +import { toast } from "sonner"; + +import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; +import { + getDraft, + type DraftMeta, + type DraftRecord, +} from "@/roundtable-planning/api/drafts"; +import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts"; +import { desensitize } from "@/lib/desensitize"; + +const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning"; + +type SortType = "updated_desc" | "updated_asc" | "messages_desc"; + +interface RoundtableItem { + draft: DraftMeta; + record?: DraftRecord; + status: ReturnType; + businessChainName: string; + messageCount: number; + model?: string; + tags: string[]; +} + +function progressOf(draft: DraftMeta): { + label: string; + stepLabel: string; + tone: "completed" | "active" | "waiting" | "failed" | "cancelled"; +} { + const stepLabel = `第 ${draft.furthestStep} 步`; + + if (draft.status === "done" || (!draft.status && draft.furthestStep === 3)) { + return { label: "已完成", tone: "completed", stepLabel }; + } + if (draft.status === "error") { + return { label: "执行失败", tone: "failed", stepLabel: `停在${stepLabel}` }; + } + if (draft.status === "cancelled") { + return { label: "已取消", tone: "cancelled", stepLabel: `停在${stepLabel}` }; + } + if (draft.status === "awaiting_input") { + return { label: "等待确认", tone: "waiting", stepLabel: `当前${stepLabel}` }; + } + return { label: "进行中", tone: "active", stepLabel: `当前${stepLabel}` }; +} + +function modeOf(record?: DraftRecord): string { + switch (record?.step2?.orchestrationMode) { + case "chain": + return "业务链会商"; + case "dag": + return "DAG 编排"; + default: + return "自由模式"; + } +} + +function businessChainNameOf(draft: DraftMeta, record?: DraftRecord): string { + return record?.step2?.chain?.title ?? draft.chainTitle ?? modeOf(record); +} + +function interactionCount(record?: DraftRecord): number { + if (!record) return 0; + return ( + (record.step1?.interactiveMessages?.length ?? 0) + + (record.step2?.step2RoundtableDialogues?.length ?? 0) + + (record.step3?.summaryReport?.dialogues?.length ?? 0) + + (record.step3?.reportDialogues?.length ?? 0) + + (record.step3?.dashboardDialogues?.length ?? 0) + ); +} + +function modelOf(record?: DraftRecord): string | undefined { + return record?.step3?.summaryReport?.model ?? record?.step3?.model; +} + +function tagsOf(draft: DraftMeta, record?: DraftRecord): string[] { + const chain = record?.step2?.chain?.title ?? draft.chainTitle; + return chain ? [chain] : []; +} + +function timestamp(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function renderHighlightedTitle(text: string, query: string) { + if (!query.trim()) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escaped})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); +} + +function RoundtableHistoryRow({ + item, + searchQuery, + onDelete, + onOpen, +}: { + item: RoundtableItem; + searchQuery: string; + onDelete: () => void; + onOpen: () => void; +}) { + const { draft, status } = item; + const statusClass = { + completed: "border-emerald-200/80 bg-emerald-50 text-emerald-700", + active: "border-red-200/80 bg-red-50 text-red-600", + waiting: "border-amber-200/80 bg-amber-50 text-amber-700", + failed: "border-rose-200/80 bg-rose-50 text-rose-600", + cancelled: "border-neutral-200/80 bg-neutral-50 text-neutral-500", + }[status.tone]; + const statusDotClass = { + completed: "bg-emerald-500", + active: "bg-red-500", + waiting: "bg-amber-500", + failed: "bg-rose-500", + cancelled: "bg-neutral-400", + }[status.tone]; + + return ( +
{ + if ((event.target as HTMLElement).closest("button, input")) return; + onOpen(); + }} + className="group relative flex cursor-pointer items-start justify-between rounded-xl border border-neutral-200/80 bg-white p-4 transition-all duration-150 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs sm:items-center" + > +
+
+
+ + + {status.tone === "active" && ( + + )} + + + + 会商 + +

+ {renderHighlightedTitle(desensitize(draft.title || "未命名会商"), searchQuery)} +

+
+ +
+ + + {status.label} + + + {status.stepLabel} + + + + {item.businessChainName} + + + + {formatRelative(draft.updatedAt)} + +
+
+
+ +
+
+ + +
+
+
+ ); +} + +export function RoundtableHistoryTab({ + onTotalChange, +}: { + onTotalChange?: (total: number) => void; +}) { + const navigate = useNavigate(); + 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]); + + 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?.(filteredItems.length); + }, [filteredItems.length, onTotalChange]); + + const openDraft = (draft: DraftMeta) => { + sessionStorage.setItem("roundtable:pending-draft", draft.id); + navigate(ROUNDTABLE_PATH); + }; + + const deleteDraft = async (draft: DraftMeta) => { + if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return; + try { + await remove(draft.id); + toast.success("已删除会商记录"); + } catch { + toast.error("删除失败,请重试"); + } + }; + + return ( +
+
+
+
+ + {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) => ( + + ))} +
+ )} +
+ +
+
+ + +
+
+
+
+ + {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/WritingHistoryTab.tsx b/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx new file mode 100644 index 00000000..ba5cde2d --- /dev/null +++ b/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx @@ -0,0 +1,154 @@ +"use client"; + +/** + * 「对话历史」页 —— AI写作 Tab。 + * + * 数据与原侧栏「最近写作」列表同源(useAIWritingSessions);点击行为一致: + * 暂存 sessionStorage「ai-writing:pending-session」后跳转 AI 写作页,由 + * AIWritingPanel 挂载时消费并打开该会话。 + */ + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { FilePenLine } from "lucide-react"; +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, + HistoryTypeBadge, + 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 ( + } + title={desensitize(session.title)} + meta={ + <> + + · + {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..cce68560 --- /dev/null +++ b/frontend-web/src/pages/chats-history/history-tab-shared.tsx @@ -0,0 +1,181 @@ +"use client"; + +/** + * 「对话历史」页(/page/workspace/chats)业务历史 Tab(多智能体会商 / AI写作 / + * 深度研究)的共享小组件。行样式与「对话」Tab 保持一致:标题行 + 次行元信息 + + * 悬浮操作按钮,让四个 Tab 在同一页里观感统一。 + */ + +import { useEffect, useState, type ReactNode } from "react"; +import { Pencil, Trash2, type LucideIcon } 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} + ); +} + +/** 历史卡片左上角的类型标签,与对话卡片的「对话」标签保持同一视觉层级。 */ +export function HistoryTypeBadge({ + icon: Icon, + label, +}: { + icon: LucideIcon; + label: string; +}) { + return ( + + + {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} +
+
+ ); +} + +/** 重命名 / 删除图标操作按钮(与「对话」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()); + } + }} + /> +
+ + + + +
+
+ ); +} 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); diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 38074d8d..fe0945a5 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -28,8 +28,9 @@ import type { JobStatus } from "@/roundtable-planning/lib/job-types"; export interface DraftStep1Snapshot { intentThreadId: string | null; intentReady: IntentSummary | null; - interactiveMessages: unknown[]; - timelineEntries: unknown[]; + /** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底。 */ + interactiveMessages?: unknown[]; + timelineEntries?: unknown[]; } export interface DraftStep2Snapshot { diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 6eb5c0b4..2a574f35 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report"; +import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning"; export type ThreadIdMap = Record; @@ -254,8 +255,7 @@ export interface StreamMultiAgentRequest { */ synthesisMode?: boolean; /** - * 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage - * 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。 + * 仅为兼容旧协议保留。总控 prompt 属于总控私有上下文,前端恒传 true,后端也会无条件隔离。 */ suppressPreBroadcast?: boolean; /** @@ -291,12 +291,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; /** * 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个 @@ -468,7 +470,9 @@ function isFinalStatusFrame(value: unknown): value is { * structured content blocks. Returns null when the frame is not an AI text * chunk (e.g., tool calls, tool results, value snapshots). */ -function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { +function extractAiTextDelta( + parsed: unknown, +): { content: string; reasoning: string; id: string } | null { if (!Array.isArray(parsed) || parsed.length < 1) return null; const chunk = parsed[0]; if (!chunk || typeof chunk !== "object") return null; @@ -491,9 +495,18 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul } } } - if (!text) return null; + const additionalKwargs = obj.additional_kwargs; + const directReasoning = obj.reasoning_content ?? obj.reasoning; + const nestedReasoning = + additionalKwargs && typeof additionalKwargs === "object" + ? (additionalKwargs as Record).reasoning_content ?? + (additionalKwargs as Record).reasoning + : undefined; + const rawReasoning = nestedReasoning ?? directReasoning; + const reasoning = typeof rawReasoning === "string" ? rawReasoning : ""; + if (!text && !reasoning) return null; const id = typeof obj.id === "string" ? obj.id : ""; - return { text, id }; + return { content: text, reasoning, id }; } /** @@ -532,8 +545,8 @@ export async function streamMultiAgent( skill_stop_names: req.skillStopNames ?? [], // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 synthesis_mode: req.synthesisMode ?? false, - // 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。 - suppress_pre_broadcast: req.suppressPreBroadcast ?? false, + // 总控 prompt 永不进入席位 thread;后端同样强制执行,避免旧调用方破坏角色隔离。 + suppress_pre_broadcast: req.suppressPreBroadcast ?? true, // 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。 parallel_no_file: req.parallelNoFile ?? false, // 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。 @@ -620,27 +633,36 @@ export async function streamMultiAgent( let currentPhase: StreamPhase = "connecting"; let currentPhaseName = ""; let currentPhaseArgsSignature = ""; + let currentPhaseToolCallId = ""; req.onPhaseChange?.("connecting"); + // The normal chat renders provider reasoning from a distinct message field. + // Roundtable's bespoke SSE reader keeps that field inline so its existing + // `` renderer remains the single display path for Steps 2 and 3. + const inlineAiDelta = createInlineReasoningDeltaFormatter(); 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); }; /** @@ -782,6 +804,7 @@ export async function streamMultiAgent( const toolCalls = chunkObj.tool_calls; const toolCallChunks = chunkObj.tool_call_chunks; const content = chunkObj.content; + const streamDelta = extractAiTextDelta(parsed); // 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。 // 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。 @@ -812,27 +835,34 @@ 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)) + (Boolean(streamDelta) || + (typeof content === "string" && content.length > 0) || + (Array.isArray(content) && content.length > 0)) ) { setPhase("streaming"); } @@ -900,8 +930,9 @@ export async function streamMultiAgent( const delta = extractAiTextDelta(parsed); if (delta) { - if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`); - req.onTextDelta?.(delta.text, delta.id); + const text = inlineAiDelta(delta); + if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`); + if (text) req.onTextDelta?.(text, delta.id); } req.onUpstreamFrame?.(parsed); diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx index 6b104f53..7ee324a2 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -9,7 +9,7 @@ * 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致), * agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。 */ -import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowRightIcon, ClockIcon, @@ -593,8 +593,9 @@ function ChainSeatsFlow({ return (
+ {/* 同 DispatchChainModal:div.contents 代替 ,规避 dev tagger 注入 data-lov-id 的警告。 */} {stages.map((stageIds, stageIdx) => ( - +
{stageIdx > 0 && ( )} @@ -609,7 +610,7 @@ function ChainSeatsFlow({ ) : ( seatTag(stageIds[0]) )} - +
))}
); diff --git a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx index 72698d88..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 && } @@ -234,6 +248,16 @@ export function DashboardChatPanel({ } className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" /> + {busy && ( + + )} + {!hideFollowupActions && ( + + )} - + {!hideFollowupActions && ( + + )} {footerAction ?? (!hideEnterRoundtable && ( diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index eb9287ed..ee07a5e5 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -43,6 +43,7 @@ const SHOW_BACKGROUND_SUSPEND = true; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; import { useLocalSettings } from "@/core/settings"; +import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts"; import { cn } from "@/lib/utils"; import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; @@ -221,8 +222,42 @@ export function Step2Panel(props: Step2PanelProps) { } = props; const step2ScrollRef = useRef(null); - const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = - useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); + // contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在 + // 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。 + const step2ContentRef = useRef(null); + 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。 // 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。 @@ -264,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) => @@ -601,14 +636,15 @@ export function Step2Panel(props: Step2PanelProps) { // 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内 // 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。 className={cn( - "flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4", + "flex-1 min-h-0 overflow-y-auto overflow-x-hidden", useStep1InputAppearance ? "eightbf-roundtable-step2-log" : "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner", )} > +
{isInitializing && step2RoundtableDialogues.length === 0 && ( -
+
正在初始化圆桌...
正在为各席位建立独立线程,请稍候
@@ -704,6 +740,7 @@ export function Step2Panel(props: Step2PanelProps) { )} )} +
{!step2AtBottom && ( @@ -888,6 +927,8 @@ function Step2LegacyComposer({ onAddFiles, onRemoveFile, onSend, + busy, + onPause, hasConsensus, onProceed, }: { @@ -897,6 +938,9 @@ function Step2LegacyComposer({ onAddFiles: (files: File[]) => void; onRemoveFile: (index: number) => void; onSend: () => void; + /** 编排中或席位仍在流式输出 —— 此时显示输入框「暂停」键(与第一步输入框一致)。 */ + busy: boolean; + onPause: () => void; hasConsensus: boolean; onProceed: () => void; }) { @@ -958,14 +1002,19 @@ function Step2LegacyComposer({ > - + {busy && ( + + )}
)} +
{!atBottom && }
+ {busy && ( + + )} + {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) => (