This commit is contained in:
董宏凯 committed 2026-08-31 23:21:32 +08:00
commit 99f1ca6029
187 files changed
+24191 -1924

No files matched your search

+44 -26
View File
@@ -87,39 +87,57 @@ offline-backend-20260512/backend/skill_evolution.json
skills/ skills/
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned. # Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py !offline-backend-20260512/backend/tests/test_word_export.py
# Keep the model configuration regression coverage versioned even though # Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests. # this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. # Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py !offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned. # Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py !offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned. # Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py !offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
# Keep the local LLMWiki vector-index and external API regression coverage versioned. # 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_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.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_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.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_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py !offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage. # Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py !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_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.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_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py !offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py !offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions. # Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py !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 !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 .history
# Cloned third-party repos (local only) # Cloned third-party repos (local only)
+2
View File
@@ -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). **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 ### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA: The frontend (`frontend-web/`) is a React 19 + Vite SPA:
+2
View File
@@ -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). **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 ### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA: The frontend (`frontend-web/`) is a React 19 + Vite SPA:
@@ -18,6 +18,8 @@ import {
listThreadArtifacts, listThreadArtifacts,
mergeArtifactPaths, mergeArtifactPaths,
} from "@/core/artifacts/listing"; } 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 { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
@@ -104,6 +106,9 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null); const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]); const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
@@ -184,6 +189,7 @@ const ChatBox: React.FC<{
deselect, deselect,
selectedArtifact, selectedArtifact,
selectedArtifactKey, selectedArtifactKey,
openStreamedArtifact,
} = useArtifacts(); } = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -398,9 +404,12 @@ const ChatBox: React.FC<{
((artifactDirectoryModeEnabled && threadChanged) || ((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact)) ((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) { ) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!); const newest = nextArtifacts[nextArtifacts.length - 1]!;
setArtifactsOpen(true); if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
setSideTab("document"); selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
}
} }
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. // 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, setArtifacts,
availableArtifacts, availableArtifacts,
disableArtifacts, 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); const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => { useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current; const wasEnabled = directoryModeWasEnabledRef.current;
@@ -19,17 +19,16 @@ const TOOLBOX_ICON_TONES = [
* 简洁模式右侧工具箱。 * 简洁模式右侧工具箱。
* *
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目 * 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。 * 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
* 收起后右侧出现竖排按钮,可再次展开。
*/ */
export function CompactToolbox() { export function CompactToolbox() {
const { toolboxItems } = useWorkspaceCompactNavItems(); const { toolboxItems } = useWorkspaceCompactNavItems();
const { pathname } = useLocation(); const { pathname } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(true);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => setOpen(false), [pathname]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -41,7 +40,6 @@ export function CompactToolbox() {
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
closeRef.current?.focus();
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
@@ -73,22 +71,8 @@ export function CompactToolbox() {
<span className="text-xs font-semibold leading-4">工具箱</span> <span className="text-xs font-semibold leading-4">工具箱</span>
</button> </button>
<button
type="button"
tabIndex={-1}
aria-label="关闭工具箱抽屉"
aria-hidden={!open}
onClick={closeDrawer}
className={cn(
"fixed inset-0 z-[90] bg-black/20 transition-opacity duration-200",
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
/>
<aside <aside
id="compact-toolbox-panel" id="compact-toolbox-panel"
role="dialog"
aria-modal={open}
aria-label="工具箱" aria-label="工具箱"
aria-hidden={!open} aria-hidden={!open}
inert={!open} inert={!open}
@@ -102,10 +86,9 @@ export function CompactToolbox() {
<header className="relative flex h-12 shrink-0 items-center justify-center px-2"> <header className="relative flex h-12 shrink-0 items-center justify-center px-2">
<h2 className="text-sm font-semibold">工具箱</h2> <h2 className="text-sm font-semibold">工具箱</h2>
<button <button
ref={closeRef}
type="button" type="button"
aria-label="关闭工具箱" aria-label="收起工具箱"
title="关闭" title="收起"
onClick={closeDrawer} onClick={closeDrawer}
className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
@@ -131,10 +114,7 @@ export function CompactToolbox() {
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25" ? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary", : "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
)} )}
onClick={() => { onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
setOpen(false);
navigate(item.path ?? "/page/workspace/chats/new");
}}
> >
<span <span
className={cn( className={cn(
@@ -42,6 +42,7 @@ import {
import { import {
buildWriteFileVirtualUrl, buildWriteFileVirtualUrl,
hasCompleteArtifactExtension, hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath, normalizeStreamingArtifactPath,
provisionalWriteFilePath, provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview"; } from "@/core/artifacts/streaming-write-preview";
@@ -51,12 +52,13 @@ import {
resolveSearchQuery, resolveSearchQuery,
type SearchResultItem, type SearchResultItem,
} from "@/core/messages/search-results"; } from "@/core/messages/search-results";
import { import {
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
findToolCallResult, findToolCallResult,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useSkills } from "@/core/skills/hooks"; import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { import {
applyInlineCitations, applyInlineCitations,
buildSkillDisplayMap, buildSkillDisplayMap,
@@ -67,6 +69,7 @@ import {
type RuntimeDisplaySource, type RuntimeDisplaySource,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter"; import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -92,7 +95,7 @@ export function MessageGroup({
isLoading?: boolean; isLoading?: boolean;
displaySources?: RuntimeDisplaySource[]; displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean; ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [showAbove, setShowAbove] = useState( const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
@@ -100,12 +103,19 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState( const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const steps = useMemo(() => convertToSteps(messages), [messages]); const [settings] = useLocalSettings();
const { skills } = useSkills(); // 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
// write_file / read_file 步骤条必须照旧显示。
const writingModeEnabled = !!settings.context.writing_mode;
const steps = useMemo(
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills();
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills], [ragModeEnabled, skills],
); );
const lastToolCallStep = useMemo(() => { const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall"); const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1]; return filteredSteps[filteredSteps.length - 1];
@@ -197,11 +207,11 @@ export function MessageGroup({
<ToolCall <ToolCall
key={step.id} key={step.id}
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
)} )}
{lastToolCallStep && ( {lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}> <FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
@@ -209,10 +219,10 @@ export function MessageGroup({
key={lastToolCallStep.id} key={lastToolCallStep.id}
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
</ChainOfThoughtContent> </ChainOfThoughtContent>
@@ -754,7 +764,6 @@ function ArtifactWriteToolCall({
args, args,
result, result,
description, description,
isLast,
isLoading, isLoading,
}: { }: {
id?: string; id?: string;
@@ -793,19 +802,20 @@ function ArtifactWriteToolCall({
!artifactPath || !artifactPath ||
!previewKey || !previewKey ||
!isLoading || !isLoading ||
!isLast || result !== undefined ||
result !== undefined !sourcePath ||
!languageOfStreamingArtifact(sourcePath)
) { ) {
return; return;
} }
openStreamedArtifact(artifactPath, previewKey); openStreamedArtifact(artifactPath, previewKey);
}, [ }, [
artifactPath, artifactPath,
isLast,
isLoading, isLoading,
openStreamedArtifact, openStreamedArtifact,
previewKey, previewKey,
result, result,
sourcePath,
]); ]);
return ( return (
@@ -847,7 +857,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] { function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = []; const steps: CoTStep[] = [];
for (const message of messages) { for (const message of messages) {
if (message.type === "ai") { if (message.type === "ai") {
@@ -865,6 +875,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
if (tool_call.name === "task") { if (tool_call.name === "task") {
continue; continue;
} }
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
if (
(
tool_call.name === "write_file" ||
tool_call.name === "str_replace" ||
tool_call.name === "read_file" ||
tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files"
) &&
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
) {
continue;
}
const step: CoTToolCallStep = { const step: CoTToolCallStep = {
id: tool_call.id, id: tool_call.id,
messageId: message.id, messageId: message.id,
@@ -1,267 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { isIMEComposing } from "@/lib/ime";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
function openDraft(
draft: DraftMeta,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
window.dispatchEvent(
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
);
} else {
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
sessionStorage.setItem("roundtable:pending-draft", draft.id);
router.push(ROUNDTABLE_PATH);
}
}
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
function StatusBadge({ draft }: { draft: DraftMeta }) {
if (draft.status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
进行中
</span>
);
}
if (draft.status === "awaiting_input") {
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
}
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
return (
<span className="text-[10px] font-medium text-muted-foreground">
{draft.chainTitle || "自由模式"}
</span>
);
}
export function RecentRoundtableList() {
const router = useRouter();
const pathname = usePathname();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
);
// 重命名对话框状态。
const [renameId, setRenameId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// 分享对话框 —— 正在分享的草稿 id。
const [shareId, setShareId] = useState<string | null>(null);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
const handleRenameSubmit = useCallback(async () => {
const id = renameId;
const title = renameValue.trim();
if (!id || !title) {
setRenameId(null);
return;
}
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
setRenameValue("");
}
}, [renameId, renameValue, rename]);
const handleDelete = useCallback(
async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
},
[remove],
);
if (!loading && drafts.length === 0) return null;
const isOnPage = pathname === ROUNDTABLE_PATH;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>多智能体会商</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && drafts.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
drafts.slice(0, 10).map((draft) => (
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openDraft(draft, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{draft.title || "未命名草稿"}
</span>
<div className="flex items-center gap-1 mt-0.5">
<StatusBadge draft={draft} />
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background top-2"
>
<MoreHorizontal />
<span className="sr-only">更多</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() => {
setRenameId(draft.id);
setRenameValue(draft.title || "");
}}
>
<Pencil className="text-muted-foreground" />
<span>重命名</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground" />
<span>分享</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
<Trash2 className="text-muted-foreground" />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* 重命名对话框 */}
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>重命名会商</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameId(null)}>
取消
</Button>
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享对话框 */}
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(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<typeof useRouter>,
) {
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 (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -14,21 +14,19 @@ import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar"; import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu"; import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({ export function WorkspaceSidebar({
compactChrome = false, compactChrome = false,
forceCollapsed = false, forceCollapsed = false,
className, className,
...props ...props
}: React.ComponentProps<typeof Sidebar> & { }: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean; compactChrome?: boolean;
forceCollapsed?: boolean; forceCollapsed?: boolean;
}) { }) {
const { open: isSidebarOpen } = useSidebar(); const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme(); const { theme } = useTheme();
const pathname = usePathname(); const pathname = usePathname();
@@ -36,26 +34,26 @@ export function WorkspaceSidebar({
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats"); const isGeneralQa = pathname.startsWith("/page/workspace/chats");
if (forceCollapsed) { if (forceCollapsed) {
return ( return (
<Sidebar <Sidebar
variant="sidebar" variant="sidebar"
collapsible="icon" collapsible="icon"
className={cn( className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar", "bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className, className,
)} )}
{...props} {...props}
> >
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList /> <WorkspaceNavChatList />
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
); );
} }
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 // collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从 // sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
@@ -123,10 +121,10 @@ export function WorkspaceSidebar({
compact={compactChrome} compact={compactChrome}
/> />
{isSidebarOpen && <RecentChatList compact={compactChrome} />} {isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐: {/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */} (/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
{isSidebarOpen && !compactChrome && <RecentRoundtableList />} 无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
{isSidebarOpen && !compactChrome && <RecentWritingList />} 全量模式本就不展示。 */}
</SidebarContent> </SidebarContent>
{!compactChrome && ( {!compactChrome && (
<SidebarFooter> <SidebarFooter>
@@ -1,7 +1,246 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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", () => { test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal( assert.equal(
@@ -1,5 +1,14 @@
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
import { isImageFile } from "../utils/files"; import { isImageFile } from "../utils/files";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
export function isWriteFileStreamingUrl(filepath: string): boolean { export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) { if (!filepath.startsWith("write-file:")) {
return false; return false;
@@ -27,8 +36,12 @@ export function resolveWriteFilePath(filepath: string): string {
type WriteFileThreadLike = { type WriteFileThreadLike = {
messages: Array<{ messages: Array<{
id?: string;
type?: string; type?: string;
name?: string;
content?: unknown;
tool_call_id?: string; tool_call_id?: string;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{ tool_calls?: Array<{
id?: string; id?: string;
name?: string; name?: string;
@@ -75,3 +88,67 @@ export function isWriteFileInFlight(
} }
return false; 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;
}
@@ -1,6 +1,5 @@
/** /**
* embed-session.ts 浏览上下文隔离单测。 * embed-session.ts 浏览上下文隔离单测。
* 运行方式(项目无 vitest):
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \ * npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs * --platform=node --outfile=.tmp-tests/embed-session.test.mjs
* node --test .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(); asTop();
syncEmbedSessionFromSearch(""); syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false); assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
asIframe(); asIframe();
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0"); syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
assert.equal(isEmbedActive(), true); assert.equal(isEmbedActive(), true);
assert.equal(isChromeHidden(), true);
assert.ok(storage.getItem("embed.session.nested")); assert.ok(storage.getItem("embed.session.nested"));
assert.equal(storage.getItem("embed.session"), null);
asTop(); asTop();
assert.equal(isEmbedActive(), false); 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 }) => { withSharedTab(({ storage, asTop }) => {
asTop(); asTop();
syncEmbedSessionFromSearch("");
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
storage.setItem( storage.setItem(
"embed.session", "embed.session",
JSON.stringify({ JSON.stringify({
@@ -113,15 +107,14 @@ test("top parent reclaims pollution from an old nested iframe (no topOwned)", ()
isolate: false, isolate: false,
}), }),
); );
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
assert.equal(isEmbedActive(), false); assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), 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 }) => { withSharedTab(({ storage, asTop }) => {
asTop(); asTop();
storage.setItem( storage.setItem(
@@ -137,12 +130,11 @@ test("top sync after old nested pollution does not mark owner", () => {
); );
syncEmbedSessionFromSearch(""); syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false); assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false); assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.equal(storage.getItem("embed.session"), null);
}); });
}); });
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 }) => { withSharedTab(({ storage, asTop, asIframe }) => {
asTop(); asTop();
storage.setItem( storage.setItem(
@@ -159,11 +151,11 @@ test("nested iframe reclaims a polluted shared embed.session key", () => {
asIframe(); asIframe();
syncEmbedSessionFromSearch("?embed=1"); syncEmbedSessionFromSearch("?embed=1");
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.ok(storage.getItem("embed.session.nested"));
asTop(); asTop();
assert.equal(isEmbedActive(), false); 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", () => { test("top ignore strips chrome in memory but keeps isolate", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
test("top reclaim strips embed but keeps isolate without nested help", () => {
withSharedTab(({ storage, asTop }) => { withSharedTab(({ storage, asTop }) => {
asTop(); asTop();
storage.setItem( storage.setItem(
@@ -224,5 +192,6 @@ test("top reclaim strips embed but keeps isolate without nested help", () => {
); );
assert.equal(isEmbedActive(), false); assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true); assert.equal(isIsolatedSession(), true);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
}); });
}); });
+98 -199
View File
@@ -16,34 +16,17 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate` * 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。 * 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
* *
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏; * 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。 * 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。
* * **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响): * 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage * 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome
* (跨标签共享 → 会污染正常页)。 * (见 `isHostNestedSurfacePath`),不依赖这份会话。
* - 主题走 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 置位);老版嵌套页仍写共用键时,
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
*/ */
const EMBED_SESSION_KEY = "embed.session"; const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */ /** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested"; const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
*/
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed"; const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean }; type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
@@ -61,7 +44,6 @@ function getSessionStorage(): Storage | null {
} }
} }
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
function isTopLevelWindow(): boolean { function isTopLevelWindow(): boolean {
const w = getWindow(); const w = getWindow();
if (!w) return true; if (!w) return true;
@@ -78,28 +60,14 @@ function embedSessionKey(): string {
export type EmbedTheme = "light" | "dark"; export type EmbedTheme = "light" | "dark";
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
export interface EmbedSession { export interface EmbedSession {
/** 嵌入模式:去全局 chrome + 强制主题。 */
embed: boolean; embed: boolean;
/** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */
theme: EmbedTheme; theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean; hideJump: boolean;
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
hideHeader: boolean; hideHeader: boolean;
/**
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
* 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。
*/
hideChrome: boolean; hideChrome: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean; isolate: boolean;
/** /** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
*/
topOwned?: boolean; 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 { function isTopChromeOwned(session?: EmbedSession | null): boolean {
const w = getWindow(); const w = getWindow();
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true; if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
return session?.topOwned === true; return session?.topOwned === true;
} }
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
function markTopChromeOwner(session: EmbedSession): void { function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow(); const w = getWindow();
if (!w || !isTopLevelWindow()) return; if (!w || !isTopLevelWindow()) return;
@@ -192,31 +123,25 @@ function markTopChromeOwner(session: EmbedSession): void {
w[TOP_CHROME_OWNER_FLAG] = true; w[TOP_CHROME_OWNER_FLAG] = true;
} }
/** function readStoredSession(): EmbedSession | null {
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
*/
function reclaimUnownedTopChrome(): void {
if (!isTopLevelWindow()) return;
try { try {
const store = getSessionStorage(); return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur || !sessionHidesChrome(cur)) return;
if (isTopChromeOwned(cur)) {
const w = getWindow();
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
return;
}
persistTopSession(stripChromeFlags(cur));
} catch { } 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 键自己用;**不删除**共用键,
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。 * 避免老版内层丢掉 embed 后把导航栏放出来。
*/ */
function migrateLegacySharedSessionIfNested(): void { function migrateLegacySharedSessionIfNested(): void {
if (isTopLevelWindow()) return; 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; type FlagParam = "on" | "off" | null;
function toSearchParams(search: string): URLSearchParams { function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
} }
/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */
function readFlagParam(search: string, key: string): FlagParam { function readFlagParam(search: string, key: string): FlagParam {
const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase(); const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase();
if (!v) return null; if (!v) return null;
@@ -262,32 +169,26 @@ function readFlagParam(search: string, key: string): FlagParam {
return null; return null;
} }
/** 读 URL 里的 `embed` 三态。 */
export function readEmbedParam(search: string): FlagParam { export function readEmbedParam(search: string): FlagParam {
return readFlagParam(search, "embed"); return readFlagParam(search, "embed");
} }
/** 读 URL 里的 `hideJump` 三态。 */
export function readHideJumpParam(search: string): FlagParam { export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump"); return readFlagParam(search, "hideJump");
} }
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
export function readHideHeaderParam(search: string): FlagParam { export function readHideHeaderParam(search: string): FlagParam {
return readFlagParam(search, "hideHeader"); return readFlagParam(search, "hideHeader");
} }
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
export function readHideChromeParam(search: string): FlagParam { export function readHideChromeParam(search: string): FlagParam {
return readFlagParam(search, "hideChrome"); return readFlagParam(search, "hideChrome");
} }
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
export function readIsolateParam(search: string): FlagParam { export function readIsolateParam(search: string): FlagParam {
return readFlagParam(search, "isolate"); return readFlagParam(search, "isolate");
} }
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
export function readEmbedThemeParam(search: string): string { export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim(); 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 { export function readEmbedSession(): EmbedSession | null {
try { const s = readStoredSession();
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。 if (!s) return null;
reclaimUnownedTopChrome(); if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) {
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null); return {
} catch { ...s,
return null; embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
} }
return s;
} }
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void { function writeEmbedSession(next: EmbedSession): void {
try { try {
const store = getSessionStorage(); const store = getSessionStorage();
if (!store) return; if (!store) return;
const key = embedSessionKey(); const key = embedSessionKey();
if ( let payload: EmbedSession = {
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(key);
return;
}
const payload: EmbedSession = {
embed: next.embed, embed: next.embed,
theme: next.theme, theme: next.theme,
hideJump: next.hideJump, hideJump: next.hideJump,
@@ -337,37 +236,56 @@ function writeEmbedSession(next: EmbedSession): void {
isolate: next.isolate, isolate: next.isolate,
}; };
if (next.topOwned) payload.topOwned = true; 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)); store.setItem(key, JSON.stringify(payload));
} catch { } catch {
/* noop */ /* noop */
} }
} }
/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */
export function isEmbedActive(): boolean { export function isEmbedActive(): boolean {
return readEmbedSession()?.embed === true; return readEmbedSession()?.embed === true;
} }
/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */
export function isJumpBarHidden(): boolean { export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true; 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 { export function isHeaderHidden(): boolean {
const session = readEmbedSession(); const session = readEmbedSession();
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true; return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
} }
/**
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
* 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。
*/
export function isChromeHidden(): boolean { export function isChromeHidden(): boolean {
const s = readEmbedSession(); const s = readEmbedSession();
return s?.embed === true || s?.hideChrome === true; return s?.embed === true || s?.hideChrome === true;
@@ -377,28 +295,38 @@ export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true; return readEmbedSession()?.hideChrome === true;
} }
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
*/
export function isIsolatedSession(): boolean { export function isIsolatedSession(): boolean {
return readEmbedSession()?.isolate === true; return readEmbedSession()?.isolate === true;
} }
/**
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
* 之前置位的开关不会被抹掉。
*
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
*/
export function syncEmbedSessionFromSearch(search: string): void { export function syncEmbedSessionFromSearch(search: string): void {
migrateLegacySharedSessionIfNested(); if (!isTopLevelWindow()) {
if (isTopLevelWindow()) reclaimUnownedTopChrome(); 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 next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search); const embedParam = readEmbedParam(search);
@@ -425,27 +353,11 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (isolateParam === "on") next.isolate = true; if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false; else if (isolateParam === "off") next.isolate = false;
if (isTopLevelWindow()) { next.topOwned = topOwned;
if (searchEnablesChromeHide(search)) {
next.topOwned = true;
} else if (!isTopChromeOwned(next)) {
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
} else {
next.topOwned = true;
}
}
writeEmbedSession(next); writeEmbedSession(next);
if (isTopLevelWindow()) markTopChromeOwner(next); if (topOwned) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
} }
/**
* 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。
* 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入,
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
*/
function currentHashSearch(): string { function currentHashSearch(): string {
const w = getWindow(); const w = getWindow();
if (!w) return ""; if (!w) return "";
@@ -454,18 +366,10 @@ function currentHashSearch(): string {
return i >= 0 ? hash.slice(i) : ""; return i >= 0 ? hash.slice(i) : "";
} }
/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */
export function syncEmbedSessionFromCurrentUrl(): void { export function syncEmbedSessionFromCurrentUrl(): void {
syncEmbedSessionFromSearch(currentHashSearch()); syncEmbedSessionFromSearch(currentHashSearch());
} }
/**
* 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用:
* - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁);
* - URL 显式 `embed=0` → 不强制(undefined);
* - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。
* 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。
*/
export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined { export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined {
const param = readEmbedParam(search); const param = readEmbedParam(search);
if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(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; return session?.embed ? session.theme : undefined;
} }
/**
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void { export function setIsolatedSession(on: boolean): void {
const cur = readEmbedSession() ?? emptySession(); const stored = readStoredSession() ?? emptySession();
writeEmbedSession({ ...cur, isolate: on }); writeEmbedSession({ ...stored, isolate: on });
} }
@@ -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", () => { test("tool messages still attach to an open processing group", () => {
const types = groupTypes([ const types = groupTypes([
{ {
+21 -8
View File
@@ -166,14 +166,19 @@ export function groupMessages<T>(
messages: [message], messages: [message],
}); });
} else if (hasOnlyDeepResearchProgress(message)) { } else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive // 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
// processing messages would pin a new report's in-progress steps onto // 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
// the parent bar, which sits above the original file card. // 会有 human/assistant 隔开,不会误并到上一轮的条上。
groups.push({ const lastGroup = groups[groups.length - 1];
id: message.id, if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
type: "assistant:processing", lastGroup.messages.push(message);
messages: [message], } else {
}); groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
} else if (hasSubagent(message)) { } else if (hasSubagent(message)) {
groups.push({ groups.push({
id: message.id, 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) { export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification"; return message.type === "tool" && message.name === "ask_clarification";
} }
@@ -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);
});
@@ -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;
}
@@ -6,6 +6,7 @@ import {
buildResearchFeedMessages, buildResearchFeedMessages,
buildResearchProgressToolCalls, buildResearchProgressToolCalls,
buildResearchReportVirtualUrl, buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
clearResearchReportStream, clearResearchReportStream,
finalizeResearchReportStream, finalizeResearchReportStream,
ingestResearchReportEvent, ingestResearchReportEvent,
@@ -139,15 +140,33 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
sourceCount: 3, sourceCount: 3,
}); });
const calls = buildResearchProgressToolCalls(active); 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.status, "complete");
assert.equal(calls[0].args.label, "梳理写作思路");
assert.equal(calls[1].args.status, "active"); assert.equal(calls[1].args.status, "active");
assert.equal(calls[1].args.label, "整理对话资料");
assert.match(calls[1].args.detail, /1\/2/); assert.match(calls[1].args.detail, /1\/2/);
const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" }); const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" });
assert.equal(buildResearchProgressToolCalls(writing).length, 3); assert.equal(buildResearchProgressToolCalls(writing).length, 3);
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active"); 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 failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
const failedCalls = buildResearchProgressToolCalls(failed); const failedCalls = buildResearchProgressToolCalls(failed);
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/); 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", () => { test("feed messages attach the write_file stub only once report text exists", () => {
const noText = streamOf({ jobId: "j9", phase: "collecting" }); const noText = streamOf({ jobId: "j9", phase: "collecting" });
assert.equal(buildResearchFeedMessages(noText).length, 1); 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 withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
const feed = buildResearchFeedMessages(withText); const feed = buildResearchFeedMessages(withText);
assert.equal(feed.length, 2); assert.equal(feed.length, 2);
const stub = feed[1].tool_calls[0]; const stub = feed[1].tool_calls?.[0];
assert.equal(stub.name, "write_file"); assert.equal(stub?.name, "write_file");
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md"); assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub.args.content, "# 报告正文"); 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)", () => { test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按 * 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理 * token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多 * (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与 * 10 次通知,流式观感不受影响。终态帧(tool_finished /
* finalize/clear 立即 flush,结束态不滞后。 * report_completed)与 finalize/clear 立即 flush,结束态不滞后。
*/ */
const EMIT_COALESCE_MS = 100; const EMIT_COALESCE_MS = 100;
@@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
case "report_completed": case "report_completed":
next = { next = {
...base, ...base,
phase: "writing", // 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
phase: "done",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0 sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount ? payload.sourceCount
: base.sourceCount, : base.sourceCount,
@@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
if (next !== previous) { if (next !== previous) {
store.state = next; store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。 // 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished") { if (frame.type === "tool_finished" || frame.type === "report_completed") {
emitNow(store); emitNow(store);
} else { } else {
emitCoalesced(store); emitCoalesced(store);
@@ -280,33 +281,41 @@ export type ResearchProgressToolCall = {
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] { export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase); const rank = phaseRank(state.phase);
const finished = state.status !== "active"; 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 = [ const stages = [
{ {
id: "plan", id: "plan",
label: "拆解检索问题", // 写作管线不检索:只按课题拆论述角度、汇总对话已收集的资料再成稿。
// 措辞避免「拆解检索问题/检索参考资料」,用户会误会管线在重新检索。
label: "梳理写作思路",
boundary: phaseRank("collecting"), boundary: phaseRank("collecting"),
detail: state.queries.length > 0 detail: state.queries.length > 0
? `已拆出 ${state.queries.length} 个检索子问题` ? `已梳理出 ${state.queries.length} 个论述角度`
: "正在围绕课题拆解检索子问题", : "正在围绕课题梳理写作思路",
}, },
{ {
id: "collect", id: "collect",
label: "检索参考资料", label: "整理对话资料",
boundary: phaseRank("compressing"), boundary: phaseRank("compressing"),
detail: state.queries.length > 0 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 > 0
? `已检索到 ${state.sourceCount} 条资料` ? `已汇总 ${state.sourceCount} 条对话资料`
: "正在逐个子问题检索资料", : "正在汇总对话中已收集的资料",
}, },
{ {
id: "write", id: "write",
label: "撰写研究报告", label: "撰写研究报告",
boundary: PHASE_ORDER.length, boundary: PHASE_ORDER.length,
detail: state.reportText detail: succeeded
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览" ? "已按「通用分析结构」写完并保存为 report.md"
: "正在整理资料并开始撰写报告", : state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
}, },
]; ];
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary); const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
@@ -330,19 +339,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
export type ResearchFeedMessage = { export type ResearchFeedMessage = {
id: string; id: string;
type: "ai"; type: "ai" | "tool";
content: string; content: string;
tool_calls: { name?: string;
tool_call_id?: string;
tool_calls?: {
id: string; id: string;
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
}[]; }[];
}; };
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
return state.status === "success" || state.phase === "done";
}
/** /**
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file * 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在 * (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
* 流式时输出 stub —— 完成后由真实 artifacts 接管。 * 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
*/ */
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] { export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [ const messages: ResearchFeedMessage[] = [
@@ -353,23 +368,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
tool_calls: buildResearchProgressToolCalls(state), 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({ messages.push({
id: `research-report-${state.jobId}`, id: `research-report-${state.jobId}-write-result`,
type: "ai", type: "tool",
content: "", name: "write_file",
tool_calls: [ tool_call_id: writeCallId,
{ content: "ok",
id: `research-report-${state.jobId}-write`,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
}); });
} }
return messages; 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<string> {
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<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
): T[] {
const hidden = new Set<number>();
let pendingIds: Set<string> | 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<T>(messages: T[], feed: T[]): T[] {
if (feed.length === 0) return messages;
let insertAt = -1;
let callIds = new Set<string>();
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<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
feed: T[],
): T[] {
return insertResearchFeedMessages(omitLeakedReportTokens(messages), feed);
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */ /** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string { export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL( return new URL(
@@ -3,8 +3,8 @@
/** /**
* 聊天页「写作模式」深度研究进度注入。 * 聊天页「写作模式」深度研究进度注入。
* *
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的 * 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
* 虚拟消息(进度步骤条 + report.md 的 write_file stub): * 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
* *
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰); * - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出), * - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
@@ -26,13 +26,16 @@ import {
artifactPreviewKey, artifactPreviewKey,
useArtifactsOptional, useArtifactsOptional,
} from "@/components/workspace/artifacts/context"; } from "@/components/workspace/artifacts/context";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
import { import {
buildResearchFeedMessages, buildResearchFeedMessages,
buildResearchReportVirtualUrl, buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
RESEARCH_REPORT_OUTPUT_PATH, RESEARCH_REPORT_OUTPUT_PATH,
useResearchReportStream, useResearchReportStream,
} from "./use-research-report-stream"; } from "./use-research-report-stream";
import { isInterceptedWritingReportWrite } from "./writing-mode";
type FeedThreadLike = { type FeedThreadLike = {
messages: Message[]; messages: Message[];
@@ -55,10 +58,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
[thread.values?.artifacts], [thread.values?.artifacts],
); );
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟 // 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
// 预览,报告在沙箱里边写边流式渲染。 // 避免「写入文件 report.md」出现在模型还在想的时候。
useEffect(() => { useEffect(() => {
if (!research || research.status !== "active" || !research.reportText.trim()) { if (
!research ||
research.status !== "active" ||
research.phase !== "writing" ||
!research.reportText.trim()
) {
return; return;
} }
if (autoOpenedJobRef.current === research.jobId) return; if (autoOpenedJobRef.current === research.jobId) return;
@@ -77,7 +85,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
if (!artifacts) return; if (!artifacts) return;
handedOverJobRef.current = research.jobId; handedOverJobRef.current = research.jobId;
const current = artifacts.selectedArtifact; 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; return;
} }
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH); artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
@@ -91,11 +107,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
}, [hasRealArtifact, research]); }, [hasRealArtifact, research]);
const displayThread = useMemo(() => { const displayThread = useMemo(() => {
if (feedMessages.length === 0) return thread; const messages = composeResearchDisplayMessages(
const messages = [ thread.messages,
...thread.messages, feedMessages as unknown as Message[],
...(feedMessages as unknown as Message[]), );
]; if (messages === thread.messages) return thread;
return { return {
...thread, ...thread,
values: { ...thread.values, messages }, values: { ...thread.values, messages },
@@ -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,
);
});
@@ -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<string, unknown>;
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));
}
@@ -273,6 +273,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
hideHeaderSaveDraft hideHeaderSaveDraft
hideHeaderSettings hideHeaderSettings
newTaskLabel="新建会话" newTaskLabel="新建会话"
step3Mode="report-only"
hideDefaultStep1RightSidebar hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`} step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`} step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
+8 -10
View File
@@ -45,6 +45,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
import { import {
Select, Select,
@@ -612,16 +613,13 @@ export default function AgentChatPage({
} as typeof displayThread; } as typeof displayThread;
}, [displayThread, showReportImportCard, taskId, threadId]); }, [displayThread, showReportImportCard, taskId, threadId]);
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path, // 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与 // 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
// ChatPage 的回传逻辑对齐)。 // .md 不得跳过管线(与 ChatPage 对齐)。
const writingArtifactPath = useMemo(() => { const writingArtifactPath = useMemo(
const isMarkdown = (path: string | null | undefined) => () => resolveWritingModeReportPath(thread.values.artifacts),
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path); [thread.values.artifacts],
return [...(thread.values.artifacts ?? [])] );
.reverse()
.find((path) => isMarkdown(path));
}, [thread.values.artifacts]);
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)────────────────────────── // ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板, // 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
+6 -10
View File
@@ -63,6 +63,7 @@ import {
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag"; import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -354,16 +355,11 @@ export default function ChatPage() {
researchDisplayThread, researchDisplayThread,
]); ]);
const writingArtifactPath = useMemo(() => { const writingArtifactPath = useMemo(
const isMarkdown = (path: string | null | undefined) => () =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path); resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
if (isMarkdown(selectedArtifact)) { [selectedArtifact, thread.values.artifacts],
return selectedArtifact ?? undefined; );
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: PromptInputMessage) => {
+447 -119
View File
@@ -3,16 +3,24 @@
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Compass,
Clock,
DownloadIcon, DownloadIcon,
MessageSquare,
PenTool,
Pin, Pin,
PinOff, PinOff,
Search,
Star, Star,
Users2,
type LucideIcon,
X,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import { import {
@@ -42,9 +50,70 @@ import {
import { formatTimeAgo } from "@/core/utils/datetime"; import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; 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; 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<Exclude<HistoryTab, "chats">, string> = {
roundtable: "搜索会商记录…",
writing: "搜索写作记录…",
research: "搜索研究记录…",
};
const TAB_TOTAL_UNIT: Record<HistoryTab, string> = {
chats: "条对话",
roundtable: "条会商记录",
writing: "篇写作",
research: "项研究",
};
export default function ChatsPage({ export default function ChatsPage({
compactChrome = false, compactChrome = false,
}: { }: {
@@ -54,16 +123,28 @@ export default function ChatsPage({
const { t } = useI18n(); const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin(); const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite(); const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [onlyFavorites, setOnlyFavorites] = useState(false); const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null);
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts();
const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions();
const { data: researchTabData } = useResearchSessions(100);
useEffect(() => { useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`; document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [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. // Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300); 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. // Reset to the first page whenever the filtered result set changes.
useEffect(() => { useEffect(() => {
setPage(1); 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 // Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is // query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small // 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, // 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. // and dropped from the paginated rows so they don't repeat on every page.
const favoriteFilter = useMemo( const threadFilter = useMemo(
() => (onlyFavorites ? { is_favorite: true } : {}), () =>
[onlyFavorites], chatFilter === "favorites"
? { is_favorite: true }
: chatFilter === "pinned"
? { is_pinned: true }
: {},
[chatFilter],
); );
const pinnedFilter = useMemo( const pinnedFilter = useMemo(
() => ({ ...favoriteFilter, is_pinned: true }), () => ({ ...threadFilter, is_pinned: true }),
[favoriteFilter], [threadFilter],
); );
const { data: pageData } = useThreadsPage({ const { data: pageData } = useThreadsPage({
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
search: debouncedSearch, search: debouncedSearch,
metadata: favoriteFilter, metadata: threadFilter,
}); });
const { data: pinnedData } = useThreadsPage({ const { data: pinnedData } = useThreadsPage({
page: 1, page: 1,
@@ -102,13 +198,28 @@ export default function ChatsPage({
}); });
const { data: totalCount } = useThreadsCount({ const { data: totalCount } = useThreadsCount({
search: debouncedSearch, 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( 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 currentPage = page;
const hasMore = pageData?.hasMore ?? false; const hasMore = pageData?.hasMore ?? false;
@@ -123,46 +234,67 @@ export default function ChatsPage({
return ( return (
<div <div
key={thread.thread_id} key={thread.thread_id}
className="group/chat-row relative flex flex-col gap-2 border-b" className={cn(
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
pinned
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
: "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs",
)}
> >
<Link href={pathOfThread(thread)} className="block p-4 pr-24"> <Button
<div className="flex items-center gap-1.5"> variant="ghost"
size="icon-sm"
className={cn(
"mt-0.5 shrink-0 rounded-lg sm:mt-0",
favorited
? "bg-amber-50 text-amber-500 hover:bg-amber-100 hover:text-amber-600"
: "text-muted-foreground/45 hover:bg-muted hover:text-muted-foreground",
)}
title={favorited ? t.common.unfavorite : t.common.favorite}
aria-label={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star className={cn("size-4", favorited && "fill-current")} />
</Button>
<Link
href={pathOfThread(thread)}
className="min-w-0 flex-1 rounded-lg p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex min-w-0 items-center gap-2">
{pinned && ( {pinned && (
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" /> <span className="inline-flex items-center gap-1 rounded border !border-red-200 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
<Pin className="size-3 rotate-45 text-red-600" />
置顶
</span>
)} )}
<span className="truncate"> <span className="inline-flex items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<MessageSquare className="size-3 text-red-600" />
对话
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-neutral-900 transition-colors group-hover/chat-row:text-red-700 sm:text-base">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
</div> </div>
{thread.updated_at && ( {thread.updated_at && (
<div className="text-muted-foreground mt-2 text-sm"> <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
<Clock className="size-3" />
{formatTimeAgo(thread.updated_at)} {formatTimeAgo(thread.updated_at)}
</div> </div>
)} )}
</Link> </Link>
{/* Row actions — kept out of the Link so clicks don't navigate. */} {thread.updated_at && (
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100"> <div className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
<Clock className="size-3" />
<span>{formatTimeAgo(thread.updated_at)}</span>
</div>
)}
<div className="flex shrink-0 items-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
className="size-8"
title={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={cn(
"size-4",
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-8" className="size-8"
title={pinned ? t.common.unpin : t.common.pin} title={pinned ? t.common.unpin : t.common.pin}
aria-label={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)} onClick={() => togglePin(thread.thread_id, !pinned)}
> >
{pinned ? ( {pinned ? (
@@ -177,6 +309,15 @@ export default function ChatsPage({
}; };
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0; const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
const historyTabCounts: Record<HistoryTab, number> = {
chats: allThreadsCount ?? 0,
roundtable: roundtableTabDrafts.length,
writing: writingTabSessions.length,
research: researchTabData?.total ?? 0,
};
return ( return (
<WorkspaceContainer> <WorkspaceContainer>
@@ -192,93 +333,280 @@ export default function ChatsPage({
} }
/> />
<WorkspaceBody> <WorkspaceBody>
<div className="flex size-full flex-col"> <div className="flex size-full min-w-0 flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8"> <header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2"> <div className="relative w-full max-w-5xl px-4 sm:px-6">
<div className="flex items-center gap-1"> <div className="flex w-full flex-col items-center">
<Button <div
variant={onlyFavorites ? "outline" : "secondary"} id="primary-tabs-nav"
size="sm" className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
onClick={() => setOnlyFavorites(false)} role="tablist"
aria-label="历史记录类型"
> >
{t.chats.allChats} {HISTORY_TABS.map((tab) => {
</Button> const isActive = activeTab === tab.id;
<Button const Icon = tab.icon;
variant={onlyFavorites ? "secondary" : "outline"} const count = historyTabCounts[tab.id];
size="sm"
onClick={() => setOnlyFavorites(true)} return (
> <button
<Star className="size-4" /> key={tab.id}
{t.chats.favorites} id={`history-tab-${tab.id}`}
</Button> type="button"
className={cn(
"relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
isActive
? "font-semibold text-red-700 shadow-xs"
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
)}
role="tab"
aria-selected={isActive}
aria-controls={`history-panel-${tab.id}`}
title={tab.description}
onClick={() => setActiveTab(tab.id)}
>
{isActive && (
<motion.div
layoutId="activeTabBadge"
className="absolute inset-0 rounded-xl border !border-red-200 bg-red-50/90 shadow-xs"
transition={{ type: "spring", stiffness: 450, damping: 35 }}
aria-hidden="true"
/>
)}
<span
className={cn(
"relative z-10 flex items-center gap-2",
isActive
? "font-semibold text-red-700"
: "text-neutral-600",
)}
>
<Icon className="size-4" aria-hidden="true" />
<span>{tab.label}</span>
</span>
{count > 0 && (
<span
className={cn(
"relative z-10 rounded-full px-1.5 py-[0.05rem] font-mono text-[11px] transition-colors",
isActive
? "bg-red-600 font-bold text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
</div> </div>
<Button {activeTab === "chats" && (
variant="outline" <Button
size="sm" variant="outline"
onClick={() => setImportOpen(true)} size="sm"
> className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
<DownloadIcon className="size-4" /> onClick={() => setImportOpen(true)}
{t.threadShare.importChats} >
</Button> <DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
)}
</div> </div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{totalCount != null && (
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
共 {totalCount} 条对话
</div>
)}
</header> </header>
<main className="min-h-0 flex-1"> <main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4"> <ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col"> <div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
{currentPage === 1 && pinnedThreads.length > 0 && ( <section
<> id={`history-panel-${activeTab}`}
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase"> role="tabpanel"
{t.chats.pinnedSection} aria-labelledby={`history-tab-${activeTab}`}
className="space-y-4 rounded-2xl border border-red-200 bg-white p-4 shadow-xs sm:p-5"
>
{activeTab !== "roundtable" && <div className="w-full space-y-3.5">
<div className="flex items-center justify-between gap-3">
{activeTab === "chats" ? (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "all"
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
)}
aria-pressed={chatFilter === "all"}
onClick={() => setChatFilter("all")}
>
<span>全部</span>
{allThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "all"
? "bg-red-700 text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{allThreadsCount}
</span>
)}
</button>
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30",
chatFilter === "favorites"
? "border-amber-300 bg-amber-50 font-semibold text-amber-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900",
)}
aria-pressed={chatFilter === "favorites"}
onClick={() => setChatFilter("favorites")}
>
<Star className={cn("size-3.5", chatFilter === "favorites" ? "fill-amber-500 text-amber-500" : "text-neutral-400")} />
<span>收藏</span>
{favoriteThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "favorites"
? "bg-amber-200 text-amber-900"
: "bg-neutral-100 text-neutral-500",
)}
>
{favoriteThreadsCount}
</span>
)}
</button>
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "pinned"
? "border-red-200 bg-red-50 font-semibold text-red-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-red-50/50 hover:text-red-700",
)}
aria-pressed={chatFilter === "pinned"}
onClick={() => setChatFilter("pinned")}
>
<Pin className="size-3.5 rotate-45 text-red-600" />
<span>置顶</span>
<span className="rounded-full bg-red-100 px-1.5 py-px font-mono text-[11px] font-medium text-red-800">
{pinnedThreadsCount}
</span>
</button>
)}
</div>
) : (
<div className="min-w-0 truncate text-sm font-medium text-neutral-500">
{activeTabConfig.description}
</div>
)}
<label className="relative min-w-0 flex-1 md:max-w-md">
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400"
aria-hidden="true"
/>
<span className="sr-only">
{activeTab === "chats"
? t.chats.searchChats
: TAB_SEARCH_PLACEHOLDER[activeTab]}
</span>
<input
type="search"
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"
placeholder={
activeTab === "chats"
? "搜索对话标题、关键词或标签…"
: TAB_SEARCH_PLACEHOLDER[activeTab]
}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
aria-label="清空搜索"
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</button>
)}
</label>
</div> </div>
{pinnedThreads.map(renderThread)} <div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
</> {displayTotal != null && (
)} <span className="font-medium">
{pagedThreads.map(renderThread)} 共 <strong className="font-bold text-red-700">{displayTotal}</strong>{" "}
{isEmpty && ( {TAB_TOTAL_UNIT[activeTab]}
<div className="text-muted-foreground py-16 text-center text-sm"> </span>
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats} )}
</div> </div>
)} </div>}
{(currentPage > 1 || hasMore) && ( {activeTab === "chats" && (
<div className="flex items-center justify-center gap-3 py-6"> <div className="space-y-3 pt-5">
<Button {currentPage === 1 && pinnedThreads.map(renderThread)}
variant="outline" {pagedThreads.map(renderThread)}
size="sm" {isEmpty && (
disabled={currentPage <= 1} <div className="text-muted-foreground py-16 text-center text-sm">
onClick={() => setPage((p) => Math.max(1, p - 1))} {chatFilter === "favorites"
> ? t.chats.noFavorites
<ChevronLeft className="size-4" /> : chatFilter === "pinned"
{t.chats.prevPage} ? "暂无置顶对话"
</Button> : t.chats.noChats}
<span className="text-muted-foreground text-sm tabular-nums"> </div>
{totalPages != null )}
? `${currentPage} / ${totalPages}` {(currentPage > 1 || hasMore) && (
: currentPage} <div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
</span> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" disabled={currentPage <= 1}
disabled={!hasMore} onClick={() => setPage((p) => Math.max(1, p - 1))}
onClick={() => setPage((p) => p + 1)} >
> <ChevronLeft className="size-4" />
{t.chats.nextPage} {t.chats.prevPage}
<ChevronRight className="size-4" /> </Button>
</Button> <span className="text-muted-foreground text-sm tabular-nums">
</div> {totalPages != null
)} ? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
)}
{activeTab === "roundtable" && (
<RoundtableHistoryTab onTotalChange={handleTabTotalChange} />
)}
{activeTab === "writing" && (
<div className="pt-5">
<WritingHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
{activeTab === "research" && (
<div className="pt-5">
<ResearchHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
</section>
</div> </div>
</ScrollArea> </ScrollArea>
</main> </main>
+6 -10
View File
@@ -48,6 +48,7 @@ import {
useThreadStream, useThreadStream,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -291,16 +292,11 @@ export default function IframeChatPage() {
threadMetadataAgentId, threadMetadataAgentId,
]); ]);
const writingArtifactPath = useMemo(() => { const writingArtifactPath = useMemo(
const isMarkdown = (path: string | null | undefined) => () =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path); resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
if (isMarkdown(selectedArtifact)) { [selectedArtifact, thread.values.artifacts],
return selectedArtifact ?? undefined; );
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
( (
+8 -10
View File
@@ -16,6 +16,7 @@ import {
readHideHeaderParam, readHideHeaderParam,
readHideChromeParam, readHideChromeParam,
} from "@/core/embed/embed-session"; } from "@/core/embed/embed-session";
import { isHostNestedSurfacePath } from "@/core/page-layout/host-nested-surface";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu"; import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
@@ -91,23 +92,20 @@ function PageLayout() {
location.pathname.startsWith("/page/workspace/chats/") location.pathname.startsWith("/page/workspace/chats/")
); );
}, [location.pathname, location.search]); }, [location.pathname, location.search]);
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页, // 深链嵌套页(单智能体问答 / 多智能体会商 / 大屏绘制)只给宿主 iframe 套用,
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false // 路径上就不带全局 Header / PageSidebar,不靠改写共用 embed.session。
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** const hostNestedSurface = isHostNestedSurfacePath(location.pathname);
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent =
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
const pageSidebarAllowed = const pageSidebarAllowed =
(forceChromeForDashboardAgent || chromeByPreference) && chromeByPreference &&
!isChatsIframeWorkspacePath(location.pathname) && !isChatsIframeWorkspacePath(location.pathname) &&
!embedActive && !embedActive &&
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。 !chromeHidden &&
!chromeHidden; !hostNestedSurface;
const compactToolboxAllowed = const compactToolboxAllowed =
!chromeByPreference && !chromeByPreference &&
!forceChromeForDashboardAgent &&
!embedActive && !embedActive &&
!chromeHidden && !chromeHidden &&
!hostNestedSurface &&
!isChatsIframeWorkspacePath(location.pathname); !isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100% // h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
@@ -0,0 +1,209 @@
"use client";
/**
* 「对话历史」页 —— 深度研究 Tab。
*
* 数据来自 deep-research 会话列表(useResearchSessions)。点击跳转深度研究页并
* 带 ``?session=<id>``,由 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<string | null>(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 (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的研究记录" : "暂无研究记录"}
</div>
);
}
return (
<>
<div>
{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 (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={Search} label="深度研究" />}
title={desensitize(session.title || session.query || "未命名研究")}
meta={
<>
<HistoryStatusPill
label={status.label}
className={status.className}
/>
{session.sourceCount > 0 && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{session.sourceCount} 条来源</span>
</>
)}
<span className="text-muted-foreground/60">·</span>
<span className="max-w-56 truncate" title={`模板:${template}`}>
模板:{template}
</span>
{timestamp && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(timestamp)}</span>
</>
)}
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title || session.query || "");
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名研究"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -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<typeof progressOf>;
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() ? (
<mark key={index} className="rounded-xs bg-red-100 px-0.5 font-semibold text-red-900">
{part}
</mark>
) : (
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 (
<div
id={`conversation-item-${draft.id}`}
onClick={(event) => {
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"
>
<div className="flex min-w-0 flex-1 items-start gap-3.5 pr-4 sm:items-center">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border !border-red-200 bg-red-50/90 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<span className="relative flex size-1.5">
{status.tone === "active" && (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
)}
<span className="relative inline-flex size-1.5 rounded-full bg-red-600" />
</span>
<Users2 className="size-3 text-red-600" />
会商
</span>
<h3 className="truncate text-sm font-semibold text-neutral-900 transition-colors group-hover:text-red-700">
{renderHighlightedTitle(desensitize(draft.title || "未命名会商"), searchQuery)}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 pt-0.5 text-[11px] text-neutral-400">
<span className={`inline-flex items-center gap-1 rounded border !border-red-200 px-1.5 py-0.5 text-[10px] font-medium ${statusClass}`}>
<span className={`size-1.5 rounded-full ${statusDotClass}`} />
{status.label}
</span>
<span className="inline-flex items-center rounded border border-neutral-200/60 bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-600">
{status.stepLabel}
</span>
<span className="inline-flex items-center gap-1 rounded border border-neutral-200/40 bg-neutral-50 px-1.5 py-0.5 text-[10px] text-neutral-500">
<Cpu className="size-2.5 text-neutral-400" />
{item.businessChainName}
</span>
<span className="inline-flex items-center gap-1 text-neutral-400">
<Clock className="size-3 text-neutral-400" />
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="hidden items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 sm:flex">
<button
id={`delete-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
title="删除会商"
aria-label="删除会商"
>
<Trash2 className="size-3.5" />
</button>
<button
id={`open-preview-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-700"
title="打开会商详情"
aria-label="打开会商详情"
>
<ExternalLink className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
export function RoundtableHistoryTab({
onTotalChange,
}: {
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { drafts, loading, refresh, remove } = useRoundtableDrafts();
const [records, setRecords] = useState<Record<string, DraftRecord>>({});
const [searchQuery, setSearchQuery] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [sortType, setSortType] = useState<SortType>("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<RoundtableItem[]>(
() =>
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 (
<div className="w-full space-y-4">
<div className="w-full space-y-3.5">
<div className="flex flex-col items-stretch justify-between gap-3 md:flex-row md:items-center">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
<button
id="filter-all-btn"
type="button"
onClick={() => setSelectedTag(null)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all ${
!selectedTag
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700"
}`}
>
<span>全部</span>
<span className={`rounded-full px-1.5 py-0.2 font-mono text-[11px] ${!selectedTag ? "bg-red-700 font-semibold text-white" : "bg-neutral-100 text-neutral-500"}`}>
{allItems.length}
</span>
</button>
{selectedTag && (
<div className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs">
<Tag className="size-3 text-red-100" />
<span>{selectedTag}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className="ml-1 hover:text-red-200"
title="清除标签筛选"
aria-label="清除标签筛选"
>
<X className="size-3" />
</button>
</div>
)}
</div>
<div className="relative flex-1 md:max-w-md">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<input
id="conversation-search-input"
type="search"
value={searchQuery}
onChange={(event) => 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 ? (
<button
id="clear-search-query-btn"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600"
aria-label="清空搜索"
>
<X className="size-3.5" />
</button>
) : (
<div className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded bg-neutral-200/60 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 sm:flex">
⌘K
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
<div className="flex items-center gap-3 overflow-x-auto">
<span className="whitespace-nowrap font-medium text-neutral-500">
共 <strong className="font-bold text-red-600">{filteredItems.length}</strong> 条会商记录
{searchQuery && <span className="ml-1 text-neutral-400">(匹配 “{searchQuery}”)</span>}
</span>
{availableTags.length > 0 && (
<div className="hidden items-center gap-1.5 text-xs lg:flex">
<span className="text-neutral-400">快捷标签:</span>
{availableTags.slice(0, 4).map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded-md px-2 py-0.5 transition-colors ${
selectedTag === tag
? "bg-red-600 font-medium text-white shadow-2xs"
: "border !border-red-200 bg-red-50 text-red-700 hover:bg-red-100 hover:text-red-800"
}`}
>
#{tag}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2 py-1 shadow-2xs">
<ArrowUpDown className="size-3 text-neutral-400" />
<select
id="sort-select-dropdown"
value={sortType}
onChange={(event) => setSortType(event.target.value as SortType)}
className="cursor-pointer bg-transparent text-xs text-neutral-700 focus:outline-hidden"
>
<option value="updated_desc">最近活跃优先</option>
<option value="updated_asc">最早创建优先</option>
<option value="messages_desc">消息数量最多</option>
</select>
</div>
</div>
</div>
</div>
{loading && allItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">加载中…</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{searchQuery || selectedTag ? "没有匹配的会商记录" : "暂无会商记录"}
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<RoundtableHistoryRow
key={item.draft.id}
item={item}
searchQuery={searchQuery}
onDelete={() => void deleteDraft(item.draft)}
onOpen={() => openDraft(item.draft)}
/>
))}
</div>
)}
</div>
);
}
@@ -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<string | null>(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 (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的写作记录" : "暂无写作记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={FilePenLine} label="AI写作" />}
title={desensitize(session.title)}
meta={
<>
<HistoryStatusPill label={label} className={color} />
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(session.updated_at)}</span>
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title);
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名写作"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -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 (
<span className={cn("text-[10px] font-medium", className)}>{label}</span>
);
}
/** 历史卡片左上角的类型标签,与对话卡片的「对话」标签保持同一视觉层级。 */
export function HistoryTypeBadge({
icon: Icon,
label,
}: {
icon: LucideIcon;
label: string;
}) {
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<Icon className="size-3 text-red-600" />
{label}
</span>
);
}
/**
* 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转
* 对应工作台,由页面挂载时消费),操作按钮经 children 传入。
*/
export function HistoryRow({
badge,
title,
meta,
onOpen,
children,
}: {
badge?: ReactNode;
title: ReactNode;
meta: ReactNode;
onOpen: () => void;
children?: ReactNode;
}) {
return (
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs">
<button
type="button"
onClick={onOpen}
className="block w-full p-3 pr-28 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:p-3.5 sm:pr-32"
>
<div className="flex min-w-0 items-center gap-2">
{badge}
<span className="min-w-0 flex-1 truncate text-sm font-semibold transition-colors group-hover/chat-row:text-red-700 sm:text-base">
{title}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{meta}
</div>
</button>
{/* Row actions — kept out of the open button so clicks don't trigger it. */}
<div className="absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded-lg bg-white/90 opacity-100 shadow-sm backdrop-blur-sm transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
{children}
</div>
</div>
);
}
/** 重命名 / 删除图标操作按钮(与「对话」Tab 的收藏/置顶按钮同规格)。 */
export function HistoryRowIconButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className="size-8"
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</Button>
);
}
export function RenameIcon() {
return <Pencil className="text-muted-foreground size-4" />;
}
export function DeleteIcon() {
return <Trash2 className="text-muted-foreground size-4" />;
}
/**
* 自包含重命名弹窗:打开时用 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 (
<Dialog open={open} onOpenChange={(o) => (o ? undefined : onCancel())}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
if (value.trim()) onConfirm(value.trim());
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button disabled={!value.trim()} onClick={() => onConfirm(value.trim())}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -20,6 +20,7 @@
*/ */
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { Select as TSelect } from "tdesign-react"; import { Select as TSelect } from "tdesign-react";
@@ -444,7 +445,12 @@ export default function DeepResearchWorkbench() {
} }
function DeepResearchWorkbenchInner() { function DeepResearchWorkbenchInner() {
const [selectedId, setSelectedId] = useState<string | null>(null); // 「?session=<id>」深链:对话历史页「深度研究」Tab 等外部入口直接定位到某条
// 研究记录。仅作初始选中(首帧读取一次);之后的切换仍走页内状态,不回写 URL。
const [searchParams] = useSearchParams();
const [selectedId, setSelectedId] = useState<string | null>(() =>
searchParams.get("session"),
);
const [mode, setMode] = useState<ResearchMode>("basic"); const [mode, setMode] = useState<ResearchMode>("basic");
const [generateImages, setGenerateImages] = useState(false); const [generateImages, setGenerateImages] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS); const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
@@ -28,8 +28,9 @@ import type { JobStatus } from "@/roundtable-planning/lib/job-types";
export interface DraftStep1Snapshot { export interface DraftStep1Snapshot {
intentThreadId: string | null; intentThreadId: string | null;
intentReady: IntentSummary | null; intentReady: IntentSummary | null;
interactiveMessages: unknown[]; /** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底。 */
timelineEntries: unknown[]; interactiveMessages?: unknown[];
timelineEntries?: unknown[];
} }
export interface DraftStep2Snapshot { export interface DraftStep2Snapshot {
@@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report"; import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
export type ThreadIdMap = Record<string, string>; export type ThreadIdMap = Record<string, string>;
@@ -254,8 +255,7 @@ export interface StreamMultiAgentRequest {
*/ */
synthesisMode?: boolean; synthesisMode?: boolean;
/** /**
* 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage * 仅为兼容旧协议保留。总控 prompt 属于总控私有上下文,前端恒传 true,后端也会无条件隔离。
* 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。
*/ */
suppressPreBroadcast?: boolean; suppressPreBroadcast?: boolean;
/** /**
@@ -291,12 +291,14 @@ export interface StreamMultiAgentRequest {
* show progressive status messages during the inevitable first-token wait. * show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise). * `name` is the tool name when phase is `tool_calling` (or empty otherwise).
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple * `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。 * 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。
* `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。
*/ */
onPhaseChange?: ( onPhaseChange?: (
phase: StreamPhase, phase: StreamPhase,
name?: string, name?: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
toolCallId?: string,
) => void; ) => void;
/** /**
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个 * 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
@@ -468,7 +470,9 @@ function isFinalStatusFrame(value: unknown): value is {
* structured content blocks. Returns null when the frame is not an AI text * structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots). * 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; if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0]; const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null; 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<string, unknown>).reasoning_content ??
(additionalKwargs as Record<string, unknown>).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 : ""; 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 ?? [], skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false, synthesis_mode: req.synthesisMode ?? false,
// 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。 // 总控 prompt 永不进入席位 thread;后端同样强制执行,避免旧调用方破坏角色隔离。
suppress_pre_broadcast: req.suppressPreBroadcast ?? false, suppress_pre_broadcast: req.suppressPreBroadcast ?? true,
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。 // 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
parallel_no_file: req.parallelNoFile ?? false, parallel_no_file: req.parallelNoFile ?? false,
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。 // 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
@@ -620,27 +633,36 @@ export async function streamMultiAgent(
let currentPhase: StreamPhase = "connecting"; let currentPhase: StreamPhase = "connecting";
let currentPhaseName = ""; let currentPhaseName = "";
let currentPhaseArgsSignature = ""; let currentPhaseArgsSignature = "";
let currentPhaseToolCallId = "";
req.onPhaseChange?.("connecting"); 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
// `<think>` renderer remains the single display path for Steps 2 and 3.
const inlineAiDelta = createInlineReasoningDeltaFormatter();
const setPhase = ( const setPhase = (
next: StreamPhase, next: StreamPhase,
name?: string, name?: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
toolCallId?: string,
) => { ) => {
const nextName = name ?? ""; const nextName = name ?? "";
const nextArgsSignature = phaseArgsSignature(args); const nextArgsSignature = phaseArgsSignature(args);
const nextToolCallId = toolCallId ?? "";
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths // 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。 // 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
if ( if (
next === currentPhase && next === currentPhase &&
nextName === currentPhaseName && nextName === currentPhaseName &&
nextArgsSignature === currentPhaseArgsSignature nextArgsSignature === currentPhaseArgsSignature &&
nextToolCallId === currentPhaseToolCallId
) { ) {
return; return;
} }
currentPhase = next; currentPhase = next;
currentPhaseName = nextName; currentPhaseName = nextName;
currentPhaseArgsSignature = nextArgsSignature; 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 toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks; const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content; const content = chunkObj.content;
const streamDelta = extractAiTextDelta(parsed);
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。 // 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。 // 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
@@ -812,27 +835,34 @@ export async function streamMultiAgent(
if (chunkType === "tool") { if (chunkType === "tool") {
setPhase("tool_result"); setPhase("tool_result");
} else if (hasToolCall) { } 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_calls(含可读 args 对象);若都还不到火候,
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。 // 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
const firstCall = const activeCall =
effectiveToolCalls && effectiveToolCalls.length > 0 effectiveToolCalls && effectiveToolCalls.length > 0
? (effectiveToolCalls[0] as Record<string, unknown>) ? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0 : Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>) ? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
: null; : null;
const toolName = const toolName =
firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; activeCall && typeof activeCall.name === "string" ? activeCall.name : "";
const args = const args =
firstCall && activeCall &&
typeof firstCall.args === "object" && typeof activeCall.args === "object" &&
firstCall.args !== null activeCall.args !== null
? (firstCall.args as Record<string, unknown>) ? (activeCall.args as Record<string, unknown>)
: undefined; : undefined;
setPhase("tool_calling", toolName, args); const toolCallId =
activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined;
setPhase("tool_calling", toolName, args, toolCallId);
} else if ( } else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && (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"); setPhase("streaming");
} }
@@ -900,8 +930,9 @@ export async function streamMultiAgent(
const delta = extractAiTextDelta(parsed); const delta = extractAiTextDelta(parsed);
if (delta) { if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`); const text = inlineAiDelta(delta);
req.onTextDelta?.(delta.text, delta.id); if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
if (text) req.onTextDelta?.(text, delta.id);
} }
req.onUpstreamFrame?.(parsed); req.onUpstreamFrame?.(parsed);
@@ -9,7 +9,7 @@
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致), * 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。 * agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/ */
import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ArrowRightIcon, ArrowRightIcon,
ClockIcon, ClockIcon,
@@ -593,8 +593,9 @@ function ChainSeatsFlow({
return ( return (
<div className="mt-3.5 flex flex-wrap items-center gap-1.5"> <div className="mt-3.5 flex flex-wrap items-center gap-1.5">
{/* 同 DispatchChainModal:div.contents 代替 <Fragment>,规避 dev tagger 注入 data-lov-id 的警告。 */}
{stages.map((stageIds, stageIdx) => ( {stages.map((stageIds, stageIdx) => (
<Fragment key={stageIdx}> <div className="contents" key={stageIdx}>
{stageIdx > 0 && ( {stageIdx > 0 && (
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" /> <ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
)} )}
@@ -609,7 +610,7 @@ function ChainSeatsFlow({
) : ( ) : (
seatTag(stageIds[0]) seatTag(stageIds[0])
)} )}
</Fragment> </div>
))} ))}
</div> </div>
); );
@@ -6,7 +6,7 @@
* 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用 * 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用
* `stripReportFence` 替换成一句提示。 * `stripReportFence` 替换成一句提示。
*/ */
import { useMemo, useRef, useState, type FormEvent } from "react"; import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { import {
CheckCircle, CheckCircle,
Loader2, Loader2,
@@ -64,7 +64,19 @@ export function DashboardChatPanel({
}: DashboardChatPanelProps) { }: DashboardChatPanelProps) {
const inputLocked = phase === "creating"; const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const contentRef = useRef<HTMLDivElement>(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 [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues); const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
@@ -156,7 +168,8 @@ export function DashboardChatPanel({
{/* 聊天记录 */} {/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white"> <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white">
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -209,6 +222,7 @@ export function DashboardChatPanel({
)} )}
</div> </div>
)} )}
</div>
</div> </div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />} {!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
@@ -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" 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 && (
<button
type="button"
onClick={onPause}
title="中止当前绘制(之后可输入修改要求继续)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
<button <button
type="submit" type="submit"
disabled={inputLocked || text.trim().length === 0} disabled={inputLocked || text.trim().length === 0}
@@ -14,7 +14,7 @@
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段 * (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。 * (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/ */
import { Fragment, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -229,9 +229,10 @@ export function DispatchChainModal({
state={coordinatorState} state={coordinatorState}
/> />
{/* 席位节点 */} {/* 席位节点(div.contents 而非 <Fragment>:dev 期 tagger 会往具名 Fragment 注入
data-lov-id 触发 React 警告,contents 不生成盒子、布局不变)。 */}
{nodes.map((node) => ( {nodes.map((node) => (
<Fragment key={node.agentId}> <div className="contents" key={node.agentId}>
<Connector <Connector
active={node.state === "active"} active={node.state === "active"}
done={node.state === "done"} done={node.state === "done"}
@@ -242,7 +243,7 @@ export function DispatchChainModal({
state={node.state} state={node.state}
order={node.order} order={node.order}
/> />
</Fragment> </div>
))} ))}
{/* 共识里程碑 */} {/* 共识里程碑 */}
@@ -61,6 +61,8 @@ interface SeatDelivery {
interface HighFidelityReportProps { interface HighFidelityReportProps {
useStep1Chrome?: boolean; useStep1Chrome?: boolean;
headerTitle?: string; headerTitle?: string;
/** `report-only` is used by 8BF; normal roundtables retain every Step 3 view. */
step3Mode?: "full" | "report-only";
threadIds: ThreadIdMap | null; threadIds: ThreadIdMap | null;
coordinatorName: string | null; coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */ /** The most recent leader-agent text — rendered as the final consensus. */
@@ -121,9 +123,26 @@ function formatGeneratedAt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} }
/** 最终成果区打开的是已落盘文件,不走实时流;独立给工具栏一个稳定的文件类型徽标。 */
function artifactTypeLabel(fileName: string): string {
const normalized = fileName.trim().toLowerCase();
if (/\.(md|markdown)$/.test(normalized)) return "MD";
if (/\.html?$/.test(normalized)) return "HTML";
if (/\.(mmd|mermaid)$/.test(normalized)) return "MMD";
if (/\.json$/.test(normalized)) return "JSON";
if (/\.csv$/.test(normalized)) return "CSV";
if (/\.ya?ml$/.test(normalized)) return "YAML";
if (/\.tsx?$/.test(normalized)) return "TS";
if (/\.js$/.test(normalized)) return "JS";
if (/\.py$/.test(normalized)) return "PY";
if (/\.txt$/.test(normalized)) return "TXT";
return "FILE";
}
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
useStep1Chrome = false, useStep1Chrome = false,
headerTitle, headerTitle,
step3Mode = "full",
threadIds, threadIds,
coordinatorName, coordinatorName,
consensusText, consensusText,
@@ -156,7 +175,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
return `圆桌总控结论-${short}`; return `圆桌总控结论-${short}`;
}, [intentReady?.objective]); }, [intentReady?.objective]);
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。 const hasFullStep3Outputs = step3Mode === "full";
// 第 3 步主页为总结报告。普通会商可继续打开最终方案、经典可视化和大屏多页;
// 8BF 明确停在报告工作台,不启动或露出这些后续交付。
const [summaryMode, setSummaryMode] = useState(true); const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false); const [drawMode, setDrawMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起 // 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
@@ -398,6 +419,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
const fileSandboxVirtualUrl = fileSandboxTarget const fileSandboxVirtualUrl = fileSandboxTarget
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0` ? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
: ""; : "";
const fileSandboxTypeLabel = fileSandboxTarget
? artifactTypeLabel(fileSandboxTarget.file.name)
: "FILE";
const fileSandboxThreadStub = useMemo(() => { const fileSandboxThreadStub = useMemo(() => {
const messages = const messages =
fileSandboxTarget && fileSandboxContent != null fileSandboxTarget && fileSandboxContent != null
@@ -414,6 +438,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
}, },
], ],
}, },
// 最终产物仅借用 write-file 虚拟 URL 来复用预览器;内容已经通过下载接口完整读回,
// 不是流式写入。因此要同时提供 tool result,避免 ArtifactFileDetail 误显示「正在生成」。
{
id: "hfr-file-tool-result",
type: "tool",
content: "",
tool_call_id: "tc-hfr-0",
},
] ]
: []; : [];
return { return {
@@ -434,7 +466,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[], [],
); );
if (summaryMode) { if (summaryMode || !hasFullStep3Outputs) {
return ( return (
<Step3SummaryMode <Step3SummaryMode
useStep1Chrome={useStep1Chrome} useStep1Chrome={useStep1Chrome}
@@ -448,16 +480,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
intentReady={intentReady} intentReady={intentReady}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt} coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary} savedSummary={savedSummary}
summary={summaryFlow} summary={summaryFlow}
enableFlow={hasFullStep3Outputs}
onViewStatic={hasFullStep3Outputs ? () => setSummaryMode(false) : undefined}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
/> />
); );
} }
if (drawMode) { if (drawMode && hasFullStep3Outputs) {
return ( return (
<Step3ReportMode <Step3ReportMode
threadIds={threadIds} threadIds={threadIds}
@@ -1043,21 +1078,26 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
allowVirtualMarkdownEdit allowVirtualMarkdownEdit
headerLeadingActionBare headerLeadingActionBare
headerLeadingAction={( headerLeadingAction={(
<TaskArtifactSubmitButton <div className="flex items-center gap-1.5">
compact <span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
controller={taskArtifactSubmission} {fileSandboxTypeLabel}
target={taskArtifactSubmission?.targetFor({ </span>
threadId: fileSandboxTarget.threadId, <TaskArtifactSubmitButton
path: fileSandboxTarget.file.path, compact
name: fileSandboxTarget.file.name, controller={taskArtifactSubmission}
mimeType: fileSandboxTarget.file.mimeType, target={taskArtifactSubmission?.targetFor({
}) ?? { threadId: fileSandboxTarget.threadId,
threadId: fileSandboxTarget.threadId, path: fileSandboxTarget.file.path,
path: fileSandboxTarget.file.path, name: fileSandboxTarget.file.name,
name: fileSandboxTarget.file.name, mimeType: fileSandboxTarget.file.mimeType,
mimeType: fileSandboxTarget.file.mimeType, }) ?? {
}} threadId: fileSandboxTarget.threadId,
/> path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}}
/>
</div>
)} )}
/> />
</ThreadContext.Provider> </ThreadContext.Provider>
@@ -22,6 +22,7 @@ import {
ChainOfThoughtStep, ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought"; } from "@/components/ai-elements/chain-of-thought";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
getStepDisplay, getStepDisplay,
@@ -177,13 +178,16 @@ function ToolStep({
const display = getStepDisplay(step.key, step.label, step.args); const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args, resolveAgentName); const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":"); const [, toolName] = step.key.split(":");
// 只让「自己生成/展示的文件」可点(write_file / str_replace / present_files), // 步骤条是工具活动的实时视图:所有 write_file 都应能打开沙箱,包括为后续执行
// 与主聊天页一致。read_file 不可点 —— 否则智能体调用技能时读取的 SOUL.md、 // 而创建的 .py/.txt 等过程文件。消息气泡下的最终文件卡片才收紧为用户可交付类型。
// 执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。 // read_file 仍不可点,避免技能/SOUL 等输入文件被误当作产物。
// str_replace 沿用原有的文档预览范围(它没有完整写入内容可供虚拟沙箱读取)。
const stepPath = typeof step.args?.path === "string" ? step.args.path : "";
const isArtifactStep = const isArtifactStep =
toolName === "present_files" ||
toolName === "write_file" || toolName === "write_file" ||
toolName === "str_replace" || (toolName === "str_replace" &&
toolName === "present_files"; collectRoundtableDeliverablePaths([stepPath]).length > 0);
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -130,6 +130,11 @@ export interface Step1PanelProps {
footerAction?: React.ReactNode; footerAction?: React.ReactNode;
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */ /** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
hideEnterRoundtable?: boolean; hideEnterRoundtable?: boolean;
/**
* 隐藏「继续追问」「跳过此项」两个快捷动作(多智能体会商页不需要;
* 岗位会商 / 8BF 不传保持原样)。
*/
hideFollowupActions?: boolean;
/** /**
* 仅替换输入区的渲染插槽。消息列表、意图状态及附件状态仍由 Step1Panel * 仅替换输入区的渲染插槽。消息列表、意图状态及附件状态仍由 Step1Panel
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。 * 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
@@ -171,6 +176,7 @@ export function Step1Panel(props: Step1PanelProps) {
enterButtonLabel = "进入多智能体研讨 >", enterButtonLabel = "进入多智能体研讨 >",
footerAction, footerAction,
hideEnterRoundtable = false, hideEnterRoundtable = false,
hideFollowupActions = false,
inputArea, inputArea,
inputDraftKey, inputDraftKey,
initialInputText = "", initialInputText = "",
@@ -425,23 +431,27 @@ export function Step1Panel(props: Step1PanelProps) {
</button> </button>
)} )}
<button {!hideFollowupActions && (
id="suggest-followup-btn" <button
onClick={onFollowUp} id="suggest-followup-btn"
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition" onClick={onFollowUp}
> className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
<Repeat className="w-3 h-3 text-slate-500" /> >
继续追问 <Repeat className="w-3 h-3 text-slate-500" />
</button> 继续追问
</button>
)}
<button {!hideFollowupActions && (
id="skip-question-btn" <button
onClick={onSkipQuestion} id="skip-question-btn"
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition" onClick={onSkipQuestion}
> className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} /> >
跳过此项 <X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
</button> 跳过此项
</button>
)}
{footerAction ?? {footerAction ??
(!hideEnterRoundtable && ( (!hideEnterRoundtable && (
@@ -43,6 +43,7 @@ const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics"; import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -221,8 +222,42 @@ export function Step2Panel(props: Step2PanelProps) {
} = props; } = props;
const step2ScrollRef = useRef<HTMLDivElement>(null); const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = // contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); // 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。
const step2ContentRef = useRef<HTMLDivElement>(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<number | null>(latestUserDialogueId);
// 用户消息入列后立即定位到底部,不等待后续模型流式消息触发滚动。
useEffect(() => {
if (
latestUserDialogueId === null ||
latestUserDialogueId === previousUserDialogueIdRef.current
) {
return;
}
previousUserDialogueIdRef.current = latestUserDialogueId;
const frameId = window.requestAnimationFrame(forceStep2ToBottom);
return () => window.cancelAnimationFrame(frameId);
}, [forceStep2ToBottom, latestUserDialogueId]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。 // 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
@@ -264,8 +299,8 @@ export function Step2Panel(props: Step2PanelProps) {
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
useEffect(() => { useEffect(() => {
scrollStep2ToBottom(); forceStep2ToBottom();
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]); }, [forceStep2ToBottom, isParallelRunning, selectedParallelSeats]);
const resolveAgentName = useCallback( const resolveAgentName = useCallback(
(idOrName: string) => (idOrName: string) =>
@@ -601,14 +636,15 @@ export function Step2Panel(props: Step2PanelProps) {
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内 // 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。 // 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
className={cn( 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 useStep1InputAppearance
? "eightbf-roundtable-step2-log" ? "eightbf-roundtable-step2-log"
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner", : "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
)} )}
> >
<div ref={step2ContentRef} className="flex min-h-full flex-col space-y-4 p-6">
{isInitializing && step2RoundtableDialogues.length === 0 && ( {isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500"> <div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div> <div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div> <div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
@@ -704,6 +740,7 @@ export function Step2Panel(props: Step2PanelProps) {
)} )}
</> </>
)} )}
</div>
</div> </div>
{!step2AtBottom && ( {!step2AtBottom && (
<ScrollToBottomButton <ScrollToBottomButton
@@ -848,6 +885,8 @@ export function Step2Panel(props: Step2PanelProps) {
onAddFiles={addInjectFiles} onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile} onRemoveFile={removeInjectFile}
onSend={handleInject} onSend={handleInject}
busy={isStep2Responding}
onPause={onPauseRoundtable}
hasConsensus={hasConsensus} hasConsensus={hasConsensus}
onProceed={onProceedToStep3} onProceed={onProceedToStep3}
/> />
@@ -888,6 +927,8 @@ function Step2LegacyComposer({
onAddFiles, onAddFiles,
onRemoveFile, onRemoveFile,
onSend, onSend,
busy,
onPause,
hasConsensus, hasConsensus,
onProceed, onProceed,
}: { }: {
@@ -897,6 +938,9 @@ function Step2LegacyComposer({
onAddFiles: (files: File[]) => void; onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void; onRemoveFile: (index: number) => void;
onSend: () => void; onSend: () => void;
/** 编排中或席位仍在流式输出 —— 此时显示输入框「暂停」键(与第一步输入框一致)。 */
busy: boolean;
onPause: () => void;
hasConsensus: boolean; hasConsensus: boolean;
onProceed: () => void; onProceed: () => void;
}) { }) {
@@ -958,14 +1002,19 @@ function Step2LegacyComposer({
> >
<Paperclip className="size-4" /> <Paperclip className="size-4" />
</button> </button>
<button {busy && (
type="button" <button
onClick={onSend} type="button"
title="记录干预指令并立即恢复会商执行" onClick={onPause}
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95" title="暂停当前会商编排,可稍后恢复"
> className="shrink-0 cursor-pointer rounded-xl bg-amber-100 px-3 py-2.5 text-xs font-bold text-amber-800 shadow-sm transition hover:bg-amber-200 active:scale-95"
干预并发送 >
</button> <span className="flex items-center gap-1.5">
<Pause className="size-3" />
暂停
</span>
</button>
)}
<button <button
type="button" type="button"
onClick={onProceed} onClick={onProceed}
@@ -1037,7 +1086,7 @@ export function Step2Message({
const rawContent = msg.content ?? ""; const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent); const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0; const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps); const presentedFiles = collectPresentedFiles(msg.steps, msg.artifactPaths);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0"); const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。 // 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
@@ -1136,36 +1185,48 @@ function RoundtableSubtaskCard({
/** /**
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。 * 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
* *
* 三类来源都算: * 三类来源:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个); * - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个)。后端
* present_files 已强制"必须在 /mnt/user-data/outputs 下且真实存在",与主聊天
* `chats/:threadId` 的产物面板同源,原样放行;
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files, * - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了 * 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 文件就出可点的卡片",而不是只有 present_files 才出。 * 成品文件就出可点的卡片"。
*
* write_file/str_replace 的 path 需过同一套交付物门槛(只认 .md/.html/
* .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套):
* 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。
*/ */
function collectPresentedFiles( function collectPresentedFiles(
steps: Step2Dialogue["steps"], steps: Step2Dialogue["steps"],
settledArtifactPaths?: string[],
): string[] { ): string[] {
if (!Array.isArray(steps) || steps.length === 0) return []; const toolSteps = Array.isArray(steps) ? steps : [];
if (toolSteps.length === 0 && (!settledArtifactPaths || settledArtifactPaths.length === 0)) return [];
const out: string[] = []; const out: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const add = (fp: unknown) => { const add = (fp: unknown, requirePreviewable = false) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return; if (typeof fp !== "string" || !fp || seen.has(fp)) return;
if (requirePreviewable && collectRoundtableDeliverablePaths([fp]).length === 0) return;
seen.add(fp); seen.add(fp);
out.push(fp); out.push(fp);
}; };
for (const step of steps) { for (const step of toolSteps) {
if (step.key === "tool_calling:present_files") { if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths; const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach(add); if (Array.isArray(fps)) fps.forEach((fp) => add(fp));
continue; continue;
} }
if ( if (
step.key === "tool_calling:write_file" || step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace" step.key === "tool_calling:str_replace"
) { ) {
add(step.args?.path); add(step.args?.path, true);
} }
} }
// A weak model may create a report through bash rather than present_files;
// the completed dialogue adds only real, user-facing document paths here.
settledArtifactPaths?.forEach((path) => add(path, true));
return out; return out;
} }
@@ -144,6 +144,25 @@ export function Step2SandboxLayout({
}, [artifacts, isStreaming, seatMessages]); }, [artifacts, isStreaming, seatMessages]);
const previewThreadId = activeThreadId ?? ""; const previewThreadId = activeThreadId ?? "";
const selectedArtifactTarget = useMemo(() => {
const target = {
threadId: previewThreadId,
path: selectedArtifact ?? "",
content: selectedArtifactContent,
};
return taskArtifactSubmission?.targetFor(target) ?? target;
}, [
previewThreadId,
selectedArtifact,
selectedArtifactContent,
taskArtifactSubmission,
]);
// 8BF 等接入任务过程产物提交的页面,用提交按钮替换文件类型;其余普通/任务深链
// 会商仍保留原有文件类型徽标。非 Markdown 产物本身不能提交,也应显示类型。
const showTaskArtifactSubmit = Boolean(
taskArtifactSubmission?.enabled
&& taskArtifactSubmission.isMarkdown(selectedArtifactTarget),
);
const artifactPanelBody = ( const artifactPanelBody = (
<div <div
@@ -162,18 +181,14 @@ export function Step2SandboxLayout({
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。 // 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize disableDesensitize
headerLeadingActionBare headerLeadingAction={showTaskArtifactSubmit ? (
headerLeadingAction={(
<TaskArtifactSubmitButton <TaskArtifactSubmitButton
compact compact
controller={taskArtifactSubmission} controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({ target={selectedArtifactTarget}
threadId: previewThreadId,
path: selectedArtifact,
content: selectedArtifactContent,
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
/> />
)} ) : undefined}
headerLeadingActionBare={showTaskArtifactSubmit}
/> />
) : ( ) : (
<div className="relative flex size-full justify-center"> <div className="relative flex size-full justify-center">
@@ -26,6 +26,8 @@ export interface Step3PanelProps {
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */ /** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
useStep1Chrome?: boolean; useStep1Chrome?: boolean;
headerTitle?: string; headerTitle?: string;
/** 普通会商提供完整交付;8BF 仅保留 Markdown 总结报告。 */
step3Mode?: "full" | "report-only";
threadIds: ThreadIdMap | null; threadIds: ThreadIdMap | null;
coordinatorName: string | null; coordinatorName: string | null;
consensusText: string; consensusText: string;
@@ -65,6 +67,7 @@ export function Step3Panel(props: Step3PanelProps) {
const { const {
useStep1Chrome = false, useStep1Chrome = false,
headerTitle, headerTitle,
step3Mode = "full",
threadIds, threadIds,
coordinatorName, coordinatorName,
consensusText, consensusText,
@@ -115,6 +118,7 @@ export function Step3Panel(props: Step3PanelProps) {
<HighFidelityReport <HighFidelityReport
useStep1Chrome={useStep1Chrome} useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle} headerTitle={headerTitle}
step3Mode={step3Mode}
threadIds={threadIds} threadIds={threadIds}
coordinatorName={coordinatorName} coordinatorName={coordinatorName}
consensusText={consensusText} consensusText={consensusText}
@@ -388,6 +388,7 @@ export function Step3ReportMode({
error={error} error={error}
themeMismatch={themeMismatch} themeMismatch={themeMismatch}
onRetry={() => redraw()} onRetry={() => redraw()}
onPause={pause}
onRevisionSubmit={interject} onRevisionSubmit={interject}
onArtifactStepClick={openArtifactFromStep} onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview} onArtifactPreview={openArtifactPreview}
@@ -513,6 +514,7 @@ function AgentProgressPanel({
error, error,
themeMismatch, themeMismatch,
onRetry, onRetry,
onPause,
onRevisionSubmit, onRevisionSubmit,
onArtifactStepClick, onArtifactStepClick,
onArtifactPreview, onArtifactPreview,
@@ -527,6 +529,8 @@ function AgentProgressPanel({
error: string | null; error: string | null;
themeMismatch: boolean; themeMismatch: boolean;
onRetry: () => void; onRetry: () => void;
/** 输入框「暂停」键 —— 绘制中显示,中断在途请求并把当前气泡收尾为「已暂停」。 */
onPause: () => void;
onRevisionSubmit: (revisionInstruction: string) => void; onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"]; onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"]; onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
@@ -541,7 +545,21 @@ function AgentProgressPanel({
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。 // 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating"; const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, phase],
{ contentRef },
);
// 新的绘制/修改气泡一出现就定位到最新进度;后续 Markdown、工具卡高度变化
// 由 contentRef 的 ResizeObserver 持续跟随。
const previousDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > previousDialogueCountRef.current) {
forceScrollToBottom();
}
previousDialogueCountRef.current = dialogues.length;
}, [dialogues.length, forceScrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -601,8 +619,9 @@ function AgentProgressPanel({
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white" className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
> >
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -674,6 +693,7 @@ function AgentProgressPanel({
</button> </button>
</div> </div>
)} )}
</div>
</div> </div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />} {!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form <form
@@ -700,6 +720,16 @@ function AgentProgressPanel({
} }
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" 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 && (
<button
type="button"
onClick={onPause}
title="暂停当前绘制(沙箱保留已生成内容,之后可输入修改要求继续)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
<button <button
type="submit" type="submit"
disabled={inputLocked || revisionText.trim().length === 0} disabled={inputLocked || revisionText.trim().length === 0}
@@ -14,6 +14,7 @@ import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { import {
ArrowRight,
CheckCircle, CheckCircle,
Download, Download,
Eye, Eye,
@@ -26,6 +27,7 @@ import {
RefreshCw, RefreshCw,
SendHorizontal, SendHorizontal,
Sparkles, Sparkles,
Workflow,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -42,6 +44,12 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -74,6 +82,7 @@ interface Step3SummaryModeProps {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
selectedModel: string; selectedModel: string;
availableModels: ReadonlyArray<ModelOption>; availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */ /** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
@@ -81,6 +90,10 @@ interface Step3SummaryModeProps {
savedSummary?: DraftStep3SummarySnapshot | null; savedSummary?: DraftStep3SummarySnapshot | null;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */ /** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn; summary: UseStep3SummaryReturn;
/** 普通会商保留流程图交付;8BF 为 false。 */
enableFlow?: boolean;
/** 普通会商进入完整的最终方案展示;8BF 不提供该入口。 */
onViewStatic?: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController; taskArtifactSubmission?: TaskArtifactSubmissionController;
} }
@@ -98,10 +111,13 @@ export function Step3SummaryMode({
intentReady, intentReady,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading,
setSelectedModel, setSelectedModel,
coordinatorPrompt, coordinatorPrompt,
savedSummary, savedSummary,
summary, summary,
enableFlow = false,
onViewStatic,
taskArtifactSubmission, taskArtifactSubmission,
}: Step3SummaryModeProps) { }: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容 void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -135,6 +151,36 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts(); const { open: artifactsOpen } = useArtifacts();
// 普通会商的结构化流程图:优先使用后端抽取+校验的结果,兼容旧草稿里 flow-json。
const flowGraph = useMemo(() => {
if (!enableFlow) return null;
if (summary.flowPhase === "done" && summary.flowGraph) {
return { graph: summary.flowGraph, streaming: false };
}
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
return null;
}
for (let index = dialogues.length - 1; index >= 0; index -= 1) {
const dialogue = dialogues[index];
if (dialogue.avatarType === "human") continue;
const graph = extractFlowGraph(dialogue.content ?? "");
if (graph && (dialogue.content ?? "").includes(FLOW_FENCE_OPEN)) {
return { graph, streaming: dialogue.streaming === true };
}
}
return null;
}, [dialogues, enableFlow, summary.flowGraph, summary.flowPhase]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!enableFlow || !flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 自动生成的图优先打开;若用户已在报告沙箱内,保留当前阅读位置并露出切换入口。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [artifactsOpen, enableFlow, flowGraph, summary.autoFlow]);
// 沙箱里已写出的 Markdown 总结报告。 // 沙箱里已写出的 Markdown 总结报告。
const mdArtifact = useMemo(() => { const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) { for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
@@ -156,16 +202,22 @@ export function Step3SummaryMode({
return null; return null;
}, [seatStubMessages]); }, [seatStubMessages]);
const handleAsk = (message: string, files?: File[]) => ask(message, files); const handleAsk = (message: string, files?: File[]) => {
if (enableFlow && detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染, // 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
// 保持与第二步一致的流式过程可见性。 // 保持与第二步一致的流式过程可见性。
const displayDialogues = dialogues; const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 // 右侧沙箱或流程图露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true), // 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。 // 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen; const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const prevRightPanelVisibleRef = useRef(false); const prevRightPanelVisibleRef = useRef(false);
useEffect(() => { useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) { if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
@@ -195,16 +247,23 @@ export function Step3SummaryMode({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const isFlowRunning =
enableFlow &&
(summary.flowPhase === "extracting" || summary.flowPhase === "validating");
const isStreaming = const isStreaming =
phase === "creating" || phase === "creating" ||
phase === "streaming" || phase === "streaming" ||
isFlowRunning ||
dialogues.some((d) => d.streaming === true); dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId] ? seatStubMessages[activeArtifactThreadId]
: undefined; : undefined;
const statusLabel = const statusLabel = isFlowRunning
phase === "creating" ? summary.flowPhase === "extracting"
? "结构化流程图抽取中…"
: "流程图校验 / 修复中…"
: phase === "creating"
? "正在初始化总结线程…" ? "正在初始化总结线程…"
: phase === "streaming" : phase === "streaming"
? "方案总结报告智能体撰写中…" ? "方案总结报告智能体撰写中…"
@@ -225,7 +284,7 @@ export function Step3SummaryMode({
<Sparkles className="w-5 h-5 text-primary" /> <Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3> <h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full"> <span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && ( {isStreaming && (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
)} )}
{phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />} {phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />}
@@ -237,12 +296,22 @@ export function Step3SummaryMode({
<button <button
type="button" type="button"
onClick={() => redraw()} onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"} disabled={busy}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition" className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
> >
<RefreshCw className="w-3.5 h-3.5 text-slate-500" /> <RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成 重新生成
</button> </button>
{onViewStatic && (
<button
type="button"
onClick={onViewStatic}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
最终方案展示
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -347,6 +416,24 @@ export function Step3SummaryMode({
isStreaming={isStreaming} isStreaming={isStreaming}
seatMessages={activeSeatMessages} seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
sidePanel={
enableFlow && flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
onChange={summary.setFlowGraph}
/>
) : undefined
}
> >
<AgentProgressPanel <AgentProgressPanel
useStep1Chrome={useStep1Chrome} useStep1Chrome={useStep1Chrome}
@@ -356,6 +443,11 @@ export function Step3SummaryMode({
error={error} error={error}
busy={busy} busy={busy}
mdAvailable={!!mdArtifact} mdAvailable={!!mdArtifact}
enableFlow={enableFlow}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingFlowClarification={summary.pendingFlowClarification}
onResolveFlowClarification={summary.resolveFlowClarification}
onRetry={redraw} onRetry={redraw}
onAsk={handleAsk} onAsk={handleAsk}
onPause={pause} onPause={pause}
@@ -363,11 +455,31 @@ export function Step3SummaryMode({
onArtifactPreview={openArtifactPreview} onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
/> />
</Step2SandboxLayout> </Step2SandboxLayout>
{enableFlow && flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section> </section>
</div> </div>
@@ -481,6 +593,11 @@ function AgentProgressPanel({
error, error,
busy, busy,
mdAvailable, mdAvailable,
enableFlow,
flowPhase,
onRegenerateFlow,
pendingFlowClarification,
onResolveFlowClarification,
onRetry, onRetry,
onAsk, onAsk,
onPause, onPause,
@@ -488,6 +605,7 @@ function AgentProgressPanel({
onArtifactPreview, onArtifactPreview,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading,
setSelectedModel, setSelectedModel,
taskArtifactSubmission, taskArtifactSubmission,
}: { }: {
@@ -499,6 +617,12 @@ function AgentProgressPanel({
busy: boolean; busy: boolean;
/** 沙箱里是否已有 Markdown 总结报告。 */ /** 沙箱里是否已有 Markdown 总结报告。 */
mdAvailable: boolean; mdAvailable: boolean;
/** 普通会商才会生成、展示和编辑流程图。 */
enableFlow: boolean;
flowPhase: UseStep3SummaryReturn["flowPhase"];
onRegenerateFlow: () => void;
pendingFlowClarification: string | null;
onResolveFlowClarification: UseStep3SummaryReturn["resolveFlowClarification"];
onRetry: () => void; onRetry: () => void;
onAsk: (message: string, files?: File[]) => void; onAsk: (message: string, files?: File[]) => void;
onPause: () => void; onPause: () => void;
@@ -506,21 +630,46 @@ function AgentProgressPanel({
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"]; onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string; selectedModel: string;
availableModels: ReadonlyArray<ModelOption>; availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController; taskArtifactSubmission?: TaskArtifactSubmissionController;
}) { }) {
// 进度面板独立根据 props 判断流程图阶段,不能引用外层 Step3SummaryMode 的局部变量。
const isFlowRunning =
enableFlow && (flowPhase === "extracting" || flowPhase === "validating");
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, flowPhase],
{ contentRef },
);
// 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底, // 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
// 让用户看到新阶段。仅在气泡**条数**增加时触发, // 让用户看到新阶段。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。 // 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length); const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => { useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) { if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom(); forceScrollToBottom();
} }
prevDialogueCountRef.current = dialogues.length; 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。 // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -598,10 +747,12 @@ function AgentProgressPanel({
id="step3-summary-model-select" id="step3-summary-model-select"
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || availableModels.length === 0} disabled={busy || modelsLoading || availableModels.length === 0}
title={ title={
availableModels.length === 0 modelsLoading
? "未在 config.yaml 中配置任何模型" ? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy : 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" 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.length === 0 && (
<option value="">未配置模型</option> <option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)} )}
{availableModels.map((m) => ( {availableModels.map((m) => (
<option key={m.name} value={m.name}> <option key={m.name} value={m.name}>
@@ -639,10 +790,11 @@ function AgentProgressPanel({
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn(
"size-full overflow-y-auto overflow-x-hidden p-4 space-y-4", "size-full overflow-y-auto overflow-x-hidden",
useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white", useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white",
)} )}
> >
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -663,10 +815,68 @@ function AgentProgressPanel({
/> />
))} ))}
{busy && ( {busy && !isFlowRunning && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500"> <div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" /> <span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"} {mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "正在撰写 Markdown 总结报告"}
</div>
)}
{enableFlow && (flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting"
? "报告已完成,正在抽取结构化流程图"
: "报告已完成,正在校验 / 修复流程图"}
<button
type="button"
onClick={onPause}
title="暂停结构化流程图抽取,已完成的报告会保留"
className="-mr-1 rounded-full border border-blue-200 bg-white px-1.5 py-0.5 text-[10px] font-bold text-blue-700 transition hover:bg-blue-100"
>
<Pause className="mr-0.5 inline-block h-3 w-3" />
暂停
</button>
</div>
)}
{enableFlow && flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">可切换模型后重新生成流程图。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{enableFlow && pendingFlowClarification && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="mb-2 font-bold">请确认下一步要做什么:</div>
<div className="flex flex-wrap gap-1.5">
{(
[
["regenerate_report", "重新生成报告"],
["modify_flow", "修改流程图"],
["regenerate_flow", "重新生成流程图"],
["qa", "只是提问"],
] as const
).map(([intent, label]) => (
<button
key={intent}
type="button"
onClick={() => onResolveFlowClarification(intent)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{label}
</button>
))}
</div>
</div> </div>
)} )}
@@ -696,10 +906,13 @@ function AgentProgressPanel({
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600"> <div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
{mdAvailable {mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。" ? enableFlow
? "已生成方案总结报告与流程图,右侧可预览 / 下载 .md 或切换流程图。"
: "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
: "已生成方案总结,可在下方继续修改报告或提问。"} : "已生成方案总结,可在下方继续修改报告或提问。"}
</div> </div>
)} )}
</div>
</div> </div>
{!atBottom && ( {!atBottom && (
<ScrollToBottomButton <ScrollToBottomButton
@@ -783,7 +996,9 @@ function AgentProgressPanel({
placeholder={ placeholder={
busy busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…" ? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)或提问" : enableFlow
? "继续对话:改报告、修改流程图或提问"
: "继续对话:改报告(如「执行路线写细些」)或提问"
} }
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" 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"
/> />
@@ -28,6 +28,8 @@ export function useAutoScroll<T extends HTMLElement>(
atBottom: boolean; atBottom: boolean;
/** Force-scroll to the bottom and resume auto-follow. */ /** Force-scroll to the bottom and resume auto-follow. */
scrollToBottom: () => void; scrollToBottom: () => void;
/** Immediately jump to the bottom and resume auto-follow (for phase transitions). */
forceScrollToBottom: () => void;
} { } {
const threshold = options.threshold ?? 32; const threshold = options.threshold ?? 32;
const contentRef = options.contentRef; const contentRef = options.contentRef;
@@ -51,16 +53,22 @@ export function useAutoScroll<T extends HTMLElement>(
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随 // - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随 // - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
const lastObservedScrollTopRef = useRef(0); const lastObservedScrollTopRef = useRef(0);
// 思考块 / Markdown 代码块完成重排时,浏览器可能在没有用户输入的情况下
// clamp scrollTop。记录高度后可把这种布局变化与真实向上滚动区分开。
const lastObservedScrollHeightRef = useRef(0);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
let prevScrollTop = el.scrollTop; let prevScrollTop = el.scrollTop;
lastObservedScrollTopRef.current = prevScrollTop; lastObservedScrollTopRef.current = prevScrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
const onScroll = () => { const onScroll = () => {
const currentScrollTop = el.scrollTop; const currentScrollTop = el.scrollTop;
const currentScrollHeight = el.scrollHeight;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold; const isAtBottom = distanceFromBottom <= threshold;
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
const programmaticTarget = programmaticScrollTargetRef.current; const programmaticTarget = programmaticScrollTargetRef.current;
if ( if (
@@ -71,6 +79,7 @@ export function useAutoScroll<T extends HTMLElement>(
programmaticScrollTargetRef.current = null; programmaticScrollTargetRef.current = null;
prevScrollTop = currentScrollTop; prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
// 程序滚动后通常落在底部,把 atBottom 维持 true。 // 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) { if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true; atBottomRef.current = true;
@@ -84,7 +93,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**, // 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。 // 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
if (currentScrollTop < prevScrollTop && atBottomRef.current) { if (currentScrollTop < prevScrollTop && atBottomRef.current && !layoutChanged) {
atBottomRef.current = false; atBottomRef.current = false;
setAtBottom(false); setAtBottom(false);
} else if (isAtBottom && !atBottomRef.current) { } else if (isAtBottom && !atBottomRef.current) {
@@ -97,6 +106,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。 // 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
prevScrollTop = currentScrollTop; prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
}; };
el.addEventListener("scroll", onScroll, { passive: true }); el.addEventListener("scroll", onScroll, { passive: true });
// 初次测量 // 初次测量
@@ -114,21 +124,25 @@ export function useAutoScroll<T extends HTMLElement>(
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const currentScrollTop = el.scrollTop; const currentScrollTop = el.scrollTop;
const currentScrollHeight = el.scrollHeight;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。 // 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1; const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
if (userScrolledUp && distanceFromBottom > threshold) { const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
if (userScrolledUp && !layoutChanged && distanceFromBottom > threshold) {
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。 // 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
if (atBottomRef.current) { if (atBottomRef.current) {
atBottomRef.current = false; atBottomRef.current = false;
setAtBottom(false); setAtBottom(false);
} }
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
return; return;
} }
if (!atBottomRef.current) { if (!atBottomRef.current) {
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。 // 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
lastObservedScrollTopRef.current = currentScrollTop; lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
return; return;
} }
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。 // 在底部 (或仅是内容刚增长) → 同步拉到新底部。
@@ -138,6 +152,7 @@ export function useAutoScroll<T extends HTMLElement>(
); );
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop; lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, deps);
@@ -163,6 +178,7 @@ export function useAutoScroll<T extends HTMLElement>(
); );
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop; lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
}); });
}); });
observer.observe(content); observer.observe(content);
@@ -183,9 +199,27 @@ export function useAutoScroll<T extends HTMLElement>(
el.scrollHeight - el.clientHeight, el.scrollHeight - el.clientHeight,
); );
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
lastObservedScrollHeightRef.current = el.scrollHeight;
atBottomRef.current = true; atBottomRef.current = true;
setAtBottom(true); setAtBottom(true);
}, [ref]); }, [ref]);
return { atBottom, scrollToBottom }; // 阶段切换/用户刚发送消息时不能依赖 smooth 动画:下一帧可能还会插入校验卡、
// Markdown 或工具步骤,动画目标会停在旧高度。这里直接写入 scrollTop,由内容
// ResizeObserver 继续接管后续增高,保证关键阶段始终落在最新底部。
const forceScrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
programmaticScrollTargetRef.current = Math.max(
0,
el.scrollHeight - el.clientHeight,
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
return { atBottom, scrollToBottom, forceScrollToBottom };
} }
@@ -283,8 +283,8 @@ export function useDraftPersistence(
/** 当前会话是否有值得保存的内容。 */ /** 当前会话是否有值得保存的内容。 */
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => { const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => {
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user"); const hasUserMessage = step1.interactiveMessages?.some((m) => m.sender === "user") ?? false;
return hasUserMessage || step2.step2RoundtableDialogues.length > 0; return hasUserMessage || (step2.step2RoundtableDialogues?.length ?? 0) > 0;
}, []); }, []);
/** /**
@@ -32,6 +32,7 @@ import {
} from "@/roundtable-planning/components/IntentClarificationCard"; } from "@/roundtable-planning/components/IntentClarificationCard";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import { import {
normalizeIntentReadySummary,
nowTime, nowTime,
splitIntentReadyBlock, splitIntentReadyBlock,
} from "@/roundtable-planning/lib/roundtable-constants"; } from "@/roundtable-planning/lib/roundtable-constants";
@@ -82,8 +83,9 @@ export type TimelineEntry = {
export type Step1Snapshot = { export type Step1Snapshot = {
intentThreadId: string | null; intentThreadId: string | null;
intentReady: IntentSummary | null; intentReady: IntentSummary | null;
interactiveMessages: InteractiveMessage[]; /** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底,避免 undefined 流进渲染层。 */
timelineEntries: TimelineEntry[]; interactiveMessages?: InteractiveMessage[];
timelineEntries?: TimelineEntry[];
}; };
export interface UseStep1IntentOptions { export interface UseStep1IntentOptions {
@@ -699,11 +701,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setPendingIntentClarificationMsgId(null); setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map()); setIntentClarificationSelections(new Map());
setIntentThreadId(snapshot.intentThreadId); setIntentThreadId(snapshot.intentThreadId);
setIntentReady(snapshot.intentReady); // 旧草稿存的 summary 可能缺 constraints/assumptions(实时流两条入口都过
// normalizeIntentReadySummary,唯独这里直接落 state)—— 先归一化再恢复,
// 保证下游 IntentSummaryCard 等消费者拿到的一定是数组。
setIntentReady(normalizeIntentReadySummary(snapshot.intentReady));
setIntentInitError(null); setIntentInitError(null);
setIntentInitErrorDetail(null); setIntentInitErrorDetail(null);
setInteractiveMessages(snapshot.interactiveMessages); setInteractiveMessages(snapshot.interactiveMessages ?? []);
setTimelineEntries(snapshot.timelineEntries); setTimelineEntries(snapshot.timelineEntries ?? []);
step1InitRef.current = true; step1InitRef.current = true;
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。 // 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
step1InitGenRef.current += 1; step1InitGenRef.current += 1;
@@ -16,9 +16,13 @@
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts"; import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
import { import {
initMultiAgent, initMultiAgent,
listSessionArtifacts,
streamMultiAgent, streamMultiAgent,
type ModelSwitchInfo, type ModelSwitchInfo,
type ClarificationPayload, type ClarificationPayload,
@@ -29,6 +33,13 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning"; import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning";
import { uploadFiles } from "@/core/uploads/api"; import { uploadFiles } from "@/core/uploads/api";
import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview";
import {
collectRoundtableDeliverablePaths,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
} from "@/roundtable-planning/lib/streaming-artifacts";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */ /** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
@@ -36,6 +47,21 @@ type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
type SeatDelivery = { name: string; content: string }; type SeatDelivery = { name: string; content: string };
/**
* `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活,
* 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。
*/
function streamStepKey(
phase: StreamPhase,
toolName?: string,
toolCallId?: string,
): string {
if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) {
return `${phase}:${toolName}:${toolCallId}`;
}
return `${phase}:${toolName ?? ""}`;
}
/** In-memory cursor for a business-chain human-validation pause. */ /** In-memory cursor for a business-chain human-validation pause. */
type HumanValidationContinuation = type HumanValidationContinuation =
| { mode: "chain"; nextSeatIndex: number } | { mode: "chain"; nextSeatIndex: number }
@@ -148,6 +174,8 @@ export type Step2Dialogue = {
streaming?: boolean; streaming?: boolean;
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */ /** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
agentThreadId?: string; agentThreadId?: string;
/** Stream settlement scans the real sandbox output directory for these cards. */
artifactPaths?: string[];
/** /**
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具 * 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片 * 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
@@ -178,21 +206,82 @@ export interface ParallelSeatView {
} }
/** /**
* Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext * Stub thread message —— 用于把 special seat SSE 拿到的工具调用(及其合成 tool result)注入到 ThreadContext
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL * 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`, * `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。 * 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
*/ */
export interface StubToolCall { export interface StubToolCall {
id: string; id: string;
/** Original LangGraph id when a pre-id stream chunk needed a local fallback. */
sourceToolCallId?: string;
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
} }
export interface StubAiMessage { export interface StubAiMessage {
id: string; id: string;
type: "ai"; type: "ai" | "tool";
content: string; content: string;
tool_calls: StubToolCall[]; tool_calls: StubToolCall[];
/** tool 类型消息对应的原始 tool call;供沙箱判定 write_file 已完成。 */
tool_call_id?: string;
}
/**
* 圆桌流为了实时预览会合成一条带 write_file 的 ai 消息。主聊天真实流在工具执行结束后
* 还会收到对应的 tool 消息;圆桌 special 流此前漏了这一步,导致沙箱永久认为文件仍在写。
*
* 这里只补齐已经存在的 write_file 调用,绝不根据整条会商/其它并行流的 running 状态推断,
* 因而每个席位、报告、流程图和大屏预览的完成态彼此独立。
*/
export function completeStubWriteFile(
messages: StubAiMessage[],
toolCallId: string,
): StubAiMessage[] {
if (!toolCallId) {
return messages;
}
const source = messages.find((message) =>
message.type === "ai" &&
message.tool_calls.some(
(toolCall) =>
toolCall.name === "write_file" &&
(toolCall.id === toolCallId || toolCall.sourceToolCallId === toolCallId),
),
);
const matchedCall = source?.tool_calls.find(
(toolCall) =>
toolCall.name === "write_file" &&
(toolCall.id === toolCallId || toolCall.sourceToolCallId === toolCallId),
);
if (!source || !matchedCall) return messages;
if (messages.some((message) => message.type === "tool" && message.tool_call_id === matchedCall.id)) {
return messages;
}
return [
...messages,
{
id: `roundtable-stub-tool-result-${source.id}-${matchedCall.id}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: matchedCall.id,
},
];
}
/** 补齐一段已正常收尾的圆桌 stub 中全部 write_file 的工具返回。 */
export function completeAllStubWriteFiles(messages: StubAiMessage[]): StubAiMessage[] {
let next = messages;
for (const message of messages) {
if (message.type !== "ai") continue;
for (const toolCall of message.tool_calls) {
if (toolCall.name === "write_file") {
next = completeStubWriteFile(next, toolCall.id);
}
}
}
return next;
} }
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */ /** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
@@ -214,7 +303,8 @@ export type Step2Snapshot = {
selectedAgents?: SelectedAgent[]; selectedAgents?: SelectedAgent[];
threadIds: ThreadIdMap | null; threadIds: ThreadIdMap | null;
coordinatorName: string | null; coordinatorName: string | null;
step2RoundtableDialogues: Step2Dialogue[]; /** 早期草稿 / 旧版后台作业写入的 run 可能缺这字段 —— hydrate 按空数组兜底。 */
step2RoundtableDialogues?: Step2Dialogue[];
lastLeaderContent: string; lastLeaderContent: string;
hasConsensus: boolean; hasConsensus: boolean;
consensusPercentage: number; consensusPercentage: number;
@@ -513,21 +603,20 @@ export function useStep2Orchestration(
select: selectArtifact, select: selectArtifact,
setOpen: setArtifactsOpen, setOpen: setArtifactsOpen,
deselect: deselectArtifact, deselect: deselectArtifact,
autoOpen, openStreamedArtifact,
autoSelect,
} = useArtifacts(); } = useArtifacts();
const artifactPathsRef = useRef<string[]>([]); const artifactPathsRef = useRef<string[]>([]);
useEffect(() => { useEffect(() => {
artifactPathsRef.current = artifacts ?? []; artifactPathsRef.current = artifacts ?? [];
}, [artifacts]); }, [artifacts]);
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
useEffect(() => {
autoOpenFlagsRef.current = { autoOpen, autoSelect };
}, [autoOpen, autoSelect]);
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */ /** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
const hasAutoOpenedRef = useRef<Set<string>>(new Set()); const hasAutoOpenedRef = useRef<Set<string>>(new Set());
/** 技能脚本内部落盘没有 write_file SSE;保留目录快照以发现新增/覆盖文件。 */
const artifactDirectorySnapshotsRef = useRef<
Map<string, Map<string, string>>
>(new Map());
/** 每轮、每线程、每路径只自动打开一次,尊重用户随后手动关闭沙箱。 */
const autoOpenedDiscoveredArtifactsRef = useRef<Set<string>>(new Set());
// ─── State ───────────────────────────────────────────────────────── // ─── State ─────────────────────────────────────────────────────────
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>( const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
@@ -560,6 +649,9 @@ export function useStep2Orchestration(
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState< const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
Step2Dialogue[] Step2Dialogue[]
>([]); >([]);
// 由气泡 id 反查真实席位线程。完成一条气泡时只收口该线程的虚拟 write_file,
// 不会误影响仍在并行运行的其它席位。
const dialogueThreadIdRef = useRef<Record<number, string>>({});
const [pendingClarification, setPendingClarification] = const [pendingClarification, setPendingClarification] =
useState<ClarificationPayload | null>(null); useState<ClarificationPayload | null>(null);
const [clarificationDraft, setClarificationDraft] = useState<string>(""); const [clarificationDraft, setClarificationDraft] = useState<string>("");
@@ -789,6 +881,7 @@ export function useStep2Orchestration(
seatThreadId: string, seatThreadId: string,
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
sourceToolCallId?: string,
): { messageId: string; toolCallId: string } => { ): { messageId: string; toolCallId: string } => {
const path = typeof args.path === "string" ? args.path : ""; const path = typeof args.path === "string" ? args.path : "";
const current = seatStubMessagesRef.current[seatThreadId] ?? []; const current = seatStubMessagesRef.current[seatThreadId] ?? [];
@@ -799,17 +892,25 @@ export function useStep2Orchestration(
content: "", content: "",
tool_calls: [], tool_calls: [],
}; };
const idx = baseMsg.tool_calls.findIndex( const bySourceId = sourceToolCallId
? baseMsg.tool_calls.findIndex(
(tc) => tc.id === sourceToolCallId || tc.sourceToolCallId === sourceToolCallId,
)
: -1;
const byPath = baseMsg.tool_calls.findIndex(
(tc) => (tc) =>
tc.name === toolName && tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path, String((tc.args as { path?: unknown })?.path ?? "") === path,
); );
const idx = bySourceId >= 0 ? bySourceId : byPath;
const toolCallId = const toolCallId =
idx >= 0 idx >= 0
? baseMsg.tool_calls[idx].id ? baseMsg.tool_calls[idx].id
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`; : sourceToolCallId || `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
const nextToolCall: StubToolCall = { const nextToolCall: StubToolCall = {
id: toolCallId, id: toolCallId,
sourceToolCallId:
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
name: toolName, name: toolName,
args, args,
}; };
@@ -835,13 +936,42 @@ export function useStep2Orchestration(
[scheduleSeatStubMessagesFlush], [scheduleSeatStubMessagesFlush],
); );
/** 将某次真实 tool 返回同步到对应的虚拟 write_file 预览。 */
const completeSeatStubWriteFile = useCallback(
(seatThreadId: string, toolCallId: string) => {
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
const nextMessages = completeStubWriteFile(current, toolCallId);
if (nextMessages === current) return;
const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages };
seatStubMessagesRef.current = nextMap;
pendingSeatStubMessagesRef.current = nextMap;
scheduleSeatStubMessagesFlush();
},
[scheduleSeatStubMessagesFlush],
);
/** special 流正常收尾的兜底:补齐该席位尚未透传出来的 write_file tool 返回。 */
const completeSeatStubWriteFiles = useCallback(
(seatThreadId: string | undefined) => {
if (!seatThreadId) return;
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages };
seatStubMessagesRef.current = nextMap;
pendingSeatStubMessagesRef.current = nextMap;
scheduleSeatStubMessagesFlush();
},
[scheduleSeatStubMessagesFlush],
);
/** /**
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content, * 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。 * 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
* *
* auto=true 流式期间: * auto=true 流式期间:
* - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule); * - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule);
* - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。 * - 用户手动关闭过**当前文件**时不反复弹开;后续新的 write_file 调用仍允许自动预览。
* auto=false(用户点击步骤 / 文件卡)始终强制打开。 * auto=false(用户点击步骤 / 文件卡)始终强制打开。
*/ */
const openSeatVirtualArtifact = useCallback( const openSeatVirtualArtifact = useCallback(
@@ -859,8 +989,6 @@ export function useStep2Orchestration(
const manual = opts?.manual ?? !auto; const manual = opts?.manual ?? !auto;
if (auto) { if (auto) {
if (hasAutoOpenedRef.current.has(virtualUrl)) return; if (hasAutoOpenedRef.current.has(virtualUrl)) return;
const flags = autoOpenFlagsRef.current;
if (!flags.autoOpen || !flags.autoSelect) return;
// 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写 // 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写
// 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。 // 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。
if ( if (
@@ -880,6 +1008,11 @@ export function useStep2Orchestration(
return; return;
} }
hasAutoOpenedRef.current.add(virtualUrl); hasAutoOpenedRef.current.add(virtualUrl);
setActiveArtifactThreadId(seatThreadId);
// 由 ArtifactsProvider 以 tool-call 维度管理「手动关闭后不再弹同一份文件」:
// 当前文件被关闭过 → false;下一次 write_file 的 previewKey 不同 → 仍会自动打开。
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
return;
} }
setActiveArtifactThreadId(seatThreadId); setActiveArtifactThreadId(seatThreadId);
const open = () => { const open = () => {
@@ -887,28 +1020,14 @@ export function useStep2Orchestration(
selectArtifact(virtualUrl, !manual); selectArtifact(virtualUrl, !manual);
setArtifactsOpen(true); setArtifactsOpen(true);
}; };
if (auto) { open();
window.setTimeout(open, 100);
} else {
open();
}
}, },
[selectArtifact, setArtifactsOpen], [openStreamedArtifact, selectArtifact, setArtifactsOpen],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
); );
/** /**
* sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。 * sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。
* 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。 * 从该 thread 的 stub messages 里倒序找第一个可预览的 write_file / str_replace。
* 无产物返回 false(调用方据此关闭沙箱)。 * 无产物返回 false(调用方据此关闭沙箱)。
*/ */
const openSeatLatestArtifact = useCallback( const openSeatLatestArtifact = useCallback(
@@ -918,12 +1037,13 @@ export function useStep2Orchestration(
for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) { for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) {
const tc = stub.tool_calls[i]; const tc = stub.tool_calls[i];
if (tc.name !== "write_file" && tc.name !== "str_replace") continue; if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const path = const args =
typeof (tc.args as { path?: unknown })?.path === "string" tc.args && typeof tc.args === "object"
? ((tc.args as { path?: string }).path as string) ? (tc.args as Record<string, unknown>)
: ""; : undefined;
const path = resolveRoundtableStreamingWritePreview(tc.name, args);
if (!path) continue; if (!path) continue;
const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id); const virtualUrl = buildWriteFileVirtualUrl({ path, messageId: stub.id, toolCallId: tc.id });
// manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化 // manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化
// 后续实时自动打开,见 openSeatVirtualArtifact 注释)。 // 后续实时自动打开,见 openSeatVirtualArtifact 注释)。
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false }); openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false });
@@ -1004,11 +1124,11 @@ export function useStep2Orchestration(
// not found 的影响。 // not found 的影响。
const seatSource = findSeatArtifactSource(normalized); const seatSource = findSeatArtifactSource(normalized);
if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) { if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) {
const virtualUrl = buildWriteFileVirtualUrl( const virtualUrl = buildWriteFileVirtualUrl({
normalized, path: normalized,
seatSource.messageId, messageId: seatSource.messageId,
seatSource.toolCallId, toolCallId: seatSource.toolCallId,
); });
openSeatVirtualArtifact( openSeatVirtualArtifact(
seatSource.seatThreadId, seatSource.seatThreadId,
virtualUrl, virtualUrl,
@@ -1053,6 +1173,96 @@ export function useStep2Orchestration(
], ],
); );
// 对齐普通聊天的产物目录刷新:技能可以通过 bash / Python 在 outputs 中直接落盘,
// 这种路径没有顶层 write_file 工具帧。只要对应气泡仍在流式,就每 1.5 秒扫描一次;
// 发现新文件或同路径被覆盖时自动打开真实沙箱。最终消息卡片仍由
// collectRoundtableDeliverablePaths 单独限制为 md/html/mermaid。
const streamingArtifactThreadSignature = useMemo(() => {
const ids = new Set<string>();
for (const dialogue of step2RoundtableDialogues) {
if (dialogue.streaming && dialogue.agentThreadId) {
ids.add(dialogue.agentThreadId);
}
}
return [...ids].sort().join("\n");
}, [step2RoundtableDialogues]);
useEffect(() => {
if (currentStep !== 2 || !streamingArtifactThreadSignature) return;
const watchedThreadIds = streamingArtifactThreadSignature
.split("\n")
.filter(Boolean);
if (watchedThreadIds.length === 0) return;
const controller = new AbortController();
const watchStartedAtMs = Date.now();
let stopped = false;
let polling = false;
const poll = async () => {
if (polling || stopped) return;
polling = true;
try {
const results = await Promise.allSettled(
watchedThreadIds.map(async (threadId) => ({
threadId,
listing: await listSessionArtifacts(threadId, {
signal: controller.signal,
}),
})),
);
if (stopped) return;
let newest:
| { threadId: string; path: string; modifiedAt: string }
| null = null;
let newestTime = Number.NEGATIVE_INFINITY;
for (const result of results) {
if (result.status !== "fulfilled") continue;
const { threadId, listing } = result.value;
const previous = artifactDirectorySnapshotsRef.current.get(threadId);
const changed = newestChangedRoundtableArtifact(
listing.files,
previous,
watchStartedAtMs,
);
artifactDirectorySnapshotsRef.current.set(
threadId,
roundtableArtifactDiskSnapshot(listing.files),
);
if (!changed) continue;
const changedTime = Date.parse(changed.modifiedAt);
const sortableTime = Number.isFinite(changedTime) ? changedTime : 0;
if (!newest || sortableTime > newestTime) {
newest = {
threadId,
path: changed.path,
modifiedAt: changed.modifiedAt,
};
newestTime = sortableTime;
}
}
if (!newest) return;
const autoOpenKey = `${orchestrationEpochRef.current}:${newest.threadId}:${newest.path}`;
if (autoOpenedDiscoveredArtifactsRef.current.has(autoOpenKey)) return;
autoOpenedDiscoveredArtifactsRef.current.add(autoOpenKey);
openArtifactPreview(newest.threadId, newest.path, { auto: true });
} finally {
polling = false;
}
};
void poll();
const intervalId = window.setInterval(() => void poll(), 1_500);
return () => {
stopped = true;
controller.abort();
window.clearInterval(intervalId);
};
}, [currentStep, openArtifactPreview, streamingArtifactThreadSignature]);
const openArtifactFromStep = useCallback( const openArtifactFromStep = useCallback(
( (
dialogue: Step2Dialogue, dialogue: Step2Dialogue,
@@ -1062,14 +1272,15 @@ export function useStep2Orchestration(
if (!seatThreadId) return; if (!seatThreadId) return;
const [, toolName] = step.key.split(":"); const [, toolName] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") { if (toolName === "write_file" || toolName === "str_replace") {
const path = step.args?.path; if (!step.args) return;
if (typeof path !== "string" || !step.args) return; const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
if (!path) return;
const { messageId, toolCallId } = upsertSeatToolCall( const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId, seatThreadId,
toolName, toolName,
step.args, step.args,
); );
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
openSeatVirtualArtifact(seatThreadId, virtualUrl, false); openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return; return;
} }
@@ -1099,28 +1310,22 @@ export function useStep2Orchestration(
toolName: string | undefined, toolName: string | undefined,
args: Record<string, unknown> | undefined, args: Record<string, unknown> | undefined,
seatThreadId: string | undefined, seatThreadId: string | undefined,
sourceToolCallId?: string,
) => { ) => {
if (phase !== "tool_calling") return; if (phase !== "tool_calling") return;
if (!seatThreadId) return; if (!seatThreadId) return;
const isArtifactTool = if (toolName !== "write_file" && toolName !== "str_replace") return;
toolName === "write_file" || toolName === "str_replace"; // `write_file` can stream its body before its path. It opens the sandbox
if (!isArtifactTool) return; // for every file type; only final message-list cards are filtered.
const path = args?.path; const path = resolveRoundtableStreamingWritePreview(toolName, args);
if (typeof path !== "string" || !path || !args) { if (!path || !args) return;
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] tool_calling without path yet",
{ toolName, args },
);
}
return;
}
const { messageId, toolCallId } = upsertSeatToolCall( const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId, seatThreadId,
toolName as string, toolName as string,
args, args,
sourceToolCallId,
); );
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
openSeatVirtualArtifact(seatThreadId, virtualUrl, true); openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
}, },
[ [
@@ -1136,7 +1341,9 @@ export function useStep2Orchestration(
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开; * - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界, * - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。 * 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。 * - 同一 messages 帧可先写文件、再读技能。阶段层只会把最后一个 read_skill 当作
* active tool,因而这里必须补一次 write_file 自动打开;以 tool-call 虚拟 URL 去重,
* 不会在后续内容增量或用户手动关闭后反复弹出。
*/ */
const handleUpstreamFrame = useCallback( const handleUpstreamFrame = useCallback(
(frame: unknown, seatThreadId: string | undefined) => { (frame: unknown, seatThreadId: string | undefined) => {
@@ -1145,6 +1352,13 @@ export function useStep2Orchestration(
const chunk = frame[0]; const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return; if (!chunk || typeof chunk !== "object") return;
const obj = chunk as Record<string, unknown>; const obj = chunk as Record<string, unknown>;
// special 流透传真实工具返回时,必须同步收口虚拟 write_file;否则右侧沙箱会把
// 已完成的文件永久当成「正在生成」。
if (String(obj.type ?? "").toLowerCase() === "tool") {
const toolCallId = typeof obj.tool_call_id === "string" ? obj.tool_call_id : "";
if (toolCallId) completeSeatStubWriteFile(seatThreadId, toolCallId);
return;
}
const toolCalls = obj.tool_calls; const toolCalls = obj.tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return; if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
for (const tc of toolCalls) { for (const tc of toolCalls) {
@@ -1155,14 +1369,79 @@ export function useStep2Orchestration(
const tcArgs = tcObj.args; const tcArgs = tcObj.args;
if (!tcArgs || typeof tcArgs !== "object") continue; if (!tcArgs || typeof tcArgs !== "object") continue;
const argsObj = tcArgs as Record<string, unknown>; const argsObj = tcArgs as Record<string, unknown>;
const path = argsObj.path; const sourceToolCallId = typeof tcObj.id === "string" ? tcObj.id : undefined;
if (typeof path !== "string" || !path) continue; // Keep the content-first call in the stub as well. The virtual preview
upsertSeatToolCall(seatThreadId, name, argsObj); // reads this message directly, so dropping pathless chunks recreates the
// exact race fixed in the normal chat.
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
name,
argsObj,
sourceToolCallId,
);
// `setPhase` 只报告一个当前工具。若 AI chunk 同时携带
// `write_file → read_skill`,它会落在 read_skill,旧逻辑便遗漏了这里的
// 实时沙箱。逐条扫描所有 write_file,把过程脚本和最终文档一律接入沙箱;
// 最终消息卡片仍由 collectRoundtableDeliverablePaths 单独过滤。
if (name !== "write_file") continue;
const previewPath = resolveRoundtableStreamingWritePreview(name, argsObj);
if (!previewPath) continue;
openSeatVirtualArtifact(
seatThreadId,
buildWriteFileVirtualUrl({
path: previewPath,
messageId,
toolCallId,
}),
true,
);
} }
}, },
[upsertSeatToolCall], [
buildWriteFileVirtualUrl,
completeSeatStubWriteFile,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
); );
/**
* A weak model may create the final report through bash after an earlier
* write_file call. Those tools have no `present_files` step, so mirror the
* normal chat's post-stream artifact-directory refresh and attach only the
* user-facing document types to this completed bubble.
*/
const attachSettledArtifactCards = useCallback((dialogueId: number, threadId?: string) => {
if (!threadId) return;
void listSessionArtifacts(threadId)
.then(({ files }) => {
const artifactPaths = collectRoundtableDeliverablePaths(files.map((file) => file.path));
if (artifactPaths.length === 0) return;
setStep2RoundtableDialogues((previous) => {
const index = previous.findIndex((dialogue) => dialogue.id === dialogueId);
if (index < 0) return previous;
const dialogue = previous[index];
const merged = collectRoundtableDeliverablePaths([
...(dialogue.artifactPaths ?? []),
...artifactPaths,
]);
if (
merged.length === (dialogue.artifactPaths ?? []).length &&
merged.every((path, itemIndex) => path === dialogue.artifactPaths?.[itemIndex])
) {
return previous;
}
const next = [...previous];
next[index] = { ...dialogue, artifactPaths: merged };
return next;
});
})
.catch(() => {
// The normal message/tool path remains usable when directory listing is
// unavailable; no noisy toast is warranted for this recovery fallback.
});
}, []);
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)────────── // ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里: // ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress); // (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
@@ -1391,15 +1670,28 @@ export function useStep2Orchestration(
let nextDialogue: Step2Dialogue; let nextDialogue: Step2Dialogue;
if (last && last.key === key) { if (last && last.key === key) {
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到 // 总控派活的 task 是 tool_call JSON 的流式增量。不能沿用普通工具的
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个 // 「首次可见参数即停」策略,否则只显示 agent_name,整段任务会在收尾时
// content chunk 更新对话 state。 // 才被最终消息一次性补上。
const hasVisibleArgs = (value?: Record<string, unknown>) => if (key.startsWith("tool_calling:agent_orchestration:") && args) {
!!value && Object.keys(value).some((argKey) => argKey !== "content"); const mergedArgs = { ...(last.args ?? {}), ...args };
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev; if (JSON.stringify(last.args ?? {}) === JSON.stringify(mergedArgs)) {
const nextSteps = [...steps]; return prev;
nextSteps[nextSteps.length - 1] = { ...last, args }; }
nextDialogue = { ...dialogue, steps: nextSteps }; const nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args: mergedArgs };
nextDialogue = { ...dialogue, steps: nextSteps };
} else {
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
// content chunk 更新对话 state。
const hasVisibleArgs = (value?: Record<string, unknown>) =>
!!value && Object.keys(value).some((argKey) => argKey !== "content");
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
const nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args };
nextDialogue = { ...dialogue, steps: nextSteps };
}
} else { } else {
nextDialogue = { nextDialogue = {
...dialogue, ...dialogue,
@@ -1427,6 +1719,9 @@ export function useStep2Orchestration(
agentThreadId?: string; agentThreadId?: string;
}): number => { }): number => {
const id = Date.now() + Math.random(); const id = Date.now() + Math.random();
if (card.agentThreadId) {
dialogueThreadIdRef.current[id] = card.agentThreadId;
}
setStep2RoundtableDialogues((prev) => [ setStep2RoundtableDialogues((prev) => [
...prev, ...prev,
{ {
@@ -1630,10 +1925,8 @@ export function useStep2Orchestration(
const opened = openSeatLatestArtifact(tid); const opened = openSeatLatestArtifact(tid);
if (!opened) { if (!opened) {
// 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→ // 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→
// 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen); // 用 deselect 收起并清空旧预览,不把别的席位当前文件标记为「手动关闭」。这样该席位
// 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false // 随后的新 write_file 仍能按自己的 previewKey 自动打开。
// (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的
// `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。
setActiveArtifactThreadId(null); setActiveArtifactThreadId(null);
deselectArtifact(); deselectArtifact();
} }
@@ -1647,7 +1940,14 @@ export function useStep2Orchestration(
const finalizeDialogue = useCallback( const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => { (id: number, finalContent: string, confidence?: string) => {
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。 // 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。
// 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。
const threadId = dialogueThreadIdRef.current[id];
completeSeatStubWriteFiles(threadId);
attachSettledArtifactCards(id, threadId);
// 完成帧可能与最后一批节流 token 落在同一任务:把尚未 flush 的增量合并进
// 收尾内容,既防止旧增量晚到反写,也不能丢最后的思考/正文 token。
const pendingDelta = directStreamDeltaRef.current.get(id) ?? "";
directStreamDeltaRef.current.delete(id); directStreamDeltaRef.current.delete(id);
if ( if (
directStreamDeltaRef.current.size === 0 && directStreamDeltaRef.current.size === 0 &&
@@ -1660,10 +1960,18 @@ export function useStep2Orchestration(
let changed = false; let changed = false;
const next = prev.map((d) => { const next = prev.map((d) => {
if (d.id !== id) return d; if (d.id !== id) return d;
const streamed = `${d.content ?? ""}${pendingDelta}`;
const streamedVisible = stripReasoning(streamed).trim();
const finalVisible = stripReasoning(finalContent).trim();
// Special-seat terminal frames intentionally return the visible
// deliverable without provider reasoning. When it matches the
// streamed body, retain the inline <think> trace already rendered.
const next = const next =
finalContent && finalContent.trim().length > 0 streamed.trim() && streamedVisible === finalVisible
? finalContent ? streamed
: d.content || "(无文本输出)"; : finalContent && finalContent.trim().length > 0
? finalContent
: streamed || "(无文本输出)";
if ( if (
d.content === next && d.content === next &&
d.confidence === (confidence ?? d.confidence) && d.confidence === (confidence ?? d.confidence) &&
@@ -1682,7 +1990,7 @@ export function useStep2Orchestration(
return changed ? next : prev; return changed ? next : prev;
}); });
}, },
[], [attachSettledArtifactCards, completeSeatStubWriteFiles],
); );
const highlightSpeaker = useCallback( const highlightSpeaker = useCallback(
@@ -1881,11 +2189,11 @@ export function useStep2Orchestration(
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel), onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text), a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(a.bubbleId, label); setDialogueConfidence(a.bubbleId, label);
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId); handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, a.seatThreadId); handleUpstreamFrame(frame, a.seatThreadId);
@@ -1971,27 +2279,25 @@ export function useStep2Orchestration(
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。 // 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。 // 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1, synthesisMode: safetyCounter > 1,
// 削减重复广播(2026-06-20):首轮(safetyCounter==1)保留预广播——它把任务背景播进各席位 // 总控 prompt 含派活/编排指令,只能进入总控 thread。任何轮次都禁止广播到席位历史,
// thread,为席位打底;第 2 轮起的续轮/综合广播是「总控收到:<续轮 prompt>」纯噪声 + N 次 // 否则弱模型会模仿总控并声称要加载编排技能。席位上下文由具体 task 与交付广播提供。
// 读写巨大席位 thread 的纯浪费(还会让弱模型从历史模仿派活),故 suppress。席位的前序交付 suppressPreBroadcast: true,
// 另经「席位交付广播」(broadcastSeatDeliveryToOthers)送达,不受影响。
suppressPreBroadcast: safetyCounter > 1,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。 // 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef), files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
signal: leaderAborter.signal, signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label); setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew( appendStepIfNew(
leaderBubbleId, leaderBubbleId,
label, label,
`${phase}:${toolName ?? ""}`, streamStepKey(phase, toolName, toolCallId),
args, args,
); );
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, leaderThreadId), handleUpstreamFrame(frame, leaderThreadId),
@@ -2256,21 +2562,25 @@ export function useStep2Orchestration(
dAgentThreadId: ids, dAgentThreadId: ids,
agentName: coord, agentName: coord,
newMessage: leaderTask, newMessage: leaderTask,
// 削减重复广播(2026-06-20):首个席位(i===0)保留预广播为其打底任务背景;从第 2 个席位起 // 链条首轮同样不能把「请总控调用 agent_orchestration」广播进席位 thread。
// suppress——后续席位的前序交付已由「席位交付广播」送达其 thread,总控派活 prompt 再播一遍 // 当前席位拿到的是总控生成的具体 task,后续席位再通过交付广播承接上下文。
// 是 N 次读写巨大 thread 的纯浪费(还会让弱模型模仿派活)。 suppressPreBroadcast: true,
suppressPreBroadcast: i > 0,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。 // 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef), files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
signal: leaderAborter.signal, signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label); setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); leaderBubbleId,
label,
streamStepKey(phase, toolName, toolCallId),
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
}); });
@@ -2349,11 +2659,11 @@ export function useStep2Orchestration(
signal: subAborter.signal, signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(subBubbleId, false), onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
onTextDelta: (text) => appendToDialogue(subBubbleId, text), onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label); setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId); handleUpstreamFrame(frame, seatThreadId);
@@ -2452,11 +2762,16 @@ export function useStep2Orchestration(
signal: closeoutAborter.signal, signal: closeoutAborter.signal,
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false), onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text), onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(closeoutBubbleId, label); setDialogueConfidence(closeoutBubbleId, label);
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); closeoutBubbleId,
label,
streamStepKey(phase, toolName, toolCallId),
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
}); });
@@ -2631,11 +2946,16 @@ export function useStep2Orchestration(
signal: leaderAborter.signal, signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label); setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(
handleArtifactToolPhase(phase, toolName, args, leaderThreadId); leaderBubbleId,
label,
streamStepKey(phase, toolName, toolCallId),
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
}); });
@@ -2758,11 +3078,11 @@ export function useStep2Orchestration(
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel), onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label); setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId); handleUpstreamFrame(frame, seatThreadId);
@@ -2970,11 +3290,11 @@ export function useStep2Orchestration(
signal: subAborter.signal, signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, true), onModelSwitch: makeModelSwitchHandler(bubbleId, true),
onTextDelta: (text) => ingestParallelDelta(bubbleId, text), onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label); setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId); handleUpstreamFrame(frame, seatThreadId);
@@ -3408,7 +3728,7 @@ export function useStep2Orchestration(
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。 // 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
// streaming===true 与「加载中」文案一一对应(finalizeDialogue 收尾时必置 false),故据此即可精确识别。 // streaming===true 与「加载中」文案一一对应(finalizeDialogue 收尾时必置 false),故据此即可精确识别。
setStep2RoundtableDialogues( setStep2RoundtableDialogues(
snapshot.step2RoundtableDialogues.map((d) => { (snapshot.step2RoundtableDialogues ?? []).map((d) => {
if (!d.streaming) return d; if (!d.streaming) return d;
const hasText = (d.content ?? "").trim().length > 0; const hasText = (d.content ?? "").trim().length > 0;
return { return {
@@ -3423,8 +3743,20 @@ export function useStep2Orchestration(
setHasConsensus(snapshot.hasConsensus); setHasConsensus(snapshot.hasConsensus);
setConsensusPercentage(snapshot.consensusPercentage); setConsensusPercentage(snapshot.consensusPercentage);
setBudgetLimit(snapshot.budgetLimit); setBudgetLimit(snapshot.budgetLimit);
const restoredStubs = snapshot.seatStubMessages ?? {}; // 历史草稿是静态完成记录;旧版本未持久化 tool result 的 write_file 也必须收口,
// 否则从「最终方案展示」回看时会错误显示仍在生成。
const restoredStubs = Object.fromEntries(
Object.entries(snapshot.seatStubMessages ?? {}).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages),
]),
);
replaceSeatStubMessages(restoredStubs); replaceSeatStubMessages(restoredStubs);
dialogueThreadIdRef.current = Object.fromEntries(
(snapshot.step2RoundtableDialogues ?? [])
.filter((dialogue) => Boolean(dialogue.agentThreadId))
.map((dialogue) => [dialogue.id, dialogue.agentThreadId!]),
);
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。 // 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
hasAutoOpenedRef.current = new Set(); hasAutoOpenedRef.current = new Set();
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上, // 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
@@ -3491,6 +3823,7 @@ export function useStep2Orchestration(
setCoordinatorName(null); setCoordinatorName(null);
setInitError(null); setInitError(null);
setStep2RoundtableDialogues([]); setStep2RoundtableDialogues([]);
dialogueThreadIdRef.current = {};
setLastLeaderContent(""); setLastLeaderContent("");
setHasConsensus(false); setHasConsensus(false);
setConsensusPercentage(0); setConsensusPercentage(0);
@@ -3504,6 +3837,8 @@ export function useStep2Orchestration(
setArtifacts([]); setArtifacts([]);
resetSeatStubMessages(); resetSeatStubMessages();
hasAutoOpenedRef.current = new Set(); hasAutoOpenedRef.current = new Set();
artifactDirectorySnapshotsRef.current = new Map();
autoOpenedDiscoveredArtifactsRef.current = new Set();
pendingInjectPromptRef.current = null; pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false; dagPlanExecutedRef.current = false;
humanValidationContinuationRef.current = null; humanValidationContinuationRef.current = null;
@@ -20,9 +20,10 @@ import {
type StreamPhase, type StreamPhase,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { import {
Step2Dialogue, completeAllStubWriteFiles,
StubAiMessage, type Step2Dialogue,
type StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration"; } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { import {
buildDashboardPrompt, buildDashboardPrompt,
@@ -234,6 +235,16 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
return { messageId, toolCallId }; return { messageId, toolCallId };
}, []); }, []);
/** 前端已完成模板渲染后,为虚拟 HTML 文件补齐 tool result,解除沙箱「正在生成」状态。 */
const completeDashStubWriteFiles = useCallback((threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
}, []);
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。 // 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
const previewEnabledRef = useRef(false); const previewEnabledRef = useRef(false);
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>( const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
@@ -274,8 +285,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const current = htmlRef.current; const current = htmlRef.current;
if (!current) return; if (!current) return;
const { messageId, toolCallId } = upsertDashStub(tid, current); const { messageId, toolCallId } = upsertDashStub(tid, current);
completeDashStubWriteFiles(tid);
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制 openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]); }, [activeArtifactThreadId, upsertDashStub, completeDashStubWriteFiles, openDashboardArtifact]);
const abort = useCallback(() => { const abort = useCallback(() => {
aborterRef.current?.abort(); aborterRef.current?.abort();
@@ -345,7 +357,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const data = extractReportData(res.content); const data = extractReportData(res.content);
if (!data) { if (!data) {
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。 // 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current); if (htmlRef.current) {
upsertDashStub(threadId, htmlRef.current);
completeDashStubWriteFiles(threadId);
}
finalizeDialogue( finalizeDialogue(
bubbleId, bubbleId,
(res.content || "").trim() || (res.content || "").trim() ||
@@ -362,6 +377,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染 setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动 dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
const { messageId, toolCallId } = upsertDashStub(threadId, rendered); const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
completeDashStubWriteFiles(threadId);
openDashboardArtifact(threadId, messageId, toolCallId); openDashboardArtifact(threadId, messageId, toolCallId);
finalizeDialogue(bubbleId, res.content, "大屏绘制完成"); finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
@@ -378,6 +394,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setDialogueConfidence, setDialogueConfidence,
finalizeDialogue, finalizeDialogue,
upsertDashStub, upsertDashStub,
completeDashStubWriteFiles,
openDashboardArtifact, openDashboardArtifact,
], ],
); );
@@ -483,6 +500,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setDashThreadId(synthThreadId); setDashThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId); setActiveArtifactThreadId(synthThreadId);
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml); const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
completeDashStubWriteFiles(synthThreadId);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。 // 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId }; lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。 // 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
@@ -495,7 +513,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
: null; : null;
if (savedStubs && Object.keys(savedStubs).length) { if (savedStubs && Object.keys(savedStubs).length) {
// 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。 // 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。
const merged = { ...stubRef.current, ...savedStubs }; const merged: Record<string, StubAiMessage[]> = Object.fromEntries(
Object.entries({ ...stubRef.current, ...savedStubs }).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages as StubAiMessage[]),
]),
);
stubRef.current = merged; stubRef.current = merged;
setSeatStubMessages(merged); setSeatStubMessages(merged);
} }
@@ -522,7 +545,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setPhase("done"); setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。 // 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
}, },
[abort, upsertDashStub], [abort, upsertDashStub, completeDashStubWriteFiles],
); );
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */ /** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
@@ -556,6 +579,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setData(parsed); setData(parsed);
dataRef.current = parsed; // 续轮修改的基线 dataRef.current = parsed; // 续轮修改的基线
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered); const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
completeDashStubWriteFiles(synthThreadId);
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId }; lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
} else { } else {
htmlRef.current = null; htmlRef.current = null;
@@ -567,7 +591,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setError(null); setError(null);
setPhase(parsed ? "done" : "idle"); setPhase(parsed ? "done" : "idle");
}, },
[abort, themeMode, upsertDashStub], [abort, themeMode, upsertDashStub, completeDashStubWriteFiles],
); );
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。 // 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
@@ -478,6 +478,36 @@ export function useStep3Report(
[], [],
); );
/**
* 自定义 SSE 的 stub 不会天然带回 LangGraph 的 ToolMessage。经典绘制正常收尾时,
* 为每个 write_file 补一条等价的完成消息,让通用沙箱能结束「正在生成」状态并自动预览。
*/
const completeStubWriteFiles = useCallback((threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const aiMessage = current.find((message) => message.type === "ai");
if (!aiMessage) return;
const completedIds = new Set(
current
.filter((message) => message.type === "tool" && message.tool_call_id)
.map((message) => message.tool_call_id!),
);
const toolResults: StubAiMessage[] = aiMessage.tool_calls
.filter((toolCall) => toolCall.name === "write_file" && !completedIds.has(toolCall.id))
.map((toolCall) => ({
id: `roundtable-report-tool-result-${threadId}-${toolCall.id}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCall.id,
}));
if (toolResults.length === 0) return;
const nextMap = { ...stubRef.current, [threadId]: [...current, ...toolResults] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
}, []);
const buildWriteFileVirtualUrl = useCallback( const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) => (path: string, messageId: string, toolCallId: string) =>
new URL( new URL(
@@ -825,6 +855,9 @@ export function useStep3Report(
? await reconcileStubFromRealFile(threadId, aborter.signal) ? await reconcileStubFromRealFile(threadId, aborter.signal)
: ""; : "";
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
// 该轮已正常收尾:解除当前 HTML 虚拟预览的写入态。不能等其它并行的总结/大屏流,
// 它们各自有独立线程和 stub,互不影响。
completeStubWriteFiles(threadId);
finalizeDialogue(bubbleId, closing, "绘制完成"); finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done"); setPhase("done");
setReportTheme(theme); setReportTheme(theme);
@@ -844,6 +877,7 @@ export function useStep3Report(
selectedModel, selectedModel,
openRealArtifactFallback, openRealArtifactFallback,
reconcileStubFromRealFile, reconcileStubFromRealFile,
completeStubWriteFiles,
finalizeDialogue, finalizeDialogue,
], ],
); );
@@ -1043,6 +1077,13 @@ export function useStep3Report(
}, },
], ],
}, },
{
id: `roundtable-report-tool-result-${synthThreadId}-${toolCallId}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCallId,
},
], ],
}; };
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。 // 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
@@ -34,9 +34,11 @@ import type {
ReportIntent, ReportIntent,
ReportSeatDelivery, ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report"; } from "@/roundtable-planning/hooks/useStep3Report";
import type { import {
Step2Dialogue, completeAllStubWriteFiles,
StubAiMessage, completeStubWriteFile,
type Step2Dialogue,
type StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration"; } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import { import {
@@ -127,7 +129,7 @@ export interface UseStep3SummaryReturn {
dialogues: Step2Dialogue[]; dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>; seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null; activeArtifactThreadId: string | null;
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */ /** 是否正在生成中(报告撰写或流程图抽取/校验)—— 驱动暂停按钮与发送态。 */
busy: boolean; busy: boolean;
/** 启动总结(幂等:进行中重复调用会被忽略)。 */ /** 启动总结(幂等:进行中重复调用会被忽略)。 */
start: () => void; start: () => void;
@@ -821,6 +823,34 @@ export function useStep3Summary(
[scheduleStubMessagesFlush], [scheduleStubMessagesFlush],
); );
/** 将 summary special 流的真实 tool 返回同步到虚拟 Markdown 文件。 */
const completeSummaryStubWriteFile = useCallback(
(threadId: string, toolCallId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeStubWriteFile(current, toolCallId);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
},
[scheduleStubMessagesFlush],
);
/** summary 一轮已完成时补齐尚未透传的 write_file tool 返回。 */
const completeSummaryStubWriteFiles = useCallback(
(threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
},
[scheduleStubMessagesFlush],
);
const buildWriteFileVirtualUrl = useCallback( const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) => (path: string, messageId: string, toolCallId: string) =>
new URL( new URL(
@@ -955,6 +985,11 @@ export function useStep3Summary(
(frame: unknown, threadId: string) => { (frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return; if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined; const chunk = frame[0] as Record<string, unknown> | undefined;
if (String(chunk?.type ?? "").toLowerCase() === "tool") {
const toolCallId = typeof chunk?.tool_call_id === "string" ? chunk.tool_call_id : "";
if (toolCallId) completeSummaryStubWriteFile(threadId, toolCallId);
return;
}
const toolCalls = chunk?.tool_calls; const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return; if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) { for (const tc of toolCalls) {
@@ -970,7 +1005,7 @@ export function useStep3Summary(
upsertStub(threadId, name, a as Record<string, unknown>); upsertStub(threadId, name, a as Record<string, unknown>);
} }
}, },
[upsertStub], [completeSummaryStubWriteFile, upsertStub],
); );
const openArtifactPreview = useCallback( const openArtifactPreview = useCallback(
@@ -1162,6 +1197,9 @@ export function useStep3Summary(
flowActiveRef.current = false; flowActiveRef.current = false;
} }
// 报告线程本轮已收尾。即使后端没有单独透传 tool 帧,也只结束本线程的预览写入态;
// 后续自动流程图/大屏等并行分支完全不参与这个判断。
completeSummaryStubWriteFiles(threadId);
finalizeDialogue(bubbleId, res.content, "总结完成"); finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done"); setPhase("done");
@@ -1187,6 +1225,7 @@ export function useStep3Summary(
handleArtifactToolPhase, handleArtifactToolPhase,
handleUpstreamFrame, handleUpstreamFrame,
reconcileStubFromRealFile, reconcileStubFromRealFile,
completeSummaryStubWriteFiles,
finalizeDialogue, finalizeDialogue,
seedStubWithCurrentMd, seedStubWithCurrentMd,
], ],
@@ -1939,6 +1978,13 @@ export function useStep3Summary(
}, },
], ],
}, },
{
id: `roundtable-stub-tool-result-${messageId}-${toolCallId}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCallId,
},
], ],
} }
: {}; : {};
@@ -1953,7 +1999,15 @@ export function useStep3Summary(
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。 // 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub = const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub; savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
replaceStubMessages(effectiveStub); // 恢复的是静态完成记录;兼容旧草稿没有保存 tool result 的场景。
replaceStubMessages(
Object.fromEntries(
Object.entries(effectiveStub).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages),
]),
),
);
currentMdRef.current = snap.md; currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId; liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。 // 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -2015,7 +2069,11 @@ export function useStep3Summary(
[abort, buildWriteFileVirtualUrl, replaceStubMessages], [abort, buildWriteFileVirtualUrl, replaceStubMessages],
); );
const busy = phase === "creating" || phase === "streaming"; const busy =
phase === "creating" ||
phase === "streaming" ||
flowPhase === "extracting" ||
flowPhase === "validating";
return { return {
phase, phase,
@@ -29,7 +29,7 @@ import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"
/** /**
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。 * 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。 * 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧中当前工具调用的 args。
* *
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边 * 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。 * 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createInlineReasoningDeltaFormatter } from "./stream-reasoning";
test("provider reasoning chunks form one live think block before answer content", () => {
const format = createInlineReasoningDeltaFormatter();
assert.equal(
format({ id: "msg-1", reasoning: "先检查约束", content: "" }),
"<think>先检查约束",
);
assert.equal(
format({ id: "msg-1", reasoning: ",再组织答案", content: "" }),
",再组织答案",
);
assert.equal(
format({ id: "msg-1", reasoning: "", content: "最终结论" }),
"</think>最终结论",
);
});
@@ -0,0 +1,30 @@
export type StreamedAiTextDelta = {
content: string;
reasoning: string;
id: string;
};
/**
* Preserve an open `<think>` block across provider reasoning-only chunks and
* close it exactly when visible content begins. The resulting text feeds the
* same Step2Message renderer used for literal `<think>` model output.
*/
export function createInlineReasoningDeltaFormatter(): (delta: StreamedAiTextDelta) => string {
const reasoningOpenByMessageId = new Set<string>();
return (delta) => {
const key = delta.id || "__roundtable-anonymous-reasoning__";
let text = "";
if (delta.reasoning) {
if (!reasoningOpenByMessageId.has(key)) {
reasoningOpenByMessageId.add(key);
text += "<think>";
}
text += delta.reasoning;
}
if (delta.content) {
if (reasoningOpenByMessageId.delete(key)) text += "</think>";
text += delta.content;
}
return text;
};
}
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectRoundtableDeliverablePaths,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
} from "./streaming-artifacts";
test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
"未命名文件.md",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { path: "/mnt/user-data/outputs/report.m", content: "# 报告" }),
"未命名文件.md",
);
});
test("roundtable live-previews every write_file type but filters final cards", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告",
}),
"/mnt/user-data/outputs/report.md",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/workspace/fetch.py",
content: "print('fetch')",
}),
"/mnt/user-data/workspace/fetch.py",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{}]",
}),
"/mnt/user-data/outputs/task-reports.json",
);
assert.equal(
resolveRoundtableStreamingWritePreview("str_replace", { content: "# 报告" }),
null,
);
assert.deepEqual(
collectRoundtableDeliverablePaths([
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/page.html",
"/mnt/user-data/outputs/chart.mermaid",
"/mnt/user-data/workspace/fetch.py",
"/mnt/user-data/workspace/notes.txt",
]),
[
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/page.html",
"/mnt/user-data/outputs/chart.mermaid",
],
);
});
test("roundtable detects files created or overwritten by a skill without write_file SSE", () => {
const oldFile = {
path: "/mnt/user-data/outputs/report.md",
sizeBytes: 10,
modifiedAt: "2026-08-31T01:00:00.000Z",
};
const baseline = roundtableArtifactDiskSnapshot([oldFile]);
const overwritten = {
...oldFile,
sizeBytes: 20,
modifiedAt: "2026-08-31T01:00:05.000Z",
};
const generatedBySkill = {
path: "/mnt/user-data/outputs/chart.html",
sizeBytes: 30,
modifiedAt: "2026-08-31T01:00:06.000Z",
};
assert.deepEqual(
newestChangedRoundtableArtifact(
[overwritten, generatedBySkill],
baseline,
Date.parse("2026-08-31T01:00:04.000Z"),
),
generatedBySkill,
);
assert.equal(
newestChangedRoundtableArtifact(
[oldFile],
undefined,
Date.parse("2026-08-31T01:00:10.000Z"),
),
null,
);
assert.deepEqual(
newestChangedRoundtableArtifact(
[generatedBySkill],
undefined,
Date.parse("2026-08-31T01:00:07.000Z"),
),
generatedBySkill,
);
});
@@ -0,0 +1,113 @@
import {
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
/**
* Resolve the path that may be live-previewed while a roundtable tool call is
* still streaming.
*
* `write_file` is deliberately unrestricted here: the sandbox is a live tool
* activity view, not the final-deliverable list. Agents commonly write a
* temporary Python script before reading a skill or running it; suppressing
* that preview makes the page look stalled even though the backend is busy.
* The separate `collectRoundtableDeliverablePaths` gate remains responsible
* for limiting message-list file cards to Markdown, HTML, and Mermaid.
*
* `str_replace` retains its existing document-only behavior. It has no full
* content payload for a virtual preview, and is not a new-file write.
*/
export function resolveRoundtableStreamingWritePreview(
toolName: string | undefined,
args: Record<string, unknown> | undefined,
): string | null {
const rawPath = typeof args?.path === "string" ? args.path : "";
const path = normalizeStreamingArtifactPath(rawPath);
if (toolName === "write_file") {
// Weak models can emit content before the `path` JSON key. Give that same
// call a temporary name so the sandbox opens and continues receiving data.
const content = typeof args?.content === "string" ? args.content : "";
// A complete path of *any* file type is previewable. Keep an incomplete
// suffix such as `report.m` provisional while content is already flowing,
// so it upgrades cleanly to Markdown once the final `d` arrives.
if (path && (hasCompleteArtifactExtension(path) || !content.trim())) {
return path;
}
return content.trim() ? provisionalWriteFilePath(content) : null;
}
// Preserve the prior, conservative behavior for in-place replacement.
return languageOfStreamingArtifact(path) ? path : null;
}
/** Final artifact cards intentionally expose only user-facing deliverables. */
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
const seen = new Set<string>();
const paths: string[] = [];
for (const value of values) {
if (typeof value !== "string") continue;
const path = normalizeStreamingArtifactPath(value);
if (!path || !languageOfStreamingArtifact(path) || seen.has(path)) continue;
seen.add(path);
paths.push(path);
}
return paths;
}
export interface RoundtableWatchedArtifact {
path: string;
sizeBytes: number;
modifiedAt: string;
}
/** Stable disk signature used to notice both newly created and overwritten files. */
export function roundtableArtifactDiskSnapshot(
files: Iterable<RoundtableWatchedArtifact>,
): Map<string, string> {
const snapshot = new Map<string, string>();
for (const file of files) {
const path = normalizeStreamingArtifactPath(file.path);
if (!path) continue;
snapshot.set(path, `${file.sizeBytes}:${file.modifiedAt}`);
}
return snapshot;
}
/**
* Pick the newest file created or changed since the prior directory poll.
*
* On the first poll there is no baseline yet. A small grace window catches a
* skill output that lands between mounting the streaming bubble and the first
* HTTP response, without reopening unrelated files from older roundtable runs.
*/
export function newestChangedRoundtableArtifact(
files: readonly RoundtableWatchedArtifact[],
previous: ReadonlyMap<string, string> | undefined,
watchStartedAtMs: number,
firstPollGraceMs = 2_000,
): RoundtableWatchedArtifact | null {
let newest: RoundtableWatchedArtifact | null = null;
let newestTime = Number.NEGATIVE_INFINITY;
for (const file of files) {
const path = normalizeStreamingArtifactPath(file.path);
if (!path) continue;
const signature = `${file.sizeBytes}:${file.modifiedAt}`;
const modifiedTime = Date.parse(file.modifiedAt);
const changed = previous
? previous.get(path) !== signature
: Number.isFinite(modifiedTime) &&
modifiedTime >= watchStartedAtMs - firstPollGraceMs;
if (!changed) continue;
const sortableTime = Number.isFinite(modifiedTime) ? modifiedTime : 0;
if (!newest || sortableTime > newestTime) {
newest = { ...file, path };
newestTime = sortableTime;
}
}
return newest;
}
@@ -18,7 +18,7 @@
* *
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。 * 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
*/ */
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -1238,7 +1238,8 @@ export default function BusinessChainEditorPage() {
{ n: 1 as const, label: "链条编排" }, { n: 1 as const, label: "链条编排" },
{ n: 2 as const, label: "基本信息" }, { n: 2 as const, label: "基本信息" },
]).map((s, i) => ( ]).map((s, i) => (
<Fragment key={s.n}> // div.contents 代替 <Fragment>:dev tagger 注入 data-lov-id 会触发 React 警告。
<div className="contents" key={s.n}>
{i > 0 && <span className="h-px w-10 bg-border" />} {i > 0 && <span className="h-px w-10 bg-border" />}
<button <button
type="button" type="button"
@@ -1261,7 +1262,7 @@ export default function BusinessChainEditorPage() {
</span> </span>
{s.label} {s.label}
</button> </button>
</Fragment> </div>
))} ))}
</nav> </nav>
@@ -22,7 +22,6 @@
* 仍走旧实现,本页不复用它,避免互相牵连。 * 仍走旧实现,本页不复用它,避免互相牵连。
*/ */
import { import {
Fragment,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -853,8 +852,9 @@ function ChainFlow({ chain }: { chain: BusinessChain }) {
style={{ maxHeight: 220 }} style={{ maxHeight: 220 }}
> >
<div className="flex min-w-max items-stretch gap-3 py-1"> <div className="flex min-w-max items-stretch gap-3 py-1">
{/* div.contents 代替 <Fragment>:dev tagger 注入 data-lov-id 会触发 React 警告。 */}
{stages.map((stage, sIdx) => ( {stages.map((stage, sIdx) => (
<Fragment key={sIdx}> <div className="contents" key={sIdx}>
<div <div
className={cn( className={cn(
"flex shrink-0 flex-col rounded-xl border p-2.5", "flex shrink-0 flex-col rounded-xl border p-2.5",
@@ -897,7 +897,7 @@ function ChainFlow({ chain }: { chain: BusinessChain }) {
<ArrowRightIcon className="size-4 text-zinc-300 dark:text-[#4A6491]" /> <ArrowRightIcon className="size-4 text-zinc-300 dark:text-[#4A6491]" />
</div> </div>
)} )}
</Fragment> </div>
))} ))}
</div> </div>
</div> </div>
@@ -1054,8 +1054,9 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) {
const { stages, seatById } = useChainStages(chain); const { stages, seatById } = useChainStages(chain);
return ( return (
<div className="custom-scrollbar flex max-w-[280px] select-none items-center gap-1.5 overflow-x-auto py-1"> <div className="custom-scrollbar flex max-w-[280px] select-none items-center gap-1.5 overflow-x-auto py-1">
{/* div.contents 代替 <Fragment>:dev tagger 注入 data-lov-id 会触发 React 警告。 */}
{stages.map((stage, sIdx) => ( {stages.map((stage, sIdx) => (
<Fragment key={sIdx}> <div className="contents" key={sIdx}>
<div <div
className={cn( className={cn(
"flex shrink-0 items-center rounded border px-2 py-1", "flex shrink-0 items-center rounded border px-2 py-1",
@@ -1073,7 +1074,7 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) {
{sIdx < stages.length - 1 && ( {sIdx < stages.length - 1 && (
<span className="shrink-0 text-zinc-300 dark:text-[#4A6491]">→</span> <span className="shrink-0 text-zinc-300 dark:text-[#4A6491]">→</span>
)} )}
</Fragment> </div>
))} ))}
</div> </div>
); );
@@ -350,6 +350,11 @@ export default function RoundtablePlanningPage({
hideHeaderSettings = false, hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */ /** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig, taskArtifactSubmissionConfig,
/**
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
*/
step3Mode = "full",
}: { }: {
compactChrome?: boolean; compactChrome?: boolean;
forceDarkAppearance?: boolean; forceDarkAppearance?: boolean;
@@ -396,6 +401,7 @@ export default function RoundtablePlanningPage({
newTaskLabel?: string; newTaskLabel?: string;
hideHeaderSettings?: boolean; hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig; taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
step3Mode?: "full" | "report-only";
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。 // 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
@@ -403,6 +409,7 @@ export default function RoundtablePlanningPage({
// 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式, // 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式,
// 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。 // 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。
const isDark = forceDarkAppearance || theme === "dark"; const isDark = forceDarkAppearance || theme === "dark";
const hasFullStep3Outputs = step3Mode === "full";
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null); const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
@@ -1634,9 +1641,9 @@ export default function RoundtablePlanningPage({
.filter((g) => g.length > 0), .filter((g) => g.length > 0),
[orchestrationPlan], [orchestrationPlan],
); );
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物, // 普通会商:Markdown 报告完成后自动续跑结构化流程图;8BF:只完成报告即结束。
// 避免报告写完后额外触发一次结构化抽取请求。 // 这个开关由入口显式传入,而不是从业务链/任务状态推断,防止两类会商串行为。
const autoSummaryFlow = false; const autoSummaryFlow = hasFullStep3Outputs;
const summaryFlow = useStep3Summary({ const summaryFlow = useStep3Summary({
intentReady: step1.intentReady, intentReady: step1.intentReady,
@@ -1646,7 +1653,7 @@ export default function RoundtablePlanningPage({
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null, coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels, chainLevels,
autoFlow: autoSummaryFlow, autoFlow: autoSummaryFlow,
flowEnabled: false, flowEnabled: hasFullStep3Outputs,
savedSummary: step3Report?.summaryReport ?? null, savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved, onSummarySaved: handleSummarySaved,
ingestAvailable, ingestAvailable,
@@ -1753,28 +1760,30 @@ export default function RoundtablePlanningPage({
} else { } else {
summaryFlow.start(); summaryFlow.start();
} }
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker), if (hasFullStep3Outputs) {
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。 // ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
if (rep?.html && !isDashboardHtml(rep.html)) { // 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
reportFlow.hydrateSaved(rep); if (rep?.html && !isDashboardHtml(rep.html)) {
} else { reportFlow.hydrateSaved(rep);
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开 } else {
} staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。 }
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。 // ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
const dashSaved = { // 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
dialogues: rep?.dashboardDialogues, const dashSaved = {
seatStubMessages: rep?.dashboardSeatStubMessages, dialogues: rep?.dashboardDialogues,
}; seatStubMessages: rep?.dashboardSeatStubMessages,
if (rep?.dashboardJson) { };
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved); if (rep?.dashboardJson) {
} else if (rep?.html && isDashboardHtml(rep.html)) { dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
dashboardFlow.hydrateSaved(rep.html, dashSaved); } else if (rep?.html && isDashboardHtml(rep.html)) {
} else { dashboardFlow.hydrateSaved(rep.html, dashSaved);
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2); } else {
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]); }, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers, hasFullStep3Outputs]);
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**, // 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。 // 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
@@ -2166,6 +2175,8 @@ export default function RoundtablePlanningPage({
onStopIntent: step1.stopIntentStream, onStopIntent: step1.stopIntentStream,
onSkipQuestion: handleSkipQuestion, onSkipQuestion: handleSkipQuestion,
onFollowUp: handleAgentFollowUp, onFollowUp: handleAgentFollowUp,
// 多智能体会商页去掉「继续追问」「跳过此项」快捷键 —— 干预走输入框直接发送即可。
hideFollowupActions: true,
onEnterRoundtable: () => { onEnterRoundtable: () => {
if (!step1.intentReady) { if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入"); triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
@@ -2606,6 +2617,7 @@ export default function RoundtablePlanningPage({
<Step3Panel <Step3Panel
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false} useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
headerTitle={step3HeaderTitle} headerTitle={step3HeaderTitle}
step3Mode={step3Mode}
threadIds={step2.threadIds} threadIds={step2.threadIds}
coordinatorName={step2.coordinatorName} coordinatorName={step2.coordinatorName}
consensusText={step2.lastLeaderContent} consensusText={step2.lastLeaderContent}
@@ -4343,7 +4355,7 @@ function IntentSummaryCard({
)} )}
{showFullDetail && ( {showFullDetail && (
<ul className="text-xs text-slate-700 space-y-2.5 font-normal"> <ul className="text-xs text-slate-700 space-y-2.5 font-normal">
{intentReady.constraints.map((c, i) => ( {(intentReady.constraints ?? []).map((c, i) => (
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed"> <li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-primary text-base leading-none">•</span> <span className="text-primary text-base leading-none">•</span>
<span> <span>
@@ -199,6 +199,39 @@ The backend is split into two layers with a strict dependency direction:
**Dependency rule**: App imports deerflow, but deerflow never imports app. This boundary is enforced by `tests/test_harness_boundary.py` which runs in CI. **Dependency rule**: App imports deerflow, but deerflow never imports app. This boundary is enforced by `tests/test_harness_boundary.py` which runs in CI.
### Workflow Studio
Coze Studio is canvas-only; ChatDev is reference-only. Plan + status per phase: `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`; decisions: `docs/adr/0001-workflow-studio-runtime.md`; contracts: `docs/WORKFLOW_SCHEMA_V1_ZH.md` + `docs/WORKFLOW_SSE_V1_ZH.md` + `docs/WORKFLOW_OPENAPI_DRAFT_V1.yaml`. Master switch + all limits: `config.yaml` → `workflows` (`deerflow.config.workflow_config.WorkflowConfig`).
**Execution kernel is a custom DAG scheduler, not a compiled LangGraph.** LangGraph stays the kernel *inside* an `agent`/`skill` node. The reason is single-source-of-truth recovery: a re-leased or resumed run replays completed node outputs from `workflow_node_runs` plus the `workflow_run_events` log, so a second (checkpoint) copy of workflow-level state would have no defensible tie-breaker. See the ADR's phase-2 amendment.
**Harness** (`packages/harness/deerflow/workflows/`, never imports `app.*`):
- `schemas` / `events` / `errors` / `ports` / `validator` / `samples` — frozen v1 contracts.
- `expressions.py` — `{{ inputs.x }}` / `{{ nodes.n.data.y }}` templates and conditions resolved by path walking + a small comparator set. **No `eval`.** Roots are restricted to `inputs` / `nodes` / `run` / `loop` / `env`.
- `runtime/engine.py` — the scheduler. Every edge is `pending` → `active` (source chose this port) or `pruned`; a node is ready when its non-back incoming edges are all resolved and at least one is `active`, and is skipped when all are `pruned` (skips propagate). That one rule covers fan-out, condition branching, and merge joins with no topological pre-pass. The `loop` node's `continue` back edge is the only permitted cycle; re-entry clears the body's results and invalidates its node runs.
- `runtime/{sink,sql_runner,code_runner,context}.py`, `nodes/*` (13 executors), `security/{http_policy,sql_policy,secret_box}.py`.
**Persistence**: `workflow_definitions` / `workflow_versions` (migration `20260828_01`), `workflow_runs` / `workflow_node_runs` / `workflow_run_events` / `workflow_run_artifacts` (`20260828_02`), `workflow_data_sources` (`20260828_03`). Stores under `deerflow.persistence.workflow_{runs,events,data_sources}` with SQL + in-memory implementations.
**App layer**: `workflow_dispatcher.py` (lease scan → atomic claim; an expired lease is how a dead worker's run is reclaimed, so every worker can run the loop), `workflow_executor.py` (one lease's lifecycle: replay → engine under a run timeout with heartbeat + cancel watcher → exactly one terminal transition; also runs subworkflows inline, depth ≤ 3), `workflow_retention.py` (hourly sweep of `workflows.retention.event_retention_days` / `run_retention_days`), `workflow_live_hub.py` (in-process SSE fan-out), `workflow_agent_runner.py` (agent/skill nodes → lead agent).
**Gateway routes** (register static prefixes before `workflows.router`'s `/{workflow_id}` catch-alls): `/api/workflows` CRUD/draft/validate/publish/copy, `/api/workflows/node-types` + `/resources/*`, `/api/workflows/{id}/runs` + `/api/workflows/runs/{run_id}{,/events,/stream,/cancel,/resume,/retry,/artifacts}`, `/api/workflows/data-sources` (+ `/{id}/introspect`, `/{id}/validate-query`), `/api/workflows/embed/{tickets,verify}`, `/api/workflow_api/*` Coze compat.
**Guarantees worth not breaking**: events are persisted *then* published, and the DB is authoritative for `seq` ordering (live frames at or below the replay cursor are dropped and re-read on the next poll, so a reconnect sees no gap; `Last-Event-ID` wins over `?after=`); a run reaching a terminal status always gets a terminal event, synthesised if necessary, or SSE clients hang; `resumeToken` appears only in the `run.awaiting_input` event, never in the run view; `POST /retry` on a `failed` run copies completed node outputs onto a new run marked `retry_of_run_id` so the engine resumes from the failed node; data-source secrets are write-only (encrypted via `WORKFLOW_SECRET_KEY`, decrypted only inside the executor, reads expose `masked_target`); the `http` node denies private/loopback/link-local targets by default (SSRF), `sql_read` allows one read-only statement with bound parameters, and the `code` node is **off by default** because its runner is a hardened subprocess rather than a container.
HTTP interface resources persist a non-secret base_url plus an allowed_methods allowlist (migration
20260829_01); request headers stay only in the encrypted secret blob. The resource catalog may return
the address and methods to the owning user, but must never return headers or any decrypted secret. The
executor receives HTTP metadata only through its data-source resolver.
The Workflow Studio agent/skill resource catalog is a read-only display projection over the existing
agent and skill ownership stores. It returns stable `agentId` / `skillId`, an agent's cached skill names,
and a `createdBy` display label. Resolve human labels from the immutable owner id at request time and
fall back to that id if lookup fails; built-in records must return `系统内置`. Do not create a parallel
creator table or accept creator data from the browser.
Tests: `tests/test_workflow_schemas.py`, `tests/test_workflow_definitions.py`, `tests/test_workflow_engine.py`, `tests/test_workflow_runs.py`.
**Import conventions**: **Import conventions**:
```python ```python
# Harness internal # Harness internal
@@ -168,6 +168,21 @@ FastAPI application providing REST endpoints for frontend integration:
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges local Vite origins `http://localhost|127.0.0.1:{5173,5174,3000,8080}`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. This keeps a Vite server that falls back from 5173 to 5174 (or the reverse) working after a backend restart. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway. The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges local Vite origins `http://localhost|127.0.0.1:{5173,5174,3000,8080}`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. This keeps a Vite server that falls back from 5173 to 5174 (or the reverse) working after a backend restart. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
### Workflow Studio interface resources
The Workflow Studio data-source API also registers reusable HTTP interface resources. Create them through
POST /api/workflows/data-sources with kind set to http; provide a non-secret baseUrl, an allowedMethods
allowlist, and optional request headers. The service encrypts the headers immediately and never returns
them through list, detail, resource-catalog, run-event, or canvas APIs. The canvas resource catalog
exposes only the name, description, HTTP address, and allowed methods. HTTP execution continues to
enforce the workflow target security policy, including the default SSRF restrictions.
The same catalog's agent and skill entries are display projections, not a second ownership store:
`/api/workflows/resources/agents` returns `agentId`, cached available `skills`, and `createdBy`; the
skills endpoint returns `skillId`, direct-call capability, and `createdBy`. Creator names are resolved
from the immutable owner id at read time (falling back to the id if an account was removed), while
built-in resources explicitly return `系统内置`.
### Deep Research modes ### Deep Research modes
Deep Research is a separate Gateway feature rather than part of AI Writing. Its Deep Research is a separate Gateway feature rather than part of AI Writing. Its
@@ -27,10 +27,10 @@ from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.deps import langgraph_runtime from app.gateway.deps import langgraph_runtime
from app.gateway.public_knowledge_cors import PublicKnowledgeCORSMiddleware from app.gateway.public_knowledge_cors import PublicKnowledgeCORSMiddleware
from app.gateway.routers import ( from app.gateway.routers import (
ai_writing,
admin_active_runs, admin_active_runs,
admin_users, admin_users,
agents, agents,
ai_writing,
artifacts, artifacts,
assistants_compat, assistants_compat,
auth, auth,
@@ -39,11 +39,12 @@ from app.gateway.routers import (
channels, channels,
checkpoint_migration, checkpoint_migration,
compact_menu_overrides, compact_menu_overrides,
dashboard_sessions,
deep_research,
external_llmwiki, external_llmwiki,
feedback, feedback,
fixed_questions, fixed_questions,
dashboard_sessions, flow_extract,
deep_research,
html_page_favorites, html_page_favorites,
intent, intent,
knowledge, knowledge,
@@ -63,19 +64,19 @@ from app.gateway.routers import (
open_chat, open_chat,
parallel_agent_tasks, parallel_agent_tasks,
parallel_agents, parallel_agents,
position_roles,
position_roundtable, position_roundtable,
positions, positions,
position_roles,
public_config, public_config,
public_embed, public_embed,
public_knowledge_vector_search, public_knowledge_vector_search,
public_leaderboard, public_leaderboard,
public_scheduled_tasks, public_scheduled_tasks,
recommend, recommend,
flow_extract,
recommended_questions, recommended_questions,
roundtable_chains, report_structures,
roundtable_artifact_submissions, roundtable_artifact_submissions,
roundtable_chains,
roundtable_diagnostics, roundtable_diagnostics,
roundtable_draft_shares, roundtable_draft_shares,
roundtable_drafts, roundtable_drafts,
@@ -91,16 +92,22 @@ from app.gateway.routers import (
system_settings, system_settings,
tags, tags,
task_buttons, task_buttons,
taskcop_tasks,
report_structures,
task_reports, task_reports,
taskcop_tasks,
thread_runs, thread_runs,
tool_metrics,
thread_shares, thread_shares,
threads, threads,
tool_metrics,
uploads, uploads,
user_preferences, user_preferences,
user_prompts, user_prompts,
workflow_data_sources,
workflow_embed,
workflow_resources,
workflow_runs,
workflow_studio,
workflows,
workflows_coze_compat,
writing, writing,
) )
from deerflow.config import app_config as deerflow_app_config from deerflow.config import app_config as deerflow_app_config
@@ -342,10 +349,10 @@ async def _run_db_migrations() -> None:
deadline = asyncio.get_running_loop().time() + timeout_s deadline = asyncio.get_running_loop().time() + timeout_s
while result_queue.empty(): while result_queue.empty():
if asyncio.get_running_loop().time() >= deadline: if asyncio.get_running_loop().time() >= deadline:
raise asyncio.TimeoutError raise TimeoutError
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
result = result_queue.get_nowait() result = result_queue.get_nowait()
except asyncio.TimeoutError: except TimeoutError:
logger.error( logger.error(
"Database migration did not finish within %.0fs; continuing startup on create_all schema. " "Database migration did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 " "Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
@@ -989,11 +996,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info(f"Starting API Gateway on {config.host}:{config.port}") logger.info(f"Starting API Gateway on {config.host}:{config.port}")
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store) # Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
async with langgraph_runtime(app): async with langgraph_runtime(app):
logger.info("LangGraph runtime initialised") logger.info("LangGraph runtime initialised")
# Run Alembic migrations before anything else touches the schema. # One-time repair switch for a failed, unused Workflow Studio schema.
await _run_db_migrations() # The helper is intentionally MySQL-only and drops an exact allow-list
# in the configured current database; it never expands ``workflow_%``.
# Keep this opt-in so established workflow data is never cleared during
# an ordinary restart.
if os.getenv("DEER_FLOW_REBUILD_WORKFLOW_SCHEMA", "").lower() in {"1", "true", "yes"}:
from deerflow.persistence.engine import rebuild_workflow_schema_for_mysql
logger.warning(
"DEER_FLOW_REBUILD_WORKFLOW_SCHEMA is enabled; rebuilding the unused workflow schema"
)
await rebuild_workflow_schema_for_mysql()
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# Local Wiki mirror/index scans are opt-in and run outside request # Local Wiki mirror/index scans are opt-in and run outside request
# handling. Per-library database leases keep multi-worker startup safe. # handling. Per-library database leases keep multi-worker startup safe.
@@ -1591,6 +1611,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Light-app registry (?????) at /api/light-apps # Light-app registry (?????) at /api/light-apps
app.include_router(light_apps.router) app.include_router(light_apps.router)
# Workflow Studio: definitions / runs / SSE / data sources / embed / Coze compat.
# Static prefixes (node-types, resources, runs, data-sources, embed) must be
# registered before the /{workflow_id} catch-alls in workflows.router.
app.include_router(workflow_resources.router)
app.include_router(workflow_data_sources.router)
app.include_router(workflow_embed.router)
app.include_router(workflow_runs.router)
app.include_router(workflow_studio.router)
app.include_router(workflows.router)
app.include_router(workflows_coze_compat.router)
app.include_router(workflows_coze_compat.chrome_router)
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides # Sidebar-menu overrides (菜单管理) at /api/menu-overrides
app.include_router(menu_overrides.router) app.include_router(menu_overrides.router)
@@ -4,25 +4,25 @@ Per RFC-001:
State-changing operations require CSRF protection. State-changing operations require CSRF protection.
""" """
import secrets import secrets
from collections.abc import Callable from collections.abc import Callable
from fastapi import Request, Response from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import ASGIApp from starlette.types import ASGIApp
from app.gateway.auth.disabled_mode import is_auth_disabled from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.proxy_path import app_relative_path from app.gateway.proxy_path import app_relative_path
from app.gateway.weknora_embed import ( from app.gateway.weknora_embed import (
WEKNORA_EMBED_COOKIE, WEKNORA_EMBED_COOKIE,
has_valid_weknora_embed_cookie, has_valid_weknora_embed_cookie,
is_weknora_embed_proxy_path, is_weknora_embed_proxy_path,
) )
CSRF_COOKIE_NAME = "csrf_token" CSRF_COOKIE_NAME = "csrf_token"
CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_TOKEN_LENGTH = 64 # bytes CSRF_TOKEN_LENGTH = 64 # bytes
def is_secure_request(request: Request) -> bool: def is_secure_request(request: Request) -> bool:
@@ -35,7 +35,7 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH) return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
def should_check_csrf(request: Request) -> bool: def should_check_csrf(request: Request) -> bool:
"""Determine if a request needs CSRF validation. """Determine if a request needs CSRF validation.
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH). CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
@@ -49,12 +49,12 @@ def should_check_csrf(request: Request) -> bool:
path = app_relative_path(request) path = app_relative_path(request)
stripped = path.rstrip("/") stripped = path.rstrip("/")
# Exempt /api/v1/auth/me endpoint # Exempt /api/v1/auth/me endpoint
if stripped == "/api/v1/auth/me": if stripped == "/api/v1/auth/me":
return False return False
if stripped == "/api/knowledge/vector-search": if stripped == "/api/knowledge/vector-search":
return False return False
if stripped == "/api/writing/export/docx": if stripped == "/api/writing/export/docx":
return False return False
# Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES). # Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES).
# These intentionally have no cookie session, so the Double-Submit Cookie # These intentionally have no cookie session, so the Double-Submit Cookie
# pattern is both impossible (no token to compare) and unnecessary (no # pattern is both impossible (no token to compare) and unnecessary (no
@@ -64,18 +64,37 @@ def should_check_csrf(request: Request) -> bool:
return False return False
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES), # 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。 # 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
if path.startswith("/api/open/"): if path.startswith("/api/open/"):
return False return False
if is_weknora_embed_proxy_request(request): if is_coze_studio_compat_path(path):
return False return False
return True if is_weknora_embed_proxy_request(request):
return False
return True
def is_weknora_embed_proxy_request(request: Request) -> bool:
path = app_relative_path(request)
if not is_weknora_embed_proxy_path(path): # Coze playground 自己的 axios 不会带 X-CSRF-Token;独立编排页经
return False # localhost:8080 同源代理访问这些接口。Cookie 会话仍由 AuthMiddleware 校验。
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE)) _COZE_STUDIO_PREFIXES: tuple[str, ...] = (
"/api/passport/",
"/api/playground_api/",
"/api/workflow_api/",
"/api/space/",
)
def is_coze_studio_compat_path(path: str) -> bool:
stripped = path.rstrip("/")
if stripped == "/api/bot/get_type_list":
return True
return any(path.startswith(prefix) for prefix in _COZE_STUDIO_PREFIXES)
def is_weknora_embed_proxy_request(request: Request) -> bool:
path = app_relative_path(request)
if not is_weknora_embed_proxy_path(path):
return False
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset( _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
@@ -90,7 +109,7 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/initialize", "/api/v1/auth/initialize",
# 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。 # 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。
"/api/parallel-agents/auth/token", "/api/parallel-agents/auth/token",
} }
) )
@@ -94,15 +94,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.run_store = RunRepository(sf) app.state.run_store = RunRepository(sf)
app.state.feedback_repo = FeedbackRepository(sf) app.state.feedback_repo = FeedbackRepository(sf)
else: else:
from deerflow.runtime.runs.store.memory import MemoryRunStore from deerflow.runtime.runs.store.memory import MemoryRunStore
app.state.run_store = MemoryRunStore() app.state.run_store = MemoryRunStore()
app.state.feedback_repo = None app.state.feedback_repo = None
from deerflow.persistence.fixed_questions import make_fixed_question_store from deerflow.persistence.fixed_questions import make_fixed_question_store
app.state.fixed_question_store = make_fixed_question_store(sf) app.state.fixed_question_store = make_fixed_question_store(sf)
from deerflow.persistence.thread_meta import make_thread_store from deerflow.persistence.thread_meta import make_thread_store
@@ -285,6 +285,18 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.light_app_store = make_light_app_store(sf) app.state.light_app_store = make_light_app_store(sf)
from deerflow.persistence.workflows import make_workflow_store
app.state.workflow_store = make_workflow_store(sf)
from deerflow.persistence.workflow_data_sources import make_workflow_data_source_store
from deerflow.persistence.workflow_events import make_workflow_event_store
from deerflow.persistence.workflow_runs import make_workflow_run_store
app.state.workflow_run_store = make_workflow_run_store(sf)
app.state.workflow_event_store = make_workflow_event_store(sf, run_store=app.state.workflow_run_store)
app.state.workflow_data_source_store = make_workflow_data_source_store(sf)
from deerflow.persistence.menu_overrides import make_menu_override_store from deerflow.persistence.menu_overrides import make_menu_override_store
app.state.menu_override_store = make_menu_override_store(sf) app.state.menu_override_store = make_menu_override_store(sf)
@@ -312,72 +324,72 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.integrations.weknora.runtime import register_llmwiki_store from deerflow.integrations.weknora.runtime import register_llmwiki_store
from deerflow.persistence.llmwiki import make_llmwiki_store from deerflow.persistence.llmwiki import make_llmwiki_store
app.state.llmwiki_store = make_llmwiki_store(sf) app.state.llmwiki_store = make_llmwiki_store(sf)
register_llmwiki_store(app.state.llmwiki_store) register_llmwiki_store(app.state.llmwiki_store)
# DeerFlow-local Wiki mirror/vector index. The store is always wired so # DeerFlow-local Wiki mirror/vector index. The store is always wired so
# migrations and status inspection work while feature flags are off; # migrations and status inspection work while feature flags are off;
# network/search services only exist when explicitly enabled. # network/search services only exist when explicitly enabled.
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
local_index_config = config.llmwiki.local_wiki_index local_index_config = config.llmwiki.local_wiki_index
app.state.llmwiki_index_store = make_llmwiki_index_store( app.state.llmwiki_index_store = make_llmwiki_index_store(
sf, sf,
write_batch_size=local_index_config.database_batch_size, write_batch_size=local_index_config.database_batch_size,
) )
app.state.llmwiki_embedding = None app.state.llmwiki_embedding = None
app.state.llmwiki_vector_cache = None app.state.llmwiki_vector_cache = None
app.state.llmwiki_vector_search = None app.state.llmwiki_vector_search = None
app.state.llmwiki_sync_service = None app.state.llmwiki_sync_service = None
app.state.llmwiki_retrieval_service = None app.state.llmwiki_retrieval_service = None
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
resolved_llmwiki = get_resolved_llmwiki_runtime(config) resolved_llmwiki = get_resolved_llmwiki_runtime(config)
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
if local_index_config.enabled: if local_index_config.enabled:
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(local_index_config.embedding) app.state.llmwiki_embedding = StrictWikiEmbeddingClient(local_index_config.embedding)
app.state.llmwiki_vector_cache = WikiVectorCache( app.state.llmwiki_vector_cache = WikiVectorCache(
app.state.llmwiki_index_store, app.state.llmwiki_index_store,
max_knowledge_bases=local_index_config.cache_max_knowledge_bases, max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
revision_check_seconds=local_index_config.cache_revision_check_seconds, revision_check_seconds=local_index_config.cache_revision_check_seconds,
) )
app.state.llmwiki_vector_search = WikiVectorSearchService( app.state.llmwiki_vector_search = WikiVectorSearchService(
local_index_config, local_index_config,
app.state.llmwiki_embedding, app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache, app.state.llmwiki_vector_cache,
) )
if weknora_client is not None: if weknora_client is not None:
app.state.llmwiki_sync_service = WikiSyncService( app.state.llmwiki_sync_service = WikiSyncService(
local_index_config, local_index_config,
app.state.llmwiki_index_store, app.state.llmwiki_index_store,
app.state.llmwiki_embedding, app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache, app.state.llmwiki_vector_cache,
weknora_client, weknora_client,
) )
if weknora_client is not None or app.state.llmwiki_vector_search is not None: if weknora_client is not None or app.state.llmwiki_vector_search is not None:
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
app.state.llmwiki_retrieval_service = WikiRetrievalService( app.state.llmwiki_retrieval_service = WikiRetrievalService(
app.state.llmwiki_index_store, app.state.llmwiki_index_store,
vector_search=( vector_search=(
app.state.llmwiki_vector_search app.state.llmwiki_vector_search
if local_index_config.internal_search_enabled if local_index_config.internal_search_enabled
else None else None
), ),
client=weknora_client, client=weknora_client,
) )
register_local_wiki_index_runtime( register_local_wiki_index_runtime(
store=app.state.llmwiki_index_store, store=app.state.llmwiki_index_store,
search=app.state.llmwiki_vector_search, search=app.state.llmwiki_vector_search,
sync=app.state.llmwiki_sync_service, sync=app.state.llmwiki_sync_service,
retrieval=app.state.llmwiki_retrieval_service, retrieval=app.state.llmwiki_retrieval_service,
) )
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
@@ -555,6 +567,47 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.deep_research_executor = None app.state.deep_research_executor = None
app.state.deep_research_dispatcher = None app.state.deep_research_dispatcher = None
# ── Workflow Studio runtime: live hub + executor + lease dispatcher ──
# Every worker runs a dispatcher; the atomic claim decides ownership and
# an expired lease is how a crashed worker's run gets picked back up.
workflow_cfg = getattr(config, "workflows", None)
if workflow_cfg is not None and workflow_cfg.enabled:
from app.gateway.workflow_dispatcher import WorkflowRunDispatcher
from app.gateway.workflow_executor import WorkflowRunExecutor
from app.gateway.workflow_live_hub import WorkflowLiveHub
app.state.workflow_live_hub = WorkflowLiveHub()
app.state.workflow_executor = WorkflowRunExecutor(
app,
run_store=app.state.workflow_run_store,
event_store=app.state.workflow_event_store,
workflow_store=app.state.workflow_store,
data_source_store=app.state.workflow_data_source_store,
live_publisher=app.state.workflow_live_hub.publish,
config=workflow_cfg,
)
app.state.workflow_dispatcher = WorkflowRunDispatcher(
app.state.workflow_run_store,
app.state.workflow_executor,
event_store=app.state.workflow_event_store,
live_publisher=app.state.workflow_live_hub.publish,
config=workflow_cfg,
)
app.state.workflow_dispatcher.start()
from app.gateway.workflow_retention import WorkflowRetentionCleaner
app.state.workflow_retention = WorkflowRetentionCleaner(
app.state.workflow_run_store,
app.state.workflow_event_store,
config=workflow_cfg,
)
app.state.workflow_retention.start()
else:
app.state.workflow_live_hub = None
app.state.workflow_executor = None
app.state.workflow_dispatcher = None
app.state.workflow_retention = None
# Run event store (has its own factory with config-driven backend selection) # Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None) run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config) app.state.run_event_store = make_run_event_store(run_events_config)
@@ -565,16 +618,16 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.concurrency_gate = make_redis_concurrency_gate(config) app.state.concurrency_gate = make_redis_concurrency_gate(config)
try: try:
yield yield
finally: finally:
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None) register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None) llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
if llmwiki_embedding is not None: if llmwiki_embedding is not None:
await llmwiki_embedding.close() await llmwiki_embedding.close()
from deerflow.persistence.notifications import set_notification_store from deerflow.persistence.notifications import set_notification_store
set_notification_store(None) set_notification_store(None)
dispatcher = getattr(app.state, "roundtable_job_dispatcher", None) dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
@@ -583,6 +636,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
dr_dispatcher = getattr(app.state, "deep_research_dispatcher", None) dr_dispatcher = getattr(app.state, "deep_research_dispatcher", None)
if dr_dispatcher is not None: if dr_dispatcher is not None:
await dr_dispatcher.stop() await dr_dispatcher.stop()
wf_dispatcher = getattr(app.state, "workflow_dispatcher", None)
if wf_dispatcher is not None:
await wf_dispatcher.stop()
wf_retention = getattr(app.state, "workflow_retention", None)
if wf_retention is not None:
await wf_retention.stop()
from deerflow.workflows.runtime.sql_runner import dispose_engines
await dispose_engines()
queue = getattr(app.state, "knowledge_auto_ingest_queue", None) queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
if queue is not None: if queue is not None:
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
@@ -795,17 +857,17 @@ _cached_local_provider: LocalAuthProvider | None = None
_cached_repo: SQLiteUserRepository | None = None _cached_repo: SQLiteUserRepository | None = None
def get_request_access_token(request: Request) -> str | None: def get_request_access_token(request: Request) -> str | None:
"""Extract the caller identity, preferring the explicit Bearer header. """Extract the caller identity, preferring the explicit Bearer header.
An iframe or another tab can rewrite the origin-wide cookie, while the An iframe or another tab can rewrite the origin-wide cookie, while the
frontend deliberately sources its Bearer token from this tab's auth store. frontend deliberately sources its Bearer token from this tab's auth store.
""" """
from app.gateway.auth.token_source import get_explicit_or_session_token from app.gateway.auth.token_source import get_explicit_or_session_token
request_token = get_explicit_or_session_token(request) request_token = get_explicit_or_session_token(request)
if request_token: if request_token:
return request_token return request_token
from app.gateway.proxy_path import app_relative_path from app.gateway.proxy_path import app_relative_path
@@ -31,8 +31,7 @@ from app.gateway.roundtable_model_fallback import (
reason_text, reason_text,
) )
from app.gateway.roundtable_run_policy import roundtable_run_policy from app.gateway.roundtable_run_policy import roundtable_run_policy
from app.gateway.roundtable_seat_skills import append_seat_skill_directive from app.gateway.roundtable_seat_skills import append_seat_role_boundary, append_seat_skill_directive
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID, COORDINATOR_AGENT_ID,
DASHBOARD_AGENT_ID, DASHBOARD_AGENT_ID,
@@ -40,6 +39,7 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID, SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents, ensure_roundtable_functional_agents,
) )
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD
from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text
from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
from deerflow.agents.roundtable_orchestrator.types import ( from deerflow.agents.roundtable_orchestrator.types import (
@@ -806,6 +806,8 @@ class InProcessRoundtableGateway:
seat_task = append_seat_skill_directive( seat_task = append_seat_skill_directive(
agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False)) agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False))
) )
# 后台挂起路径与前台保持同一角色边界:席位只能执行具体任务,编排能力永远只属于总控。
seat_task = append_seat_role_boundary(seat_task)
# 每席位「推理深度覆盖」:业务链条里给该席位单独配了 seatMode → 用该档派生的 # 每席位「推理深度覆盖」:业务链条里给该席位单独配了 seatMode → 用该档派生的
# thinking/reasoning/subagent **覆盖作业级**(self._seat_*);未配则跟随作业级。 # thinking/reasoning/subagent **覆盖作业级**(self._seat_*);未配则跟随作业级。
# 作业级仍为 None 时落 policy 默认。与前端 useStep2Orchestration「覆盖优先、否则跟随 # 作业级仍为 None 时落 policy 默认。与前端 useStep2Orchestration「覆盖优先、否则跟随
@@ -37,6 +37,22 @@ logger = logging.getLogger(__name__)
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"}) _TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
_FALSY = frozenset({"0", "false", "no", "off", "n"}) _FALSY = frozenset({"0", "false", "no", "off", "n"})
# 编排能力是圆桌总控的专属能力。两种拼写分别对应内置 tool 名和历史 custom skill
# 目录名;席位侧无论 agent 配置或业务链条开关如何,都不得把它们暴露进技能强化提示。
COORDINATOR_ONLY_SKILL_NAMES = frozenset({"agent_orchestration", "agent-orchestration"})
_SEAT_ROLE_BOUNDARY = (
"【圆桌席位角色边界(最高优先级)】你是负责完成具体任务的执行席位,不是总控智能体。"
"编排、派活和调度其它智能体的能力只允许总控智能体使用。"
"你不得加载、读取或调用 agent_orchestration / agent-orchestration,"
"不得向其它智能体派活,也不要声称准备使用编排技能;请直接完成上方交给你的具体任务并提交结果。"
)
def append_seat_role_boundary(task: str) -> str:
"""在席位本轮 human 消息末尾追加不可越权的角色边界。"""
return f"{task}\n\n---\n\n{_SEAT_ROLE_BOUNDARY}"
def _master_enabled() -> bool: def _master_enabled() -> bool:
"""``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 总开关:默认放行(kill-switch)。 """``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 总开关:默认放行(kill-switch)。
@@ -65,7 +81,11 @@ def build_seat_skill_directive(agent_id: str | None, *, chain_enabled: bool = Fa
if not (agent_enabled or chain_enabled): if not (agent_enabled or chain_enabled):
return "" return ""
configured = [name for name in (cfg.skills or []) if name] if cfg else [] configured = [
name
for name in (cfg.skills or [])
if name and name.strip().lower() not in COORDINATOR_ONLY_SKILL_NAMES
] if cfg else []
if not configured: if not configured:
return "" return ""
@@ -14,7 +14,14 @@
### 2. 生成报告文件 ### 2. 生成报告文件
信息足够后,用 `write_file` 一次性写两份文件(路径固定,覆盖旧版): 信息足够后,**必须用 `write_file` 分两次写文件**(路径固定,覆盖旧版)。目的是让用户在沙箱里**边写边看**,而不是等两份文件全部生成完才一次性弹出。
**顺序(违反即失败):**
1. **先**只调用一次 `write_file` 写 `/mnt/user-data/outputs/report.md`。这一次模型回复里禁止再夹带第二个 `write_file`、禁止夹带 `bash`。工具返回 OK 后立刻 `present_files`,参数只含这一份 md。
2. 等第一步工具都返回后,再单独一次 `write_file` 写 `/mnt/user-data/outputs/task-reports.json`。写完后再 `present_files` 两份文件。
3. **禁止**用 `bash` / `python -c` / 重定向 / `echo` / `cat <<` 创建或覆盖这两份交付文件。`bash` 只允许在入库步骤跑 `task_report_import.py`,或做只读校验(不得写入 `/mnt/user-data/outputs/`)。
4. **禁止**在同一次模型回复里同时发出两个 `write_file`。
- `/mnt/user-data/outputs/report.md` —— Markdown 研判报告,章节按下方 14 个分类组织,每章给出要点与分析。 - `/mnt/user-data/outputs/report.md` —— Markdown 研判报告,章节按下方 14 个分类组织,每章给出要点与分析。
- `/mnt/user-data/outputs/task-reports.json` —— 结构化 JSON 数组,**14 条记录一个不落**,每条形如 `{"contentJson": "<JSON 字符串>", "categoryType": "<分类名>"}`。 - `/mnt/user-data/outputs/task-reports.json` —— 结构化 JSON 数组,**14 条记录一个不落**,每条形如 `{"contentJson": "<JSON 字符串>", "categoryType": "<分类名>"}`。
@@ -68,6 +75,7 @@ python /mnt/skills/public/task-report-import/scripts/task_report_import.py --tas
## 硬性约束 ## 硬性约束
- **先检索后报告**:没有真实检索材料的报告不允许生成。 - **先检索后报告**:没有真实检索材料的报告不允许生成。
- **先 md 后 json、禁止并行两个 write_file**:第一份报告必须先 `write_file` + `present_files` 让沙箱实时打开;json 放在下一轮工具调用。交付文件禁止用 bash/python 写盘。
- `task-reports.json` 必须是合法 JSON 数组:14 个分类一条不落、`categoryType` 逐字匹配、内容全部为非空中文文字、无 `\uXXXX` 转义。 - `task-reports.json` 必须是合法 JSON 数组:14 个分类一条不落、`categoryType` 逐字匹配、内容全部为非空中文文字、无 `\uXXXX` 转义。
- 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。 - 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。
- **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。 - **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。
@@ -25,8 +25,8 @@
------------------ ------------------
通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加 通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到 **只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream`` 子 agent 完成摘要。总控输入、派活 prompt 和派活说明只留在总控 thread,
驱动各 agent 真正推理。 绝不广播给席位;前端编排循环仍靠显式多次 ``/run/stream`` 驱动各 agent 真正推理。
Leader 终态帧(前端 ``isFinalStatusFrame``) Leader 终态帧(前端 ``isFinalStatusFrame``)
------------------------------------------- -------------------------------------------
@@ -48,7 +48,6 @@ Leader 终态帧(前端 ``isFinalStatusFrame``)
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import json import json
import logging import logging
import os import os
@@ -67,8 +66,7 @@ from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_diag import record_foreground_diag from app.gateway.roundtable_diag import record_foreground_diag
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
from app.gateway.services import RUN_SCOPE_ORCHESTRATION_CHILD, internal_run_scope_headers from app.gateway.roundtable_seat_skills import append_seat_role_boundary, append_seat_skill_directive
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
ACTION_PLAN_AGENT_ID, ACTION_PLAN_AGENT_ID,
COORDINATOR_AGENT_ID, COORDINATOR_AGENT_ID,
@@ -78,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID, SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents, ensure_roundtable_functional_agents,
) )
from app.gateway.services import RUN_SCOPE_ORCHESTRATION_CHILD, internal_run_scope_headers
from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from deerflow.config import get_app_config from deerflow.config import get_app_config
@@ -1554,16 +1553,15 @@ async def _leader_run(
model_name: str, model_name: str,
seat_meta: dict[str, dict[str, Any]] | None = None, seat_meta: dict[str, dict[str, Any]] | None = None,
synthesis_mode: bool = False, synthesis_mode: bool = False,
suppress_pre_broadcast: bool = False, suppress_pre_broadcast: bool = True,
files: list[dict[str, Any]] | None = None, files: list[dict[str, Any]] | None = None,
) -> AsyncIterator[str | dict[str, Any]]: ) -> AsyncIterator[str | dict[str, Any]]:
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。 """总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
时序要点: 时序要点:
1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身; 1. 总控输入只进入总控 thread,绝不广播到席位 thread;
2. 跑总控 thread 的 stream,yield 每一行原始 SSE; 2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表; 3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表并返回。
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。 ``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
@@ -1575,6 +1573,10 @@ async def _leader_run(
``synthesis_mode``:前端在「派活后让总控判断继续/综合」的轮次置真。为真时**额外**把各 ``synthesis_mode``:前端在「派活后让总控判断继续/综合」的轮次置真。为真时**额外**把各
已交付席位的**完整交付正文**注入本轮输入(``_build_full_deliverables_block``),供总控写 已交付席位的**完整交付正文**注入本轮输入(``_build_full_deliverables_block``),供总控写
最终方案;平时(派活决策轮)只给摘要,让总控 thread 与每轮上下文保持精简、响应快。 最终方案;平时(派活决策轮)只给摘要,让总控 thread 与每轮上下文保持精简、响应快。
``suppress_pre_broadcast`` 仅为兼容旧客户端保留,服务端始终按抑制处理。角色隔离不能由
客户端布尔值决定:总控 prompt 可能含 ``agent_orchestration``,进入席位历史会诱导弱模型
模仿总控加载编排能力。
""" """
t0 = time.perf_counter() t0 = time.perf_counter()
# 实读各席位 thread 的真实交付状态,构造「席位清单 + 客观交付状态(摘要)」块;综合轮再额外 # 实读各席位 thread 的真实交付状态,构造「席位清单 + 客观交付状态(摘要)」块;综合轮再额外
@@ -1623,21 +1625,9 @@ async def _leader_run(
part for part in (seat_status_block, full_deliverables_block, new_message) if part part for part in (seat_status_block, full_deliverables_block, new_message) if part
) )
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。 # 兼容旧请求字段但不再信任它。总控输入与派活说明始终只留在总控 thread;席位需要的上下文
# # 由总控下发的具体 task、peer_deliveries 和席位交付广播提供。
# ``suppress_pre_broadcast``:DAG「每 stage 派活轮」(方案 A)置真——此时 ``new_message`` 是 _ = suppress_pre_broadcast
# 「请你作为总控…通过 agent_orchestration 派活给席位…」的**派活 prompt**,若广播进各席位
# thread,弱模型(deepseek)会从历史里捡到「派活」意图,**席位跟着模仿派活 / 绕道 bash 调
# 内部接口**(已观测 bug-1~4)。DAG 下席位的任务由前端 ``pickDagSeatTask`` 直接、干净地下发
# (含任务背景),此处不需要、也不应把总控的派活 prompt 播给席位。recommend/chain 不传该 flag,
# 行为不变。
pre_broadcast_task = (
None
if suppress_pre_broadcast
else asyncio.create_task(
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
)
)
thread_id = agent_threads[agent_name] thread_id = agent_threads[agent_name]
# 角色参数取自单一数据源 roundtable_run_policy(leader:禁 web_search、停 # 角色参数取自单一数据源 roundtable_run_policy(leader:禁 web_search、停
# agent_orchestration 图、关 thinking + low reasoning——短决策、首字节快)。 # agent_orchestration 图、关 thinking + low reasoning——短决策、首字节快)。
@@ -1677,7 +1667,7 @@ async def _leader_run(
body = _mk_body(current_model) body = _mk_body(current_model)
t_payload_built = time.perf_counter() t_payload_built = time.perf_counter()
logger.info( logger.info(
"[leader-timing] %s payload_built=%.3fs (broadcast running in background)", "[leader-timing] %s payload_built=%.3fs",
agent_name, agent_name,
t_payload_built - t0, t_payload_built - t0,
) )
@@ -1733,27 +1723,8 @@ async def _leader_run(
"(expected on rollback preemption; otherwise check the upstream run's server-side error)", "(expected on rollback preemption; otherwise check the upstream run's server-side error)",
agent_name, agent_name,
) )
if pre_broadcast_task is not None:
with contextlib.suppress(Exception):
await pre_broadcast_task
return return
# Pre-broadcast must finish before we yield the final status frame on any
# branch — the seats that get dispatched next rely on having seen the
# original user message. Only await it once (first attempt); the rare
# self-correction retry reuses the same already-awaited broadcast.
if attempt == 0 and pre_broadcast_task is not None:
t_before_broadcast_await = time.perf_counter()
try:
await pre_broadcast_task
except Exception as exc:
logger.warning("pre-broadcast failed: %s", exc)
logger.info(
"[leader-timing] %s pre_broadcast_await=%.3fs (>0 means broadcast was still in flight after LLM finished)",
agent_name,
time.perf_counter() - t_before_broadcast_await,
)
dispatch_msg = _find_dispatch_message(messages) dispatch_msg = _find_dispatch_message(messages)
leader_text = _flatten_content(dispatch_msg.get("content", "")) leader_text = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or [] tool_calls = dispatch_msg.get("tool_calls") or []
@@ -1839,22 +1810,9 @@ async def _leader_run(
# 非法 id 已被丢弃;若 roster 校验后无合法派活(且重试已用尽),退化为空共识, # 非法 id 已被丢弃;若 roster 校验后无合法派活(且重试已用尽),退化为空共识,
# 而不是把假 id 漏给前端再报 400。 # 而不是把假 id 漏给前端再报 400。
dispatched: list[list[str]] = [[sub_name, task_text] for sub_name, task_text in valid_dispatch] dispatched: list[list[str]] = [[sub_name, task_text] for sub_name, task_text in valid_dispatch]
# 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知 # 关键路径优先:yield 派活帧,让前端立刻启动席位 special run。总控派活说明不广播给
# 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播 # 任何席位,避免席位从历史中模仿总控角色;席位间承上依靠完成交付广播 / peer_deliveries。
# 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name, "model_used": current_model} yield {"status": dispatched, "content": leader_text, "agent_name": agent_name, "model_used": current_model}
# DAG 派活轮(suppress_pre_broadcast)同样**不**把「总控给 X 派活」广播给兄弟席位 ——
# 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发,
# 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。
if not suppress_pre_broadcast:
for sub_name, task_text in valid_dispatch:
_spawn_detached_broadcast(
base,
headers,
f"总控智能体给{sub_name}派活,内容为:" + task_text,
agent_threads,
[agent_name, sub_name],
)
return return
@@ -1937,6 +1895,8 @@ async def _special_run(
seat_message = new_message seat_message = new_message
if role in ("seat", "seat_parallel"): if role in ("seat", "seat_parallel"):
seat_message = append_seat_skill_directive(agent_name, new_message, chain_enabled=seat_skill_directive) seat_message = append_seat_skill_directive(agent_name, new_message, chain_enabled=seat_skill_directive)
# 角色边界放在所有技能强化之后,确保本轮最后一条指令明确:席位永远不能加载/调用编排能力。
seat_message = append_seat_role_boundary(seat_message)
# 深链接业务规范(仅 summary 单例):把该业务的「业务链完整性核对」指令拼进本轮任务,强制总结报告 # 深链接业务规范(仅 summary 单例):把该业务的「业务链完整性核对」指令拼进本轮任务,强制总结报告
# 核对产出相对业务链完不完整。business_code 为空(普通会商)/ 非写实业务 → directive 为 "",原样不变。 # 核对产出相对业务链完不完整。business_code 为空(普通会商)/ 非写实业务 → directive 为 "",原样不变。
elif role == "summary" and business_code: elif role == "summary" and business_code:
@@ -2131,10 +2091,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
# 置真,网关据此用 seat_parallel policy 额外禁 write_file/bash/str_replace(§4)。 # 置真,网关据此用 seat_parallel policy 额外禁 write_file/bash/str_replace(§4)。
# 兼容 no_file 别名;缺省 False → 普通 seat,旧前端不传也不破坏。 # 兼容 no_file 别名;缺省 False → 普通 seat,旧前端不传也不破坏。
no_file: bool = bool(payload.get("parallel_no_file") or payload.get("no_file") or False) no_file: bool = bool(payload.get("parallel_no_file") or payload.get("no_file") or False)
# 抑制 leader 预广播(仅 leader 用):DAG「每 stage 派活轮」置真,避免把总控的派活 prompt # 旧客户端兼容字段。服务端已强制隔离总控 thread,因此无论请求值为何都不会执行 leader→seat
# 广播进席位 thread 让弱模型模仿派活(详见 _leader_run 的 suppress_pre_broadcast 注释)。 # 广播;仍解析并透传只是保持调用签名稳定。
# 缺省 False,recommend/chain 行为不变。 suppress_pre_broadcast: bool = bool(payload.get("suppress_pre_broadcast", True))
suppress_pre_broadcast: bool = bool(payload.get("suppress_pre_broadcast") or False)
# 前序席位完整交付(仅 special 用):前端 DAG stage>0 席位带上此前各阶段的全文,网关注入 # 前序席位完整交付(仅 special 用):前端 DAG stage>0 席位带上此前各阶段的全文,网关注入
# run context,供 read_peer_delivery 工具按需取全文(平时只看广播摘要,§12.9)。规整成 # run context,供 read_peer_delivery 工具按需取全文(平时只看广播摘要,§12.9)。规整成
# [{name, content}] 且过滤空项;缺省 None。 # [{name, content}] 且过滤空项;缺省 None。
@@ -0,0 +1,287 @@
"""Workflow data sources and HTTP credentials.
Write-only secrets: the DSN (or the credential header blob) is accepted on
create/update, encrypted immediately, and never returned. Reads expose only a
masked target so an operator can tell two sources apart.
Shared sources (``ownerId`` null) are admin-only; a regular user may manage
their own.
"""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.workflow_audit import audit_workflow
from deerflow.config.app_config import get_app_config
from deerflow.workflows.security.secret_box import SecretKeyMissingError, secrets_available
from deerflow.workflows.security.sql_policy import assert_read_only, assert_tables_allowed
router = APIRouter(prefix="/api/workflows/data-sources", tags=["workflow-data-sources"])
_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
class DataSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=191)
description: str = ""
kind: str = Field(default="sql", pattern="^(sql|http)$")
dsn: str | None = None
headers: dict[str, str] | None = None
base_url: str = Field(default="", max_length=2048, alias="baseUrl")
allowed_methods: list[str] = Field(default_factory=list, alias="allowedMethods")
max_rows: int = Field(default=1000, ge=1, le=100_000, alias="maxRows")
allowed_tables: list[str] = Field(default_factory=list, alias="allowedTables")
enabled: bool = True
shared: bool = False
model_config = {"populate_by_name": True}
class QueryBody(BaseModel):
statement: str = Field(min_length=1)
class DataSourceUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=191)
description: str | None = None
dsn: str | None = None
headers: dict[str, str] | None = None
base_url: str | None = Field(default=None, max_length=2048, alias="baseUrl")
allowed_methods: list[str] | None = Field(default=None, alias="allowedMethods")
max_rows: int | None = Field(default=None, ge=1, le=100_000, alias="maxRows")
allowed_tables: list[str] | None = Field(default=None, alias="allowedTables")
enabled: bool | None = None
model_config = {"populate_by_name": True}
def _require_enabled() -> None:
if not get_app_config().workflows.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _store(request: Request):
store = getattr(request.app.state, "workflow_data_source_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow data source store not available")
return store
async def _is_admin(request: Request) -> bool:
user = await get_optional_user_from_request(request)
return user is not None and getattr(user, "system_role", None) == "admin"
def _normalise_http_metadata(
base_url: str,
allowed_methods: list[str],
) -> tuple[str, list[str]]:
target = base_url.strip()
try:
parsed = urlsplit(target)
except ValueError as exc:
raise HTTPException(status_code=400, detail="HTTP 接口地址格式无效") from exc
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise HTTPException(
status_code=400,
detail="HTTP 接口地址必须是无凭据、无查询参数的 http(s) 地址",
)
normalised_methods: list[str] = []
for raw_method in allowed_methods:
method = str(raw_method).strip().upper()
if method not in _HTTP_METHODS:
raise HTTPException(status_code=400, detail=f"不支持的 HTTP 方法:{method}")
if method not in normalised_methods:
normalised_methods.append(method)
if not normalised_methods:
raise HTTPException(status_code=400, detail="至少选择一种 HTTP 方法")
return target, normalised_methods
def _normalise_headers(headers: dict[str, str] | None) -> dict[str, str]:
normalised: dict[str, str] = {}
for raw_name, raw_value in (headers or {}).items():
name = str(raw_name).strip()
value = str(raw_value)
if not name or any(char in name + value for char in "\r\n"):
raise HTTPException(status_code=400, detail="HTTP 请求头格式无效")
normalised[name] = value
return normalised
def _secret_from(kind: str, dsn: str | None, headers: dict[str, str] | None) -> str:
if kind == "http":
return json.dumps({"headers": _normalise_headers(headers)}, ensure_ascii=False)
if not dsn:
raise HTTPException(status_code=400, detail="SQL 数据源需要提供 dsn")
if "://" not in dsn:
raise HTTPException(status_code=400, detail="dsn 格式无效")
return dsn
@router.get("")
async def list_data_sources(request: Request) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
rows = await _store(request).list_sources(owner_id=user_id)
return {"dataSources": rows, "secretsEncryptionAvailable": secrets_available()}
@router.post("")
async def create_data_source(request: Request, body: DataSourceCreate) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
is_admin = await _is_admin(request)
if body.shared and not is_admin:
raise HTTPException(status_code=403, detail="仅管理员可创建共享数据源")
secret = _secret_from(body.kind, body.dsn, body.headers)
base_url = ""
allowed_methods: list[str] = []
if body.kind == "http":
base_url, allowed_methods = _normalise_http_metadata(
body.base_url,
body.allowed_methods,
)
try:
created = await _store(request).create_source(
{
"name": body.name,
"description": body.description,
"kind": body.kind,
"dsn": secret,
"base_url": base_url,
"allowed_methods": allowed_methods,
"max_rows": body.max_rows,
"allowed_tables": body.allowed_tables,
"enabled": body.enabled,
"owner_id": None if body.shared else user_id,
"created_by": user_id,
}
)
except SecretKeyMissingError as exc:
raise HTTPException(
status_code=503,
detail={
"code": "WORKFLOW_RESOURCE_MISSING",
"message": "服务端未配置 WORKFLOW_SECRET_KEY,无法安全保存连接信息",
},
) from exc
audit_workflow("data_source.create", user_id=user_id, dataSourceId=created["id"], kind=body.kind, shared=body.shared)
return created
async def _owned(request: Request, source_id: str) -> dict[str, Any]:
row = await _store(request).get_source(source_id)
if row is None:
raise HTTPException(status_code=404, detail="数据源不存在")
user_id = await get_current_user(request)
if row.get("owner_id") == user_id:
return row
if await _is_admin(request):
return row
raise HTTPException(status_code=403, detail="无权操作该数据源")
async def _visible(request: Request, source_id: str) -> dict[str, Any]:
row = await _store(request).get_source(source_id)
if row is None:
raise HTTPException(status_code=404, detail="数据源不存在")
user_id = await get_current_user(request)
if row.get("owner_id") in (user_id, None):
return row
if await _is_admin(request):
return row
raise HTTPException(status_code=403, detail="无权查看该数据源")
@router.get("/{source_id}")
async def get_data_source(request: Request, source_id: str) -> dict[str, Any]:
_require_enabled()
return await _visible(request, source_id)
@router.post("/{source_id}/introspect")
async def introspect_data_source(request: Request, source_id: str) -> dict[str, Any]:
"""Return the configured table allowlist only — never sample rows."""
_require_enabled()
row = await _visible(request, source_id)
tables = list(row.get("allowed_tables") or [])
return {
"dataSourceId": row["id"],
"kind": row.get("kind"),
"tables": [{"name": name} for name in tables],
"schemas": [],
}
@router.post("/{source_id}/validate-query")
async def validate_data_source_query(request: Request, source_id: str, body: QueryBody) -> dict[str, Any]:
"""Validate SQL against the read-only policy without executing it."""
_require_enabled()
row = await _visible(request, source_id)
try:
statement = assert_read_only(body.statement)
assert_tables_allowed(statement, list(row.get("allowed_tables") or []))
except Exception as exc: # noqa: BLE001
from deerflow.workflows.errors import WorkflowError
if isinstance(exc, WorkflowError):
raise HTTPException(status_code=400, detail=exc.to_body().model_dump(by_alias=True)) from exc
raise
audit_workflow("data_source.validate_query", user_id=await get_current_user(request), dataSourceId=source_id)
return {"ok": True, "statement": statement}
@router.put("/{source_id}")
async def update_data_source(request: Request, source_id: str, body: DataSourceUpdate) -> dict[str, Any]:
_require_enabled()
row = await _owned(request, source_id)
payload = body.model_dump(exclude_unset=True, by_alias=False)
kind = str(row.get("kind") or "sql")
if body.dsn is not None or body.headers is not None:
payload["dsn"] = _secret_from(kind, body.dsn, body.headers)
payload.pop("headers", None)
if kind == "http":
base_url, allowed_methods = _normalise_http_metadata(
body.base_url if body.base_url is not None else str(row.get("base_url") or ""),
body.allowed_methods
if body.allowed_methods is not None
else list(row.get("allowed_methods") or []),
)
payload["base_url"] = base_url
payload["allowed_methods"] = allowed_methods
else:
payload.pop("base_url", None)
payload.pop("allowed_methods", None)
try:
updated = await _store(request).update_source(source_id, payload)
except SecretKeyMissingError as exc:
raise HTTPException(status_code=503, detail="服务端未配置 WORKFLOW_SECRET_KEY") from exc
if updated is None:
raise HTTPException(status_code=404, detail="数据源不存在")
audit_workflow("data_source.update", user_id=await get_current_user(request), dataSourceId=source_id)
return updated
@router.delete("/{source_id}")
async def delete_data_source(request: Request, source_id: str) -> dict[str, Any]:
_require_enabled()
await _owned(request, source_id)
deleted = await _store(request).delete_source(source_id)
audit_workflow("data_source.delete", user_id=await get_current_user(request), dataSourceId=source_id)
return {"deleted": deleted}
@@ -0,0 +1,186 @@
"""iframe embedding support: origin allowlist + short-lived signed tickets.
Scope, stated plainly: a ticket proves *"this workflow may be embedded by this
origin, for this user, right now"*. It is **not** an API credential — embedded
pages still authenticate their API calls with the normal session, exactly like
the rest of the app's iframe surfaces. What the ticket adds is an explicit
origin decision that can be audited, plus the ``frame-ancestors`` value the host
should send.
Tickets are stateless HS256 JWTs (so any worker can verify them) with a 60s
default TTL. Single-use is enforced best-effort per process via a replay cache;
the short TTL is the real protection.
"""
from __future__ import annotations
import hashlib
import logging
import os
import time
from typing import Any
from urllib.parse import urlparse
import jwt
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from deerflow.config.app_config import get_app_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/workflows/embed", tags=["workflow-embed"])
_ALGORITHM = "HS256"
_AUDIENCE = "workflow-embed"
_replay_cache: dict[str, float] = {}
_REPLAY_CACHE_MAX = 4096
class TicketRequest(BaseModel):
workflow_id: str = Field(alias="workflowId")
origin: str
model_config = {"populate_by_name": True}
class VerifyRequest(BaseModel):
ticket: str
origin: str | None = None
def _signing_key() -> str:
for name in ("WORKFLOW_SECRET_KEY", "DEERFLOW_SECRET_KEY", "JWT_SECRET_KEY"):
value = os.environ.get(name)
if value and value.strip():
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()
raise HTTPException(
status_code=503,
detail={
"code": "WORKFLOW_EMBED_TICKET_INVALID",
"message": "服务端未配置签名密钥,无法签发嵌入票据",
},
)
def _normalize_origin(raw: str) -> str:
parsed = urlparse(raw.strip())
if not parsed.scheme or not parsed.netloc:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_EMBED_ORIGIN_DENIED", "message": "origin 格式无效"},
)
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def _assert_origin_allowed(origin: str) -> str:
cfg = get_app_config().workflows.embed
normalized = _normalize_origin(origin)
allowed = {_normalize_origin(o) for o in cfg.allowed_origins if o.strip()}
if not allowed:
raise HTTPException(
status_code=403,
detail={
"code": "WORKFLOW_EMBED_ORIGIN_DENIED",
"message": "未配置任何允许的嵌入来源(workflows.embed.allowed_origins)",
},
)
if normalized not in allowed:
logger.warning("workflow embed origin denied: %s", normalized)
raise HTTPException(
status_code=403,
detail={"code": "WORKFLOW_EMBED_ORIGIN_DENIED", "message": "该来源不允许嵌入"},
)
return normalized
def _prune_replay_cache(now: float) -> None:
for key, expiry in list(_replay_cache.items()):
if expiry <= now:
_replay_cache.pop(key, None)
if len(_replay_cache) > _REPLAY_CACHE_MAX:
_replay_cache.clear()
@router.post("/tickets")
async def issue_ticket(request: Request, body: TicketRequest) -> dict[str, Any]:
cfg = get_app_config().workflows
if not cfg.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
user_id = await get_current_user(request)
origin = _assert_origin_allowed(body.origin)
definitions = getattr(request.app.state, "workflow_store", None)
if definitions is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
definition = await definitions.get_definition(body.workflow_id, include_draft=False)
if definition is None:
raise HTTPException(status_code=404, detail="工作流不存在")
if definition.get("owner_id") != user_id:
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权嵌入该工作流"})
now = int(time.time())
ttl = cfg.embed.ticket_ttl_seconds
payload = {
"aud": _AUDIENCE,
"sub": user_id,
"wf": body.workflow_id,
"org": origin,
"iat": now,
"exp": now + ttl,
"jti": hashlib.sha256(f"{user_id}{body.workflow_id}{origin}{now}".encode()).hexdigest()[:32],
}
ticket = jwt.encode(payload, _signing_key(), algorithm=_ALGORITHM)
logger.info(
"workflow embed ticket issued workflow=%s user=%s origin=%s ttl=%ss",
body.workflow_id,
user_id,
origin,
ttl,
)
return {
"ticket": ticket,
"expiresIn": ttl,
"origin": origin,
"frameAncestors": sorted({_normalize_origin(o) for o in cfg.embed.allowed_origins if o.strip()}),
}
@router.post("/verify")
async def verify_ticket(body: VerifyRequest) -> dict[str, Any]:
cfg = get_app_config().workflows
if not cfg.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
try:
payload = jwt.decode(body.ticket, _signing_key(), algorithms=[_ALGORITHM], audience=_AUDIENCE)
except jwt.PyJWTError as exc:
raise HTTPException(
status_code=401,
detail={"code": "WORKFLOW_EMBED_TICKET_INVALID", "message": "票据无效或已过期"},
) from exc
if body.origin and _normalize_origin(body.origin) != payload.get("org"):
raise HTTPException(
status_code=403,
detail={"code": "WORKFLOW_EMBED_ORIGIN_DENIED", "message": "票据与当前来源不匹配"},
)
now = time.time()
_prune_replay_cache(now)
jti = str(payload.get("jti") or "")
if jti and jti in _replay_cache:
raise HTTPException(
status_code=401,
detail={"code": "WORKFLOW_EMBED_TICKET_INVALID", "message": "票据已被使用"},
)
if jti:
_replay_cache[jti] = float(payload.get("exp") or now + 60)
return {
"ok": True,
"workflowId": payload.get("wf"),
"userId": payload.get("sub"),
"origin": payload.get("org"),
"expiresAt": payload.get("exp"),
}
@@ -0,0 +1,236 @@
"""Workflow node-type and resource catalog APIs (phase 1)."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from app.gateway.deps import get_current_user, get_local_provider
from deerflow.config.app_config import get_app_config
from deerflow.persistence.workflows import WorkflowStore
from deerflow.workflows.schemas import ALL_NODE_TYPES
router = APIRouter(prefix="/api/workflows", tags=["workflow-resources"])
_NODE_META: dict[str, dict[str, str]] = {
"start": {"label": "开始", "category": "control"},
"output": {"label": "输出", "category": "control"},
"agent": {"label": "智能体", "category": "ai"},
"skill": {"label": "技能", "category": "ai"},
"http": {"label": "HTTP", "category": "data"},
"sql_read": {"label": "SQL 只读", "category": "data"},
"code": {"label": "代码", "category": "data"},
"transform": {"label": "模板/转换", "category": "data"},
"condition": {"label": "条件", "category": "control"},
"merge": {"label": "合并", "category": "control"},
"human_input": {"label": "人工介入", "category": "control"},
"subworkflow": {"label": "子工作流", "category": "control"},
"loop": {"label": "循环", "category": "control"},
}
def _require_enabled() -> None:
if not get_app_config().workflows.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _get_store(request: Request) -> WorkflowStore:
store = getattr(request.app.state, "workflow_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
return store
async def _resolve_creator_names(user_ids: set[str]) -> dict[str, str]:
"""Resolve resource-owner ids to short display names, best-effort.
The workflow catalog is a small display projection over the agent and
skill stores. Those stores intentionally retain immutable owner ids for
access control; this resolver exposes a friendly creator name without
changing that ownership model. If an account has since disappeared, the
stable id remains a useful audit fallback.
"""
if not user_ids:
return {}
try:
provider = get_local_provider()
except Exception: # noqa: BLE001
return {user_id: user_id for user_id in user_ids}
names: dict[str, str] = {}
for user_id in user_ids:
try:
user = await provider.get_user(user_id)
except Exception: # noqa: BLE001
user = None
email = getattr(user, "email", None) if user is not None else None
if isinstance(email, str) and email:
names[user_id] = email.split("@", 1)[0] or user_id
else:
names[user_id] = user_id
return names
def _creator_label(owner_id: object, creator_names: dict[str, str]) -> str:
"""Return the catalog label for a user-owned or built-in resource."""
if owner_id is None or not str(owner_id).strip():
return "系统内置"
owner_key = str(owner_id)
return creator_names.get(owner_key, owner_key)
@router.get("/node-types")
async def list_node_types(request: Request) -> dict[str, Any]:
_require_enabled()
await get_current_user(request)
items = []
for node_type in ALL_NODE_TYPES:
meta = _NODE_META.get(node_type, {"label": node_type, "category": "other"})
items.append({"type": node_type, **meta})
return {"nodeTypes": items}
@router.get("/resources/agents")
async def list_agent_resources(request: Request) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
agent_store = getattr(request.app.state, "agent_store", None)
agents: list[dict[str, Any]] = []
if agent_store is not None:
try:
if hasattr(agent_store, "list_visible"):
rows = await agent_store.list_visible(user_id)
elif hasattr(agent_store, "list_all"):
rows = await agent_store.list_all()
else:
rows = []
creator_names = await _resolve_creator_names({str(row["user_id"]) for row in rows or [] if row.get("user_id") is not None})
agent_ids = [str(row.get("id") or row.get("agent_id") or "") for row in rows or []]
extras_by_agent: dict[str, dict[str, Any]] = {}
if hasattr(agent_store, "get_extras_for"):
extras_by_agent = await agent_store.get_extras_for([agent_id for agent_id in agent_ids if agent_id])
keyword = request.query_params.get("keyword", "").strip().casefold()
for row in rows or []:
agent_id = str(row.get("id") or row.get("agent_id") or "")
name = str(row.get("name") or agent_id)
description = str(row.get("description") or "")
if keyword and keyword not in f"{agent_id} {name} {description}".casefold():
continue
cached_skills = (extras_by_agent.get(agent_id) or {}).get("skills") or []
agents.append(
{
"agentId": agent_id,
"name": name,
"description": description,
"skills": [str(skill) for skill in cached_skills],
"createdBy": _creator_label(row.get("user_id"), creator_names),
}
)
except Exception: # noqa: BLE001
agents = []
return {"agents": agents}
@router.get("/resources/skills")
async def list_skill_resources(request: Request) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
skills: list[dict[str, Any]] = []
try:
from deerflow.skills import get_or_new_skill_storage
skill_store = getattr(request.app.state, "skill_store", None)
ownership_by_name: dict[str, dict[str, Any]] = {}
if skill_store is not None and hasattr(skill_store, "list_visible"):
records = await skill_store.list_visible(user_id)
ownership_by_name = {str(record["name"]): record for record in records if record.get("name")}
creator_names = await _resolve_creator_names({str(record["owner_user_id"]) for record in ownership_by_name.values() if record.get("owner_user_id") is not None})
storage = get_or_new_skill_storage()
for skill in storage.load_skills(enabled_only=True) or []:
name = getattr(skill, "name", None)
if not name:
continue
record = ownership_by_name.get(str(name), {})
skills.append(
{
"skillId": name,
"name": name,
"description": getattr(skill, "description", "") or "",
# Workflow direct-skill execution remains opt-in. Older
# Skill definitions do not advertise this capability and
# are therefore correctly shown as agent-only resources.
"callable": bool(getattr(skill, "callable", False)),
"createdBy": _creator_label(record.get("owner_user_id"), creator_names),
}
)
except Exception: # noqa: BLE001
skills = []
return {"skills": skills}
@router.get("/resources/data-sources")
async def list_data_source_resources(request: Request) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
store = getattr(request.app.state, "workflow_data_source_store", None)
if store is None:
return {"dataSources": []}
rows = await store.list_sources(owner_id=user_id)
return {
"dataSources": [
{
"id": row["id"],
"dataSourceId": row["id"],
"name": row["name"],
"description": row.get("description") or "",
"kind": row.get("kind"),
"engine": row.get("driver") or "",
"baseUrl": row.get("base_url") or "",
"maskedTarget": row.get("masked_target"),
"enabled": row.get("enabled"),
"readOnly": row.get("kind") == "sql",
"allowedMethods": row.get("allowed_methods") or [],
"allowedTables": row.get("allowed_tables") or [],
}
for row in rows
if row.get("enabled", True)
]
}
@router.get("/resources/credentials")
async def list_credential_resources(request: Request) -> dict[str, Any]:
"""Read-only credential directory: ids and labels only.
HTTP credentials are ``http``-kind data sources; the encrypted header blob
is resolved exclusively by the run executor. This endpoint must never
return headers, tokens, DSNs or ciphertext — a 404-free catalog so the
canvas credential picker does not fail the whole resource panel.
"""
_require_enabled()
user_id = await get_current_user(request)
store = getattr(request.app.state, "workflow_data_source_store", None)
if store is None:
return {"credentials": []}
rows = await store.list_sources(owner_id=user_id, include_shared=True)
return {
"credentials": [
{
"credentialId": row["id"],
"name": row["name"],
"type": "http_headers",
"sourceKind": "http",
}
for row in rows
if str(row.get("kind") or "") == "http" and row.get("enabled", True)
]
}
@router.get("/resources/subworkflows")
async def list_subworkflow_resources(request: Request) -> dict[str, Any]:
_require_enabled()
await get_current_user(request)
store = _get_store(request)
return {"subworkflows": await store.list_published_summaries()}
@@ -0,0 +1,718 @@
"""Workflow run APIs: start / inspect / cancel / resume / retry / events / SSE stream."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.workflow_audit import audit_workflow
from deerflow.config.app_config import get_app_config
from deerflow.persistence.workflow_events.base import WorkflowEventStore
from deerflow.persistence.workflow_runs.base import WorkflowRunStore
from deerflow.workflows.runtime.sink import envelope_from_row
from deerflow.workflows.schema_validation import schema_issues
from deerflow.workflows.schemas import WorkflowGraph
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/workflows", tags=["workflow-runs"])
_TERMINAL_STATUSES = ("completed", "failed", "cancelled")
_TERMINAL_EVENTS = ("run.completed", "run.failed", "run.cancelled")
_REPLAY_PAGE = 200
_DB_POLL_SECONDS = 0.8
_LIVE_WAIT_SECONDS = 0.25
_HEARTBEAT_SECONDS = 15.0
_STREAM_DEADLINE_SECONDS = 60 * 60
class StartRunBody(BaseModel):
version_id: str | None = Field(default=None, alias="versionId")
inputs: dict[str, Any] = Field(default_factory=dict)
# 独立 Coze 工作流页早期使用 input/target;保留兼容入口,内部仍只使用 inputs。
input: dict[str, Any] | None = None
target: str | None = None
idempotency_key: str | None = Field(default=None, alias="idempotencyKey", max_length=128)
model_config = {"populate_by_name": True}
def resolved_inputs(self) -> dict[str, Any]:
return self.inputs if self.inputs or self.input is None else self.input
class ResumeRunBody(BaseModel):
resume_token: str = Field(alias="resumeToken")
node_id: str | None = Field(default=None, alias="nodeId")
action: str = "submit"
values: dict[str, Any] = Field(default_factory=dict)
# 兼容独立页的 ResumeRunRequest:payload/message。
payload: dict[str, Any] | None = None
message: str | None = None
model_config = {"populate_by_name": True}
def resolved_values(self) -> dict[str, Any]:
return self.values if self.values or self.payload is None else self.payload
# ── shared plumbing ─────────────────────────────────────────────────────
def _cfg():
return get_app_config().workflows
def _require_enabled() -> None:
if not _cfg().enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _runs(request: Request) -> WorkflowRunStore:
store = getattr(request.app.state, "workflow_run_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow run store not available")
return store
def _events(request: Request) -> WorkflowEventStore:
store = getattr(request.app.state, "workflow_event_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow event store not available")
return store
def _definitions(request: Request):
store = getattr(request.app.state, "workflow_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
return store
def _nudge(request: Request) -> None:
dispatcher = getattr(request.app.state, "workflow_dispatcher", None)
if dispatcher is not None:
dispatcher.nudge()
async def _is_admin(request: Request) -> bool:
user = await get_optional_user_from_request(request)
return user is not None and getattr(user, "system_role", None) == "admin"
async def _authorized_run(request: Request, run_id: str) -> dict[str, Any]:
run = await _runs(request).get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_RUN_NOT_FOUND", "message": "运行不存在"})
user_id = await get_current_user(request)
if run.get("owner_id") != user_id and not await _is_admin(request):
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权访问该运行"})
return run
def _public_run(run: dict[str, Any]) -> dict[str, Any]:
"""Run view for HTTP clients: no lease internals, no resume token."""
return {
"id": run["id"],
"runId": run["id"],
"workflowId": run["workflow_id"],
"versionId": run["workflow_version_id"],
"status": run["status"],
"ownerId": run["owner_id"],
"input": run.get("input") or {},
"output": run.get("output"),
"error": run.get("error"),
"pendingInput": run.get("pending_input"),
"attempt": run.get("attempt"),
"retryOfRunId": run.get("retry_of_run_id"),
"startedAt": run.get("started_at"),
"finishedAt": run.get("finished_at"),
"createdAt": run.get("created_at"),
"updatedAt": run.get("updated_at"),
# The run row's counter holds the last seq handed out by the event log
# (the event store bumps it in the insert transaction); 0 = no events.
"lastSeq": int(run.get("next_event_seq") or 0),
}
# ── start / list / get / cancel / resume ────────────────────────────────
@router.post("/{workflow_id}/runs")
async def start_run(request: Request, workflow_id: str, body: StartRunBody) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
definitions = _definitions(request)
is_draft_run = str(body.target or "").strip().lower() == "draft"
if is_draft_run:
# Test-run semantics: execute the CURRENT draft graph (what the canvas
# shows right now), versioned as the literal "draft". The compiled
# execution graph travels in run.context (never the canvas document),
# and the executor prefers it over any published version.
definition = await definitions.get_definition(workflow_id, include_draft=True)
else:
definition = await definitions.get_definition(workflow_id, include_draft=False)
if definition is None:
raise HTTPException(status_code=404, detail="工作流不存在")
if definition.get("owner_id") != user_id and not await _is_admin(request):
raise HTTPException(status_code=403, detail={"code": "WORKFLOW_FORBIDDEN", "message": "无权运行该工作流"})
draft_graph_raw: dict[str, Any] | None = None
if is_draft_run:
draft_graph_raw = definition.get("draft_graph") or {}
if not draft_graph_raw.get("nodes"):
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_DRAFT_EMPTY",
"message": "草稿还没有可运行的执行图,请先在画布保存一次",
},
)
version_id = body.version_id
if not version_id and not is_draft_run:
versions = await definitions.list_versions(workflow_id)
if not versions:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_VERSION_NOT_FOUND", "message": "工作流尚未发布任何版本"},
)
version_id = versions[0]["id"]
if is_draft_run:
version_id = "draft"
else:
version = await definitions.get_version(version_id)
if version is None or version.get("workflow_id") != workflow_id:
raise HTTPException(status_code=404, detail={"code": "WORKFLOW_VERSION_NOT_FOUND", "message": "版本不存在"})
graph = WorkflowGraph.model_validate(draft_graph_raw if is_draft_run else (version.get("graph") or {}))
inputs = body.resolved_inputs()
missing = [key for key in (graph.input_schema.get("required") or []) if inputs.get(str(key)) in (None, "")]
if missing:
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_INPUT_INVALID",
"message": f"缺少必填输入:{'、'.join(str(m) for m in missing)}",
"details": {"missing": missing},
},
)
# Full JSON Schema pass so type/enum/nested mistakes fail here with a
# structured 400 instead of surfacing mid-run as a node error.
schema_errors = schema_issues(inputs, graph.input_schema or {})
if schema_errors:
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_INPUT_INVALID",
"message": "输入不符合 schema:" + ";".join(e["message"] for e in schema_errors[:3]),
"details": {"errors": schema_errors},
},
)
runs = _runs(request)
active = [r for r in await runs.list_runs(owner_id=user_id, limit=200) if r["status"] in ("queued", "running")]
if len(active) >= _cfg().max_concurrent_runs_per_user:
raise HTTPException(
status_code=429,
detail={
"code": "WORKFLOW_LIMIT_EXCEEDED",
"message": f"同时运行的工作流已达上限({_cfg().max_concurrent_runs_per_user})",
},
)
# Scope the idempotency key per workflow so two workflows can share a client key.
key = f"{workflow_id}:{body.idempotency_key}" if body.idempotency_key else None
context: dict[str, Any] = {"depth": 0}
if is_draft_run:
# The draft snapshot rides with the run; the executor prefers it over
# the published version. Execution graph only — never the canvas doc.
context["draftGraph"] = draft_graph_raw
run, created = await runs.create_run(
{
"workflow_id": workflow_id,
"workflow_version_id": version_id,
"owner_id": user_id,
"input": inputs,
"idempotency_key": key,
"context": context,
}
)
if created:
await _events(request).append(
run_id=run["id"],
workflow_id=workflow_id,
version_id=version_id,
event_type="run.created",
payload={"inputs": inputs},
)
audit_workflow("run.start", user_id=user_id, workflowId=workflow_id, runId=run["id"], versionId=version_id)
_nudge(request)
return {**_public_run(run), "created": created}
@router.get("/{workflow_id}/runs")
async def list_workflow_runs(
request: Request,
workflow_id: str,
status: str | None = None,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
owner = None if await _is_admin(request) else user_id
rows = await _runs(request).list_runs(workflow_id=workflow_id, owner_id=owner, status=status, limit=limit, offset=offset)
return {"runs": [_public_run(r) for r in rows], "hasMore": len(rows) >= limit}
@router.get("/runs/{run_id}")
async def get_run(request: Request, run_id: str) -> dict[str, Any]:
_require_enabled()
run = await _authorized_run(request, run_id)
node_runs = await _runs(request).list_node_runs(run_id, current_only=True)
return {
**_public_run(run),
"nodeRuns": [
{
"nodeId": r["node_id"],
"nodeType": r["node_type"],
"status": r["status"],
"attempt": r["attempt"],
"iteration": r["iteration"],
"durationMs": r["duration_ms"],
"error": r["error"],
"startedAt": r["started_at"],
"finishedAt": r["finished_at"],
}
for r in node_runs
],
}
@router.get("/runs/{run_id}/artifacts")
async def list_run_artifacts(request: Request, run_id: str) -> dict[str, Any]:
_require_enabled()
await _authorized_run(request, run_id)
rows = await _runs(request).list_artifacts(run_id)
return {
"artifacts": [
{
"artifactId": r["id"],
"nodeId": r["node_id"],
"name": r["name"],
"mimeType": r["mime_type"],
"sizeBytes": r["size_bytes"],
"preview": r["preview"],
}
for r in rows
]
}
# ── artifact content / download ─────────────────────────────────────────
_PREVIEW_MAX_BYTES = 256 * 1024
_TEXT_MIME_PREFIXES = ("text/", "application/json", "application/javascript", "application/x-yaml", "application/xml", "application/csv")
def _is_textual(mime_type: str) -> bool:
return (mime_type or "").lower().startswith(_TEXT_MIME_PREFIXES)
def _read_prefix(path: str, limit: int) -> tuple[bytes, bool]:
"""Read up to ``limit + 1`` bytes; the extra byte detects truncation."""
with open(path, "rb") as handle:
data = handle.read(limit + 1)
return data[:limit], len(data) > limit
def _file_exists(path: str) -> bool:
import os
return bool(path) and os.path.isfile(path)
async def _authorized_artifact(request: Request, run_id: str, artifact_id: str) -> dict[str, Any]:
"""Run-level authorization first, then artifact-must-belong-to-run.
The file path is resolved exclusively from the registered artifact row —
clients address artifacts by id and can never pass a sandbox path in.
"""
await _authorized_run(request, run_id)
artifact = await _runs(request).get_artifact(run_id, artifact_id)
if artifact is None:
raise HTTPException(
status_code=404,
detail={"code": "WORKFLOW_ARTIFACT_NOT_FOUND", "message": "制品不存在"},
)
return artifact
@router.get("/runs/{run_id}/artifacts/{artifact_id}")
async def get_run_artifact_content(request: Request, run_id: str, artifact_id: str) -> dict[str, Any]:
"""Size-bounded text preview for file cards; binaries stay download-only."""
from starlette.concurrency import run_in_threadpool
from deerflow.workflows.events import artifact_kind
_require_enabled()
artifact = await _authorized_artifact(request, run_id, artifact_id)
mime = str(artifact.get("mime_type") or "application/octet-stream")
kind = artifact_kind(mime)
base = {
"artifactId": artifact["id"],
"name": artifact["name"],
"kind": kind,
"mimeType": mime,
"sizeBytes": artifact.get("size_bytes"),
}
path = str(artifact.get("path") or "")
file_ok = bool(path) and await run_in_threadpool(_file_exists, path)
if file_ok:
raw, truncated = (b"", False)
if _is_textual(mime):
raw, truncated = await run_in_threadpool(_read_prefix, path, _PREVIEW_MAX_BYTES)
return {
**base,
"text": raw.decode("utf-8", errors="replace"),
"truncated": truncated,
"source": "file",
}
return {
**base,
"binary": True,
"downloadUrl": f"/api/workflows/runs/{run_id}/artifacts/{artifact_id}/download",
"source": "file",
}
preview = artifact.get("preview")
if preview:
return {**base, "text": str(preview), "truncated": True, "source": "preview"}
raise HTTPException(
status_code=404,
detail={"code": "WORKFLOW_ARTIFACT_CONTENT_UNAVAILABLE", "message": "制品内容不可用(文件已过期)"},
)
@router.get("/runs/{run_id}/artifacts/{artifact_id}/download")
async def download_run_artifact(request: Request, run_id: str, artifact_id: str):
from urllib.parse import quote
from starlette.concurrency import run_in_threadpool
from starlette.responses import FileResponse, Response
_require_enabled()
artifact = await _authorized_artifact(request, run_id, artifact_id)
mime = str(artifact.get("mime_type") or "application/octet-stream")
name = str(artifact.get("name") or artifact_id)
disposition = f"attachment; filename*=UTF-8''{quote(name)}"
path = str(artifact.get("path") or "")
if path and await run_in_threadpool(_file_exists, path):
return FileResponse(path, media_type=mime, headers={"Content-Disposition": disposition})
preview = artifact.get("preview")
if preview:
return Response(
content=str(preview),
media_type=mime if _is_textual(mime) else "text/plain",
headers={"Content-Disposition": disposition},
)
raise HTTPException(
status_code=404,
detail={"code": "WORKFLOW_ARTIFACT_CONTENT_UNAVAILABLE", "message": "制品文件已过期,无法下载"},
)
@router.post("/runs/{run_id}/cancel")
async def cancel_run(request: Request, run_id: str) -> dict[str, Any]:
_require_enabled()
run = await _authorized_run(request, run_id)
if run["status"] in _TERMINAL_STATUSES:
return {**_public_run(run), "cancelled": False}
updated = await _runs(request).request_cancel(run_id)
if updated is None:
raise HTTPException(
status_code=409,
detail={"code": "WORKFLOW_RUN_CONFLICT", "message": "当前状态无法取消"},
)
await _events(request).append(
run_id=run_id,
workflow_id=run["workflow_id"],
version_id=run["workflow_version_id"],
event_type="run.cancel_requested",
payload={},
)
_nudge(request)
audit_workflow("run.cancel", user_id=await get_current_user(request), runId=run_id, workflowId=run["workflow_id"])
return {**_public_run(updated), "cancelled": True}
@router.post("/runs/{run_id}/resume")
async def resume_run(request: Request, run_id: str, body: ResumeRunBody) -> dict[str, Any]:
_require_enabled()
run = await _authorized_run(request, run_id)
if run["status"] != "awaiting_input":
raise HTTPException(
status_code=409,
detail={"code": "WORKFLOW_RUN_NOT_RESUMABLE", "message": "运行当前不在等待人工输入"},
)
pending = run.get("pending_input") or {}
node_id = body.node_id or str(pending.get("nodeId") or "")
if not node_id:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_HUMAN_INPUT_INVALID", "message": "缺少 nodeId"},
)
runs = _runs(request)
# Single conditional update: token validation + payload write + token
# consumption + status flip all in one statement, so two concurrent resume
# requests with the same token cannot interleave — exactly one succeeds and
# the loser gets 409 without ever touching the run.
resumed = await runs.resume_with_payload(
run_id,
body.resume_token,
{
"nodeId": node_id,
"action": body.action,
"values": body.resolved_values(),
**({"message": body.message} if body.message else {}),
},
context=dict(run.get("context") or {}),
)
if resumed is None:
raise HTTPException(
status_code=409,
detail={"code": "WORKFLOW_RESUME_TOKEN_INVALID", "message": "恢复令牌无效或已被使用"},
)
await _events(request).append(
run_id=run_id,
workflow_id=run["workflow_id"],
version_id=run["workflow_version_id"],
event_type="run.resumed",
payload={"nodeId": node_id, "action": body.action},
node_id=node_id,
)
_nudge(request)
audit_workflow("run.resume", user_id=await get_current_user(request), runId=run_id, nodeId=node_id)
return _public_run(resumed)
@router.post("/runs/{run_id}/retry", status_code=201)
async def retry_run(request: Request, run_id: str) -> dict[str, Any]:
"""Start a new run from a failed one.
Completed node outputs are copied onto the new run so the engine resumes
from the first unfinished node. If there is nothing to replay, the new run
is a full replay of the same version and inputs, still marked
``retry_of_run_id``.
"""
_require_enabled()
run = await _authorized_run(request, run_id)
if run["status"] != "failed":
raise HTTPException(
status_code=409,
detail={"code": "WORKFLOW_RUN_NOT_RETRYABLE", "message": "只有失败的运行可以重试"},
)
user_id = await get_current_user(request)
runs = _runs(request)
active = [r for r in await runs.list_runs(owner_id=user_id, limit=200) if r["status"] in ("queued", "running")]
if len(active) >= _cfg().max_concurrent_runs_per_user:
raise HTTPException(
status_code=429,
detail={
"code": "WORKFLOW_LIMIT_EXCEEDED",
"message": f"同时运行的工作流已达上限({_cfg().max_concurrent_runs_per_user})",
},
)
context = dict(run.get("context") or {})
context.pop("resume_payload", None)
context["retry_of_run_id"] = run_id
new_run, _created = await runs.create_run(
{
"workflow_id": run["workflow_id"],
"workflow_version_id": run["workflow_version_id"],
"owner_id": user_id,
"input": run.get("input") or {},
"retry_of_run_id": run_id,
"context": context,
}
)
for row in await runs.list_node_runs(run_id, current_only=True):
if row.get("status") != "completed":
continue
await runs.record_node_run(
{
"run_id": new_run["id"],
"node_id": row["node_id"],
"node_type": row.get("node_type") or "unknown",
"status": "completed",
"attempt": 1,
"iteration": int(row.get("iteration") or 0),
"output": row.get("output"),
"duration_ms": row.get("duration_ms"),
"is_current": True,
}
)
await _events(request).append(
run_id=new_run["id"],
workflow_id=new_run["workflow_id"],
version_id=new_run["workflow_version_id"],
event_type="run.created",
payload={"retryOfRunId": run_id, "inputs": new_run.get("input") or {}},
)
_nudge(request)
audit_workflow("run.retry", user_id=user_id, runId=new_run["id"], retryOfRunId=run_id)
return {**_public_run(new_run), "created": True}
# ── events / SSE ────────────────────────────────────────────────────────
@router.get("/runs/{run_id}/events")
async def list_run_events(
request: Request,
run_id: str,
after: int = Query(default=0, ge=0),
after_seq: int | None = Query(default=None, alias="after_seq", ge=0),
limit: int = Query(default=_REPLAY_PAGE, ge=1, le=1000),
) -> dict[str, Any]:
_require_enabled()
await _authorized_run(request, run_id)
cursor = after_seq if after_seq is not None else after
rows = await _events(request).list_after(run_id, after_seq=cursor, limit=limit)
return {"events": [envelope_from_row(r) for r in rows], "lastSeq": rows[-1]["seq"] if rows else cursor}
def _frame(payload: dict[str, Any]) -> str:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str)
return f"id: {payload.get('seq', 0)}\nevent: {payload.get('event', 'message')}\ndata: {body}\n\n"
def _replay_cursor(request: Request, after: int) -> int:
"""``Last-Event-ID`` wins over ``?after=``; invalid headers fall back."""
raw = request.headers.get("last-event-id")
if raw:
try:
return max(0, int(str(raw).strip()))
except (TypeError, ValueError):
pass
return after
@router.get("/runs/{run_id}/stream")
async def stream_run(
request: Request,
run_id: str,
after: int = Query(default=0, ge=0),
after_seq: int | None = Query(default=None, alias="after_seq", ge=0),
) -> StreamingResponse:
"""Replay from ``after`` then tail live.
The database is authoritative for ordering: live frames whose ``seq`` we have
not yet replayed are held back and picked up by the next DB poll, so a
reconnecting client never sees a gap or an out-of-order ``seq``.
``Last-Event-ID`` takes priority over ``after``.
"""
_require_enabled()
run = await _authorized_run(request, run_id)
events = _events(request)
runs = _runs(request)
hub = getattr(request.app.state, "workflow_live_hub", None)
cursor_start = _replay_cursor(request, after_seq if after_seq is not None else after)
async def generator():
cursor = cursor_start
live_queue = hub.subscribe(run_id) if hub is not None else None
loop = asyncio.get_running_loop()
deadline = loop.time() + _STREAM_DEADLINE_SECONDS
next_poll = 0.0
last_beat = loop.time()
try:
while loop.time() < deadline:
if await request.is_disconnected():
return
if loop.time() >= next_poll:
while True:
rows = await events.list_after(run_id, after_seq=cursor, limit=_REPLAY_PAGE)
if not rows:
break
for row in rows:
yield _frame(envelope_from_row(row))
cursor = max(cursor, int(row["seq"]))
last_beat = loop.time()
if row["event_type"] in _TERMINAL_EVENTS:
return
if len(rows) < _REPLAY_PAGE:
break
current = await runs.get_run(run_id)
if current is None:
return
if current["status"] in _TERMINAL_STATUSES:
# Terminal state with no terminal event (e.g. a crash
# before the event landed): synthesise a closing frame
# so the client is never left hanging.
yield _frame(
{
"schemaVersion": "1.0",
"runId": run_id,
"workflowId": current["workflow_id"],
"versionId": current["workflow_version_id"],
"seq": cursor,
"event": f"run.{current['status']}",
"timestamp": datetime.now(UTC).isoformat(),
"data": {
"status": current["status"],
"output": current.get("output"),
"error": current.get("error"),
"synthesised": True,
},
}
)
return
next_poll = loop.time() + _DB_POLL_SECONDS
if live_queue is not None:
try:
event = await asyncio.wait_for(live_queue.get(), timeout=_LIVE_WAIT_SECONDS)
except TimeoutError:
event = None
if event is not None and int(event.get("seq") or 0) > cursor:
yield _frame(event)
cursor = int(event["seq"])
last_beat = loop.time()
if event.get("event") in _TERMINAL_EVENTS:
return
else:
await asyncio.sleep(_LIVE_WAIT_SECONDS)
if loop.time() - last_beat >= _HEARTBEAT_SECONDS:
yield ": heartbeat\n\n"
last_beat = loop.time()
finally:
if live_queue is not None and hub is not None:
hub.unsubscribe(run_id, live_queue)
return StreamingResponse(
generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Workflow-Run-Status": run["status"],
},
)
@@ -0,0 +1,351 @@
"""Studio facade: camelCase DTOs for the standalone Coze canvas page.
Four endpoints in front of the standard workflow store:
GET /api/workflows/{id}/studio-document
PUT /api/workflows/{id}/studio-draft
POST /api/workflows/{id}/studio-validate
POST /api/workflows/{id}/studio-publish
Design contract (docs/WORKFLOW_STUDIO_COZE_FRONTEND_ADAPTATION_ZH.md §3-§4):
- One workflow, two documents. ``canvasSchemaJson`` is an opaque Coze canvas
saved for editing; ``executionGraph`` is the WorkflowGraph v1.0 the runtime
executes. Both land in the SAME CAS update, so they can never disagree on
revision.
- The facade only maps DTO naming and delegates to the standard store /
validator / publish service — it never forks versioning logic.
- Publishing a workflow whose draft has no valid execution graph is a 400, not
a silent empty-graph publish.
"""
from __future__ import annotations
import json
from hashlib import sha256
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from app.gateway.workflow_audit import audit_workflow
from app.gateway.workflow_resource_checks import build_resource_catalog, make_resource_checker
from deerflow.config.app_config import get_app_config
from deerflow.persistence.workflows import (
WorkflowDraftConflictError,
WorkflowNotFoundError,
WorkflowStore,
)
from deerflow.workflows.schemas import WorkflowGraph
from deerflow.workflows.validator import validate_workflow_graph
router = APIRouter(prefix="/api/workflows", tags=["workflow-studio"])
# Canvas document guards: it is stored and replayed as an opaque document, so
# the only checks are size / JSON shape / depth / obvious credential keys.
_CANVAS_MAX_CHARS = 2_000_000
_CANVAS_MAX_DEPTH = 64
_CANVAS_SECRET_KEYS = frozenset(
{
"authorization",
"proxy-authorization",
"cookie",
"x-api-key",
"apikey",
"api_key",
"secret",
"client_secret",
"password",
"dsn",
"access_token",
"refresh_token",
"resumetoken",
"resume_token",
}
)
class StudioDraftBody(BaseModel):
expected_revision: int = Field(alias="expectedRevision", ge=0)
# Canvas may arrive as a JSON string or an embedded object; both are
# canonicalised to compact JSON text before storage.
canvas_schema_json: Any = Field(default=None, alias="canvasSchemaJson")
execution_graph: dict[str, Any] = Field(alias="executionGraph")
model_config = {"populate_by_name": True}
class StudioValidateBody(BaseModel):
execution_graph: dict[str, Any] | None = Field(default=None, alias="executionGraph")
model_config = {"populate_by_name": True}
class StudioPublishBody(BaseModel):
expected_revision: int | None = Field(default=None, alias="expectedRevision", ge=0)
change_note: str = Field(default="", alias="changeNote", max_length=2000)
model_config = {"populate_by_name": True}
def _require_enabled() -> None:
if not get_app_config().workflows.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _get_store(request: Request) -> WorkflowStore:
store = getattr(request.app.state, "workflow_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
return store
async def _load_owned(request: Request, workflow_id: str) -> dict[str, Any]:
"""Owner or admin only — the studio edits the draft, so read == write."""
from app.gateway.routers.workflows import _load_owned as _standard_load_owned
return await _standard_load_owned(request, workflow_id, write=True)
def _limits() -> dict[str, int]:
cfg = get_app_config().workflows
return {
"system_max_steps": cfg.max_steps,
"system_max_loop_iterations": cfg.max_loop_iterations,
"system_max_parallelism": cfg.max_parallelism,
}
def _parse_canvas(raw: Any) -> str:
"""Validate + canonicalise the canvas document into compact JSON text."""
if raw is None or raw == "":
return "{}"
if isinstance(raw, str):
text = raw
try:
document = json.loads(text)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_CANVAS_INVALID", "message": "画布 JSON 无法解析"},
) from exc
elif isinstance(raw, dict):
document = raw
else:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_CANVAS_INVALID", "message": "画布必须是 JSON 对象或 JSON 字符串"},
)
text = json.dumps(document, ensure_ascii=False, separators=(",", ":"))
if len(text) > _CANVAS_MAX_CHARS:
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_CANVAS_INVALID", "message": f"画布文档超过 {_CANVAS_MAX_CHARS} 字符上限"},
)
offending = _find_secret_key(document, 0)
if offending:
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_CANVAS_INVALID",
"message": f"画布中出现凭据类字段 {offending},请改用凭据引用",
"field": offending,
},
)
return text
def _find_secret_key(node: Any, depth: int) -> str | None:
if depth > _CANVAS_MAX_DEPTH:
return "<max-depth-exceeded>"
if isinstance(node, dict):
for key, value in node.items():
name = str(key).strip().lower()
if name in _CANVAS_SECRET_KEYS and value not in (None, "", {}, []):
return str(key)
found = _find_secret_key(value, depth + 1)
if found:
return found
elif isinstance(node, list):
for item in node:
found = _find_secret_key(item, depth + 1)
if found:
return found
return None
def _studio_issues(issues: list[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for issue in issues:
body = issue.model_dump(by_alias=True)
details = body.get("details") or {}
out.append(
{
"code": body.get("code"),
"nodeId": body.get("nodeId"),
"field": details.get("field") if isinstance(details, dict) else None,
"message": body.get("message"),
}
)
return out
async def _validate_with_resources(request: Request, workflow_id: str, raw_graph: dict[str, Any], user_id: str) -> list[Any]:
catalog = await build_resource_catalog(request, user_id=user_id)
checker = make_resource_checker(catalog, config=get_app_config().workflows, current_workflow_id=workflow_id)
return validate_workflow_graph(raw_graph, resource_checker=checker, **_limits())
@router.get("/{workflow_id}/studio-document")
async def get_studio_document(request: Request, workflow_id: str) -> dict[str, Any]:
_require_enabled()
definition = await _load_owned(request, workflow_id)
store = _get_store(request)
graph = definition.get("draft_graph") or {}
versions = await store.list_versions(workflow_id)
return {
"workflowId": definition["id"],
"name": definition.get("name"),
"description": definition.get("description") or "",
"status": definition.get("status"),
"draftRevision": int(definition.get("draft_revision") or 0),
"publishedVersionId": versions[0]["id"] if versions else None,
# Canvas stays an opaque JSON document (string); never null.
"canvasSchemaJson": definition.get("draft_canvas_schema") or "{}",
"executionGraph": graph,
"inputSchema": graph.get("inputSchema") or graph.get("input_schema") or {"type": "object", "properties": {}},
"outputSchema": graph.get("outputSchema") or graph.get("output_schema") or {"type": "object", "properties": {}},
"updatedAt": definition.get("updated_at"),
}
@router.put("/{workflow_id}/studio-draft")
async def put_studio_draft(request: Request, workflow_id: str, body: StudioDraftBody) -> dict[str, Any]:
_require_enabled()
await _load_owned(request, workflow_id)
store = _get_store(request)
user_id = await get_current_user(request)
try:
WorkflowGraph.model_validate(body.execution_graph)
except Exception as exc: # noqa: BLE001 - surface pydantic as 400
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_SCHEMA_INVALID", "message": f"执行图不符合 WorkflowGraph v1.0: {exc}"},
) from exc
canvas_text = _parse_canvas(body.canvas_schema_json)
try:
saved = await store.save_studio_draft(
workflow_id,
expected_revision=body.expected_revision,
graph=body.execution_graph,
canvas_schema=canvas_text,
updated_by=user_id,
)
except WorkflowDraftConflictError as exc:
raise HTTPException(
status_code=409,
detail={
"code": "WORKFLOW_DRAFT_CONFLICT",
"message": "草稿版本冲突,请刷新后重试或另存副本",
"currentRevision": exc.current_revision,
},
) from exc
except WorkflowNotFoundError as exc:
raise HTTPException(status_code=404, detail="工作流不存在") from exc
audit_workflow("workflow.studio_draft", user_id=user_id, workflowId=workflow_id, revision=saved.get("draft_revision"))
return {"revision": int(saved.get("draft_revision") or 0), "updatedAt": saved.get("updated_at")}
@router.post("/{workflow_id}/studio-validate")
async def studio_validate(request: Request, workflow_id: str, body: StudioValidateBody | None = None) -> dict[str, Any]:
_require_enabled()
definition = await _load_owned(request, workflow_id)
user_id = await get_current_user(request)
graph = (body.execution_graph if body and body.execution_graph is not None else definition.get("draft_graph")) or {}
try:
issues = await _validate_with_resources(request, workflow_id, graph, user_id)
except Exception as exc: # noqa: BLE001 - unparsable graph is a validation result, not a 500
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_SCHEMA_INVALID", "message": f"图结构无效: {exc}"},
) from exc
valid = not issues
graph_hash = None
if valid:
try:
graph_hash = WorkflowGraph.model_validate(graph).graph_hash()
except Exception: # noqa: BLE001
valid = False
return {"valid": valid, "issues": _studio_issues(issues), "graphHash": graph_hash}
@router.post("/{workflow_id}/studio-publish")
async def studio_publish(request: Request, workflow_id: str, body: StudioPublishBody | None = None) -> dict[str, Any]:
_require_enabled()
definition = await _load_owned(request, workflow_id)
store = _get_store(request)
user_id = await get_current_user(request)
body = body or StudioPublishBody()
graph_raw = definition.get("draft_graph") or {}
if not graph_raw or not (graph_raw.get("nodes") or []):
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_SCHEMA_INVALID",
"message": "草稿没有可发布的执行图(只有画布文档不能发布)",
},
)
try:
issues = await _validate_with_resources(request, workflow_id, graph_raw, user_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=400,
detail={"code": "WORKFLOW_SCHEMA_INVALID", "message": f"图结构无效: {exc}"},
) from exc
if issues:
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_SCHEMA_INVALID",
"message": "发布校验未通过",
"issues": _studio_issues(issues),
},
)
if body.expected_revision is not None and body.expected_revision != definition.get("draft_revision"):
raise HTTPException(
status_code=409,
detail={
"code": "WORKFLOW_DRAFT_CONFLICT",
"message": "发布前草稿版本已变化",
"currentRevision": definition.get("draft_revision"),
},
)
graph = WorkflowGraph.model_validate(graph_raw)
canvas_text = str(definition.get("draft_canvas_schema") or "{}")
canvas_hash = sha256(canvas_text.encode("utf-8")).hexdigest()
version = await store.publish_version(
workflow_id,
graph=graph.model_dump(by_alias=True),
graph_hash=graph.graph_hash(),
published_by=user_id,
change_note=body.change_note,
canvas_schema_hash=canvas_hash,
)
audit_workflow(
"workflow.studio_publish",
user_id=user_id,
workflowId=workflow_id,
versionId=version.get("id"),
graphHash=graph.graph_hash(),
)
return {
"versionId": version.get("id"),
"versionNumber": version.get("version_number"),
"publishedAt": version.get("published_at"),
}
@@ -0,0 +1,313 @@
"""Workflow definition / draft / publish API (phase 1 — no execution yet)."""
from __future__ import annotations
from hashlib import sha256
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.workflow_audit import audit_workflow
from app.gateway.workflow_resource_checks import build_resource_catalog, make_resource_checker
from deerflow.config.app_config import get_app_config
from deerflow.persistence.workflows import (
WorkflowDraftConflictError,
WorkflowNotFoundError,
WorkflowStore,
)
from deerflow.workflows.schemas import WorkflowGraph
from deerflow.workflows.validator import validate_workflow_graph
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
class WorkflowCreateRequest(BaseModel):
name: str = Field(default="未命名工作流", min_length=1, max_length=255)
description: str = ""
graph: dict[str, Any] | None = None
class WorkflowPatchRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
class DraftPutRequest(BaseModel):
expected_revision: int = Field(alias="expectedRevision", ge=0)
graph: dict[str, Any]
model_config = {"populate_by_name": True}
class PublishRequest(BaseModel):
change_note: str = Field(default="", alias="changeNote")
expected_revision: int | None = Field(default=None, alias="expectedRevision")
model_config = {"populate_by_name": True}
class ValidateRequest(BaseModel):
graph: dict[str, Any] | None = None
def _get_store(request: Request) -> WorkflowStore:
store = getattr(request.app.state, "workflow_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
return store
def _require_enabled() -> None:
cfg = get_app_config().workflows
if not cfg.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
async def _current_user_id(request: Request) -> str:
return await get_current_user(request)
def _can_access(definition: dict[str, Any], user_id: str, *, write: bool = False) -> bool:
# Phase 1: owner-only. Shared/org ACLs land later.
owner = definition.get("owner_id")
if owner == user_id:
return True
# Auth disabled environments often use "default" / anonymous — still owner-scoped.
if write:
return False
return False
async def _load_owned(request: Request, workflow_id: str, *, write: bool = False) -> dict[str, Any]:
store = _get_store(request)
user_id = await _current_user_id(request)
definition = await store.get_definition(workflow_id, include_draft=True)
if definition is None:
raise HTTPException(status_code=404, detail="工作流不存在")
if not _can_access(definition, user_id, write=write):
# Soft admin bypass for list/read when system_role=admin
user = await get_optional_user_from_request(request)
if user is not None and getattr(user, "system_role", None) == "admin" and not write:
return definition
if user is not None and getattr(user, "system_role", None) == "admin" and write:
return definition
raise HTTPException(status_code=403, detail="无权访问该工作流")
return definition
def _limits() -> dict[str, int]:
cfg = get_app_config().workflows
return {
"system_max_steps": cfg.max_steps,
"system_max_loop_iterations": cfg.max_loop_iterations,
"system_max_parallelism": cfg.max_parallelism,
}
@router.get("")
async def list_workflows(
request: Request,
include_archived: bool = Query(False, alias="includeArchived"),
status: str | None = None,
) -> dict[str, Any]:
_require_enabled()
store = _get_store(request)
user_id = await _current_user_id(request)
user = await get_optional_user_from_request(request)
is_admin = user is not None and getattr(user, "system_role", None) == "admin"
rows = await store.list_definitions(
owner_id=None if is_admin else user_id,
status=status,
include_archived=include_archived,
)
if is_admin:
# Still hide other users' drafts from non-explicit admin listing? Phase 1: admin sees all.
pass
else:
rows = [r for r in rows if r.get("owner_id") == user_id]
return {"workflows": rows}
@router.post("")
async def create_workflow(request: Request, body: WorkflowCreateRequest) -> dict[str, Any]:
_require_enabled()
store = _get_store(request)
user_id = await _current_user_id(request)
created = await store.create_definition(
{
"name": body.name,
"description": body.description,
"owner_id": user_id,
"draft_graph": body.graph,
}
)
audit_workflow("workflow.create", user_id=user_id, workflowId=created["id"])
return created
@router.get("/{workflow_id}")
async def get_workflow(request: Request, workflow_id: str) -> dict[str, Any]:
_require_enabled()
return await _load_owned(request, workflow_id, write=False)
@router.patch("/{workflow_id}")
async def patch_workflow(request: Request, workflow_id: str, body: WorkflowPatchRequest) -> dict[str, Any]:
_require_enabled()
await _load_owned(request, workflow_id, write=True)
store = _get_store(request)
updated = await store.update_definition(
workflow_id,
body.model_dump(exclude_unset=True),
)
if updated is None:
raise HTTPException(status_code=404, detail="工作流不存在")
return updated
@router.delete("/{workflow_id}")
async def archive_workflow(request: Request, workflow_id: str) -> dict[str, Any]:
_require_enabled()
await _load_owned(request, workflow_id, write=True)
store = _get_store(request)
archived = await store.archive_definition(workflow_id)
if archived is None:
raise HTTPException(status_code=404, detail="工作流不存在")
audit_workflow("workflow.archive", user_id=await _current_user_id(request), workflowId=workflow_id)
return archived
@router.post("/{workflow_id}/copy")
async def copy_workflow(request: Request, workflow_id: str) -> dict[str, Any]:
_require_enabled()
definition = await _load_owned(request, workflow_id, write=False)
store = _get_store(request)
user_id = await _current_user_id(request)
created = await store.create_definition(
{
"name": f"{definition.get('name') or '未命名工作流'} 副本",
"description": definition.get("description") or "",
"owner_id": user_id,
"draft_graph": definition.get("draft_graph"),
}
)
audit_workflow("workflow.copy", user_id=user_id, workflowId=created["id"], sourceWorkflowId=workflow_id)
return created
@router.put("/{workflow_id}/draft")
async def put_draft(request: Request, workflow_id: str, body: DraftPutRequest) -> dict[str, Any]:
_require_enabled()
await _load_owned(request, workflow_id, write=True)
store = _get_store(request)
user_id = await _current_user_id(request)
try:
# Light structural parse so obviously broken drafts fail early; full publish
# checks run on /validate and /publish.
WorkflowGraph.model_validate(body.graph)
except Exception as exc: # noqa: BLE001 — surface pydantic as 400
raise HTTPException(status_code=400, detail=f"图结构无效: {exc}") from exc
try:
return await store.save_draft(
workflow_id,
expected_revision=body.expected_revision,
graph=body.graph,
updated_by=user_id,
)
except WorkflowDraftConflictError as exc:
raise HTTPException(
status_code=409,
detail={
"code": "WORKFLOW_DRAFT_CONFLICT",
"message": "草稿版本冲突,请刷新后重试或另存副本",
"currentRevision": exc.current_revision,
},
) from exc
except WorkflowNotFoundError as exc:
raise HTTPException(status_code=404, detail="工作流不存在") from exc
@router.post("/{workflow_id}/validate")
async def validate_workflow(request: Request, workflow_id: str, body: ValidateRequest | None = None) -> dict[str, Any]:
_require_enabled()
definition = await _load_owned(request, workflow_id, write=False)
graph = (body.graph if body and body.graph is not None else definition.get("draft_graph")) or {}
issues = validate_workflow_graph(graph, **_limits())
return {
"ok": len(issues) == 0,
"issues": [i.model_dump(by_alias=True) for i in issues],
"graphHash": WorkflowGraph.model_validate(graph).graph_hash() if not issues else None,
}
@router.post("/{workflow_id}/publish")
async def publish_workflow(request: Request, workflow_id: str, body: PublishRequest | None = None) -> dict[str, Any]:
_require_enabled()
definition = await _load_owned(request, workflow_id, write=True)
body = body or PublishRequest()
if body.expected_revision is not None and body.expected_revision != definition.get("draft_revision"):
raise HTTPException(
status_code=409,
detail={
"code": "WORKFLOW_DRAFT_CONFLICT",
"message": "发布前草稿版本已变化",
"currentRevision": definition.get("draft_revision"),
},
)
graph_raw = definition.get("draft_graph") or {}
# Same resource/permission checks as the studio facade (§5.3): the standard
# publish path must not accept graphs referencing invisible agents or
# forbidden resources.
catalog = await build_resource_catalog(request, user_id=await _current_user_id(request))
checker = make_resource_checker(catalog, config=get_app_config().workflows, current_workflow_id=workflow_id)
issues = validate_workflow_graph(graph_raw, resource_checker=checker, **_limits())
if issues:
raise HTTPException(
status_code=400,
detail={
"code": "WORKFLOW_SCHEMA_INVALID",
"message": "发布校验未通过",
"issues": [i.model_dump(by_alias=True) for i in issues],
},
)
graph = WorkflowGraph.model_validate(graph_raw)
store = _get_store(request)
user_id = await _current_user_id(request)
canvas_text = str(definition.get("draft_canvas_schema") or "{}")
version = await store.publish_version(
workflow_id,
graph=graph.model_dump(by_alias=True),
graph_hash=graph.graph_hash(),
published_by=user_id,
change_note=body.change_note,
canvas_schema_hash=sha256(canvas_text.encode("utf-8")).hexdigest(),
)
audit_workflow(
"workflow.publish",
user_id=user_id,
workflowId=workflow_id,
versionId=version.get("id"),
graphHash=graph.graph_hash(),
)
return version
@router.get("/{workflow_id}/versions")
async def list_versions(request: Request, workflow_id: str) -> dict[str, Any]:
_require_enabled()
await _load_owned(request, workflow_id, write=False)
store = _get_store(request)
return {"versions": await store.list_versions(workflow_id)}
@router.get("/{workflow_id}/versions/{version_id}")
async def get_version(request: Request, workflow_id: str, version_id: str) -> dict[str, Any]:
_require_enabled()
await _load_owned(request, workflow_id, write=False)
store = _get_store(request)
version = await store.get_version(version_id)
if version is None or version.get("workflow_id") != workflow_id:
raise HTTPException(status_code=404, detail="版本不存在")
return version
@@ -0,0 +1,461 @@
"""Minimal Coze Studio workflow_api compatibility layer (phase 1).
Internal services always use DeerFlow draft DTOs. This module only translates
request/response shapes for the standalone canvas page: ``/canvas`` replies
with the Coze playground envelope and ``/save`` persists the Coze canvas into
``save_studio_draft`` without letting it anywhere near ``WorkflowGraph``
validation (the canvas is an opaque editing document; the executable graph is
compiled client-side and saved via the studio facade).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.routers.workflow_studio import _parse_canvas
from deerflow.config.app_config import get_app_config
from deerflow.persistence.workflows import (
WorkflowDraftConflictError,
WorkflowNotFoundError,
WorkflowStore,
)
router = APIRouter(prefix="/api/workflow_api", tags=["workflow-coze-compat"])
# Playground 启动还会打这些 Coze 壳接口;缺了它们画布和节点资源都不会挂载。
chrome_router = APIRouter(tags=["workflow-coze-compat"])
_DEFAULT_SPACE: dict[str, Any] = {
"id": "1",
"space_id": 1,
"name": "默认空间",
"description": "",
# SpaceType.Personal = 1;Playground 找不到个人空间会去调 space/save。
"space_type": 1,
"space_mode": 0,
"icon_uri": "",
"icon_url": "",
"owner_id": "1",
"owner_user_id": "1",
"role_type": 1,
"has_permission": True,
}
# Coze playground node_template_list payload. The standalone canvas maps
# DeerFlow node kinds onto these StandardNodeType ids (see the frontend's
# node-catalog.ts); entries keep the Coze field names (node_type / icon_url /
# color) so the playground's template list renders without adaptation.
_COZE_NODE_TEMPLATES: list[dict[str, Any]] = [
{"node_type": "1", "name": "开始", "icon_url": "", "color": "#5C62FF", "desc": "工作流入口,定义输入参数"},
{"node_type": "2", "name": "结束", "icon_url": "", "color": "#5C62FF", "desc": "工作流出口,汇总输出"},
{"node_type": "3", "name": "大模型", "icon_url": "", "color": "#5C62FF", "desc": "调用智能体处理任务", "support_batch": 1},
{"node_type": "5", "name": "代码", "icon_url": "", "color": "#00B2B2", "desc": "在沙箱中执行代码", "support_batch": 0},
{"node_type": "8", "name": "选择器", "icon_url": "", "color": "#00B2B2", "desc": "按条件分支执行", "support_batch": 0},
{"node_type": "9", "name": "工作流", "icon_url": "", "color": "#00B83E", "desc": "调用已发布子工作流", "support_batch": 1},
{"node_type": "13", "name": "输出", "icon_url": "", "color": "#5C62FF", "desc": "定义输出字段", "support_batch": 0},
{"node_type": "15", "name": "文本处理", "icon_url": "", "color": "#3071F2", "desc": "模板/文本转换", "support_batch": 0},
{"node_type": "18", "name": "问答", "icon_url": "", "color": "#3071F2", "desc": "人工介入确认", "support_batch": 0},
{"node_type": "21", "name": "循环", "icon_url": "", "color": "#00B2B2", "desc": "循环执行子图", "support_batch": 0},
{"node_type": "32", "name": "变量聚合", "icon_url": "", "color": "#00B2B2", "desc": "合并多分支变量", "support_batch": 0},
{"node_type": "43", "name": "查询数据", "icon_url": "", "color": "#F2B600", "desc": "SQL 只读查询", "support_batch": 0},
{"node_type": "45", "name": "HTTP 请求", "icon_url": "", "color": "#3071F2", "desc": "调用外部接口", "support_batch": 0},
]
_COZE_NODE_CATEGORIES: list[dict[str, Any]] = [
{"name": "", "node_type_list": ["3", "9"]},
{"name": "业务逻辑", "node_type_list": ["5", "8", "21", "32"]},
{"name": "输入&输出", "node_type_list": ["1", "2", "13"]},
{"name": "组件", "node_type_list": ["15", "18", "45"]},
{"name": "数据库", "node_type_list": ["43"]},
]
# Mirrors the standalone page's built-in empty canvas (start → end), used when
# a workflow has never been opened in the Coze editor.
_DEFAULT_CANVAS_SCHEMA = json.dumps(
{
"nodes": [
{
"id": "100001",
"type": "1",
"meta": {"position": {"x": 0, "y": 0}},
"data": {"outputs": [{"type": "string", "name": "", "required": True}]},
},
{
"id": "900001",
"type": "2",
"meta": {"position": {"x": 1000, "y": 0}},
},
],
"edges": [{"sourceNodeID": "100001", "targetNodeID": "900001"}],
},
ensure_ascii=False,
)
class CozeCreateRequest(BaseModel):
name: str = Field(default="未命名工作流")
description: str = ""
graph: dict[str, Any] | None = None
class CozeCanvasRequest(BaseModel):
workflow_id: str | None = Field(default=None, alias="workflowId")
workflow_id_snake: str | None = Field(default=None, alias="workflow_id")
model_config = {"populate_by_name": True}
def resolved_id(self) -> str | None:
return self.workflow_id or self.workflow_id_snake
class CozeSaveRequest(BaseModel):
workflow_id: str | None = Field(default=None, alias="workflowId")
workflow_id_snake: str | None = Field(default=None, alias="workflow_id")
expected_revision: int | None = Field(default=None, alias="expectedRevision")
expected_revision_snake: int | None = Field(default=None, alias="expected_revision")
# Coze posts the canvas as a JSON string under ``schema``.
schema_: str | dict[str, Any] | None = Field(default=None, alias="schema")
graph: str | dict[str, Any] | None = None
data: dict[str, Any] | None = None
model_config = {"populate_by_name": True}
def resolved_id(self) -> str | None:
return self.workflow_id or self.workflow_id_snake
def resolved_revision(self) -> int | None:
if self.expected_revision is not None:
return self.expected_revision
return self.expected_revision_snake
def resolved_canvas(self) -> str | dict[str, Any] | None:
if self.schema_ is not None:
return self.schema_
if isinstance(self.graph, (str, dict)):
return self.graph
if self.data is not None:
for key in ("schema", "graph"):
value = self.data.get(key)
if isinstance(value, (str, dict)):
return value
return self.data
return None
def _require_enabled() -> None:
if not get_app_config().workflows.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _get_store(request: Request) -> WorkflowStore:
store = getattr(request.app.state, "workflow_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow store not available")
return store
def _canvas_schema_text(definition: dict[str, Any]) -> str:
raw = definition.get("draft_canvas_schema")
if isinstance(raw, str) and raw.strip() not in ("", "{}", "null"):
return raw
if isinstance(raw, dict) and raw:
return json.dumps(raw, ensure_ascii=False)
return _DEFAULT_CANVAS_SCHEMA
def _to_coze_canvas(definition: dict[str, Any], *, is_published: bool = False) -> dict[str, Any]:
owner = str(definition.get("owner_id") or "1")
return {
"code": 0,
"msg": "success",
"data": {
"workflow": {
"workflow_id": definition["id"],
"space_id": "1",
"name": definition.get("name"),
"desc": definition.get("description") or "",
"schema_json": _canvas_schema_text(definition),
"status": 1,
"collaborator_mode": "Open",
"creator": {"user_id": owner, "nickname": owner},
"is_published": is_published,
},
"vcs_data": {"can_edit": True},
"db_data": {"status": 1},
"operation_info": {},
"is_bind_agent": False,
"workflow_version": "1",
},
}
async def _load_owned_definition(request: Request, store: WorkflowStore, workflow_id: str, user_id: str) -> dict[str, Any]:
definition = await store.get_definition(workflow_id, include_draft=True)
if definition is None:
raise HTTPException(status_code=404, detail="工作流不存在")
if definition.get("owner_id") == user_id:
return definition
user = await get_optional_user_from_request(request)
if user is not None and getattr(user, "system_role", None) == "admin":
return definition
raise HTTPException(status_code=403, detail="无权访问该工作流")
def _coze_ok(data: dict[str, Any]) -> dict[str, Any]:
return {"code": 0, "msg": "success", "data": data}
def _account_payload(user: Any, user_id: str) -> dict[str, Any]:
email = str(getattr(user, "email", "") or "")
nickname = email.split("@", 1)[0] if email else user_id
return {
"user_id_str": user_id,
"user_id": user_id,
"nickname": nickname,
"name": nickname,
"email": email,
"avatar_url": "",
"login_status": "logined",
}
async def _require_studio_user(request: Request) -> tuple[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
user = await get_optional_user_from_request(request)
return user_id, user
def _space_list_payload() -> dict[str, Any]:
return {
"has_personal_space": True,
"bot_space_list": [_DEFAULT_SPACE],
"team_space_num": 0,
"max_team_space_num": 10,
"recently_used_space_list": [],
}
@chrome_router.api_route("/api/passport/account/info/v2/", methods=["GET", "POST"])
async def coze_account_info(request: Request) -> dict[str, Any]:
user_id, user = await _require_studio_user(request)
return _coze_ok(_account_payload(user, user_id))
@chrome_router.api_route("/api/space/list", methods=["GET", "POST"])
async def coze_space_list(request: Request) -> dict[str, Any]:
await _require_studio_user(request)
payload = _space_list_payload()
# developer_api.SpaceList 把列表放在顶层;playground v2 读 data。
return {"code": 0, "msg": "success", "data": payload, **payload}
@chrome_router.api_route(
"/api/playground_api/space/list",
methods=["GET", "POST", "PUT", "PATCH"],
)
@chrome_router.api_route(
"/api/playground_api/space/list/",
methods=["GET", "POST", "PUT", "PATCH"],
)
async def coze_playground_space_list(request: Request) -> dict[str, Any]:
await _require_studio_user(request)
return _coze_ok(_space_list_payload())
@chrome_router.api_route(
"/api/playground_api/space/save",
methods=["GET", "POST"],
)
async def coze_playground_space_save(request: Request) -> dict[str, Any]:
await _require_studio_user(request)
return _coze_ok({"id": _DEFAULT_SPACE["id"], "check_not_pass": False})
def _coze_model_type(name: str) -> int:
"""Stable positive int32 from the model name.
The Coze LLM form stores the picked model as ``modelType`` (number) plus
``modleName`` (string, upstream typo) — the compiler reads the string, so
the number only needs to be stable within a deployment, not reversible.
"""
digest = 5381
for char in name:
digest = ((digest * 33) + ord(char)) & 0xFFFFFFFF
return digest & 0x7FFFFFFF
@chrome_router.api_route("/api/bot/get_type_list", methods=["GET", "POST"])
async def coze_model_type_list(request: Request) -> dict[str, Any]:
"""LLM form model list → real DeerFlow models (names only, no secrets).
The Coze ``Model`` shape is trimmed to what the workflow form consumes:
``model_type`` (stable number), ``name`` (the DeerFlow model name, stored
verbatim into the node so the runtime can override the model), and empty
ability/params maps — ``getLLMModels`` fills in response_format defaults
client-side. Parameter tuning panels stay inert: model tuning happens in
DeerFlow, the workflow node only picks which model to call.
"""
await _require_studio_user(request)
config = get_app_config()
configured = list(getattr(config, 'models', None) or [])
model_list = [
{
"model_type": _coze_model_type(model.name),
"name": model.name,
"display_name": model.display_name or model.name,
"description": "",
"model_ability": {},
"model_params": [],
"is_custom": False,
}
for model in configured
]
return _coze_ok({"model_list": model_list})
@router.post("/create")
async def coze_create(request: Request, body: CozeCreateRequest) -> dict[str, Any]:
_require_enabled()
store = _get_store(request)
user_id = await get_current_user(request)
created = await store.create_definition(
{
"name": body.name,
"description": body.description,
"owner_id": user_id,
}
)
return {"code": 0, "msg": "success", "data": {"workflow_id": created["id"]}}
@router.post("/canvas")
async def coze_canvas(request: Request, body: CozeCanvasRequest) -> dict[str, Any]:
_require_enabled()
workflow_id = body.resolved_id()
if not workflow_id:
raise HTTPException(status_code=400, detail="workflow_id required")
store = _get_store(request)
user_id = await get_current_user(request)
definition = await _load_owned_definition(request, store, workflow_id, user_id)
versions = await store.list_versions(workflow_id)
return _to_coze_canvas(definition, is_published=bool(versions))
@router.post("/save")
async def coze_save(request: Request, body: CozeSaveRequest) -> dict[str, Any]:
_require_enabled()
workflow_id = body.resolved_id()
if not workflow_id:
raise HTTPException(status_code=400, detail="workflow_id required")
canvas_raw = body.resolved_canvas()
if canvas_raw is None or canvas_raw == "" or canvas_raw == {}:
raise HTTPException(status_code=400, detail="schema required")
canvas_text = _parse_canvas(canvas_raw)
store = _get_store(request)
user_id = await get_current_user(request)
definition = await _load_owned_definition(request, store, workflow_id, user_id)
expected = body.resolved_revision()
if expected is None:
expected = int(definition.get("draft_revision") or 0)
try:
await store.save_studio_draft(
workflow_id,
expected_revision=expected,
# The execution graph is owned by the studio facade; the Coze save
# only persists the canvas, so the current graph passes through.
graph=definition.get("draft_graph") or {},
canvas_schema=canvas_text,
updated_by=user_id,
)
except WorkflowDraftConflictError as exc:
raise HTTPException(
status_code=409,
detail={
"code": "WORKFLOW_DRAFT_CONFLICT",
"currentRevision": exc.current_revision,
},
) from exc
except WorkflowNotFoundError as exc:
raise HTTPException(status_code=404, detail="工作流不存在") from exc
return {"code": 0, "msg": "success", "data": {}}
@router.post("/node_template_list")
async def coze_node_template_list(request: Request) -> dict[str, Any]:
_require_enabled()
await get_current_user(request)
# Coze playground envelope: data.template_list / cate_list (§ workflow-playground-context).
return {
"code": 0,
"msg": "success",
"data": {
"template_list": _COZE_NODE_TEMPLATES,
"cate_list": _COZE_NODE_CATEGORIES,
"plugin_api_list": [],
"plugin_category_list": [],
},
}
@router.post("/workflow_references")
async def coze_workflow_references(request: Request) -> dict[str, Any]:
"""Which workflows reference the current one via subworkflow nodes.
Honest empty answer: the standalone page's subworkflow form is not wired
yet, so no canvas can reference another — an empty list is the truthful
state, not a placeholder for missing data.
"""
_require_enabled()
await get_current_user(request)
return {"code": 0, "msg": "success", "data": {"workflow_list": []}}
@router.post("/nodeDebug")
async def coze_node_debug(request: Request) -> dict[str, Any]:
"""Single-node debug: NOT wired to the DeerFlow executor.
Deliberately a business error (Coze envelope code != 0) so the playground
surfaces an explicit message instead of pretending the debug succeeded.
"""
_require_enabled()
await get_current_user(request)
return {
"code": 4003101,
"msg": "节点单点调试暂未接入 DeerFlow 执行器,请使用画布下方「试运行」运行整个工作流",
}
@router.post("/llm_fc_setting_detail")
@router.post("/llm_fc_setting_merged")
async def coze_llm_fc_setting(request: Request) -> dict[str, Any]:
"""LLM function-call settings detail/merge for plugin/workflow lists.
No Coze plugins are wired into the canvas, so every detail map is empty.
"""
_require_enabled()
await get_current_user(request)
return {
"code": 0,
"msg": "success",
"data": {
"plugin_detail_map": {},
"plugin_api_detail_map": {},
"workflow_detail_map": {},
"dataset_detail_map": {},
"knowledge_detail_map": {},
},
}
@chrome_router.post("/api/devops/debugger/v1/coze/testcase/casedata/mget")
async def coze_testcase_casedata_mget(request: Request) -> dict[str, Any]:
"""Playground test-case fetch: no Coze test cases exist for this page."""
await _require_studio_user(request)
return {"code": 0, "msg": "success", "data": {"case_data_list": [], "total": 0}}
@@ -0,0 +1,209 @@
"""Runs a DeerFlow agent for one workflow ``agent`` / ``skill`` node.
This is the single place where the workflow runtime touches LangGraph. We build
the lead agent with :func:`make_lead_agent` (so the node inherits the same
middleware chain, SOUL, skills and model resolution as a normal chat turn) and
stream ``stream_mode=["values", "messages"]`` so the node can forward token
deltas into the workflow event log.
Each node gets its own thread id (``wf-{run_id}-{node_id}``) unless the node
asks for ``shared_run_thread``, which keeps the whole run in one conversation.
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from langchain_core.messages import AIMessage, ToolMessage
from deerflow.workflows.errors import WorkflowError
logger = logging.getLogger(__name__)
def _extract_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text") or ""))
return "".join(parts)
return ""
def _thread_id(run_id: str, node_id: str, thread_mode: str) -> str:
if thread_mode == "shared_run_thread":
return f"wf-{run_id}"
return f"wf-{run_id}-{node_id}"
class WorkflowAgentRunner:
def __init__(self, app: Any) -> None:
self._app = app
async def run_agent(
self,
*,
run_id: str,
node_id: str,
owner_id: str,
agent_id: str,
prompt: str,
skill_names: list[str] | None = None,
thread_mode: str = "isolated_per_node",
cancel: Any = None,
on_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
model_name: str | None = None,
) -> dict[str, Any]:
from deerflow.agents.lead_agent.agent import make_lead_agent
thread_id = _thread_id(run_id, node_id, thread_mode)
configurable: dict[str, Any] = {
"thread_id": thread_id,
"agent_id": agent_id,
"agent_name": agent_id,
"user_id": owner_id,
"model_name": model_name or "",
# Workflow nodes are non-interactive: no plan mode, no clarification.
"is_plan_mode": False,
"workflow_run_id": run_id,
"workflow_node_id": node_id,
}
if skill_names:
configurable["skill_names"] = list(skill_names)
config = {"configurable": configurable, "recursion_limit": 60}
try:
agent = make_lead_agent(config)
except Exception as exc: # noqa: BLE001
logger.warning("failed to build agent %s for workflow node %s", agent_id, node_id, exc_info=True)
raise WorkflowError(
"WORKFLOW_RESOURCE_MISSING",
"引用的智能体不存在或配置无效",
node_id=node_id,
details={"agentId": agent_id},
) from exc
checkpointer = getattr(self._app.state, "checkpointer", None)
if checkpointer is not None:
agent.checkpointer = checkpointer
store = getattr(self._app.state, "store", None)
if store is not None:
agent.store = store
seen_tool_calls: set[str] = set()
finished_tool_calls: set[str] = set()
tool_names: dict[str, str] = {}
final_state: dict[str, Any] | None = None
streamed: dict[str, list[str]] = {}
last_message_id = ""
async for mode, chunk in agent.astream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
stream_mode=["values", "messages"],
):
if cancel is not None and getattr(cancel, "cancelled", False):
raise WorkflowError("WORKFLOW_CANCELLED", "运行已被取消", node_id=node_id)
if mode == "values":
if isinstance(chunk, dict):
final_state = chunk
continue
if mode != "messages":
continue
message = chunk[0] if isinstance(chunk, tuple) and chunk else chunk
if isinstance(message, ToolMessage):
# Tool result for an earlier call: close its step in the event log.
call_id = str(getattr(message, "tool_call_id", "") or "")
if call_id and call_id not in finished_tool_calls:
finished_tool_calls.add(call_id)
if on_tool is not None:
content = getattr(message, "content", "")
if not isinstance(content, str):
content = str(content)
await on_tool(
{
"phase": "finished",
"id": call_id,
"name": tool_names.get(call_id) or getattr(message, "name", "") or "",
"ok": getattr(message, "status", None) != "error",
"summary": content[:200],
}
)
continue
if not isinstance(message, AIMessage):
continue
text = _extract_text(getattr(message, "content", ""))
if text:
message_id = str(getattr(message, "id", "") or "")
streamed.setdefault(message_id, []).append(text)
last_message_id = message_id
if on_delta is not None:
await on_delta(text)
for call in getattr(message, "tool_calls", None) or []:
call_id = str(call.get("id") or "")
if not call_id or call_id in seen_tool_calls:
continue
seen_tool_calls.add(call_id)
tool_names[call_id] = str(call.get("name") or "")
if on_tool is not None:
await on_tool(
{
"phase": "started",
"id": call_id,
"name": tool_names[call_id],
"args": call.get("args"),
}
)
final_text = "".join(streamed.get(last_message_id, ())) or self._final_text(final_state)
if not final_text.strip():
raise WorkflowError(
"WORKFLOW_AGENT_FAILED",
"智能体没有返回任何内容",
retryable=True,
node_id=node_id,
)
return {
"text": final_text,
"thread_id": thread_id,
"messages": [],
"artifacts": self._artifacts(final_state),
"usage": {},
}
def _final_text(self, state: dict[str, Any] | None) -> str:
for message in reversed((state or {}).get("messages") or []):
if isinstance(message, AIMessage):
text = _extract_text(getattr(message, "content", "")).strip()
if text:
return text
return ""
def _artifacts(self, state: dict[str, Any] | None) -> list[dict[str, Any]]:
raw = (state or {}).get("artifacts")
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if isinstance(item, dict) and item.get("name"):
out.append(
{
"name": str(item.get("name")),
"path": str(item.get("path") or ""),
"mime_type": str(item.get("mime_type") or item.get("mimeType") or ""),
"size_bytes": item.get("size_bytes") or item.get("sizeBytes"),
"preview": item.get("preview"),
}
)
return out
__all__ = ["WorkflowAgentRunner"]
@@ -0,0 +1,22 @@
"""Structured audit lines for Workflow Studio mutating APIs.
These are operator logs, not a separate audit table: every line is a single
JSON object with a stable ``action`` plus the ids needed to reconstruct who
did what. Secrets never go in.
"""
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("deerflow.workflows.audit")
def audit_workflow(action: str, *, user_id: str, **fields: Any) -> None:
payload = {"action": action, "userId": user_id, **fields}
logger.info("workflow.audit %s", json.dumps(payload, ensure_ascii=False, default=str))
__all__ = ["audit_workflow"]
@@ -0,0 +1,151 @@
"""Lease-based dispatcher for workflow runs.
A single background loop scans for claimable runs (``queued``, or ``running``
with an expired lease) and hands each successfully claimed run to the executor.
Because the claim is an atomic conditional UPDATE, several workers can run this
loop concurrently and a run that dies with its worker is picked up by whoever
notices the expired lease first — that is the restart-recovery story.
``nudge()`` is called by the start/resume routes so a fresh run does not wait
for the next poll tick.
"""
from __future__ import annotations
import asyncio
import logging
import os
import socket
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from app.gateway.workflow_executor import LeaseParams, WorkflowRunExecutor
from deerflow.config.workflow_config import WorkflowConfig
from deerflow.persistence.workflow_events.base import WorkflowEventStore
from deerflow.persistence.workflow_runs.base import WorkflowRunStore
from deerflow.workflows.runtime import PersistingWorkflowEventSink
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 2.0
_CLAIM_BATCH = 5
def _worker_id() -> str:
return f"{socket.gethostname()[:24]}-{os.getpid()}-{uuid.uuid4().hex[:6]}"
class WorkflowRunDispatcher:
def __init__(
self,
store: WorkflowRunStore,
executor: WorkflowRunExecutor,
*,
event_store: WorkflowEventStore | None = None,
live_publisher: Any = None,
config: WorkflowConfig | None = None,
) -> None:
self._store = store
self._executor = executor
self._events = event_store
self._publish = live_publisher
self._config = config or WorkflowConfig()
self._worker_id = _worker_id()
self._wake = asyncio.Event()
self._task: asyncio.Task | None = None
self._stopping = False
@property
def worker_id(self) -> str:
return self._worker_id
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="workflow-run-dispatcher")
logger.info("workflow dispatcher started (worker=%s)", self._worker_id)
async def stop(self) -> None:
self._stopping = True
self._wake.set()
if self._task is not None:
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
self._task = None
await self._executor.drain()
def nudge(self) -> None:
self._wake.set()
async def _loop(self) -> None:
await self._safe_dispatch()
while not self._stopping:
try:
await asyncio.wait_for(self._wake.wait(), timeout=_POLL_INTERVAL_SECONDS)
except TimeoutError:
pass
self._wake.clear()
if self._stopping:
return
await self._safe_dispatch()
async def _safe_dispatch(self) -> None:
try:
await self.dispatch_once()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - the loop must survive a bad round
logger.exception("workflow dispatch round failed")
async def dispatch_once(self) -> int:
now = datetime.now(UTC)
await self._reap_cancel_requested()
candidates = await self._store.list_claimable(now=now, limit=_CLAIM_BATCH)
if not candidates:
return 0
ttl = max(30, self._config.lease_ttl_seconds)
started = 0
for run_id in candidates:
row = await self._store.claim_run(
run_id,
lease_owner=self._worker_id,
lease_until=now + timedelta(seconds=ttl),
)
if row is None:
continue
if self._executor.start(LeaseParams(run_id=run_id, lease_owner=self._worker_id, attempt=int(row.get("attempt") or 1))):
started += 1
return started
async def _reap_cancel_requested(self) -> None:
"""Finalize cancels whose worker is gone (no live lease to notice them)."""
rows = await self._store.list_runs(status="cancel_requested", limit=20)
now = datetime.now(UTC)
for row in rows:
lease_until = row.get("lease_until")
if lease_until:
try:
if datetime.fromisoformat(str(lease_until)) > now:
continue # its worker is alive and will handle the cancel
except ValueError:
pass
finalized = await self._store.finalize_cancel(row["id"])
if finalized is not None and self._events is not None:
# Nobody owns this run, so the dispatcher owes the stream its
# terminal frame — otherwise open SSE connections hang.
sink = PersistingWorkflowEventSink(
self._events,
run_id=row["id"],
workflow_id=row["workflow_id"],
version_id=row["workflow_version_id"],
publish=self._publish,
)
await sink.emit("run.cancelled", data={"reapedBy": self._worker_id})
__all__ = ["WorkflowRunDispatcher"]
@@ -0,0 +1,453 @@
"""Executes leased workflow runs and owns every terminal state transition.
Lifecycle for one lease:
1. resolve the published version → immutable graph snapshot,
2. replay completed node results from ``workflow_node_runs`` (so a re-lease or a
resume never re-runs finished work),
3. drive :class:`WorkflowEngine` under a run-level timeout with a heartbeat
renewing the lease and a watcher polling for ``cancel_requested``,
4. write exactly one terminal outcome: ``completed`` / ``failed`` /
``cancelled`` / ``awaiting_input``.
Every exit path emits a matching terminal event, because the SSE endpoint uses
those events to decide when to close a stream.
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from deerflow.config.workflow_config import WorkflowConfig
from deerflow.persistence.workflow_events.base import WorkflowEventStore
from deerflow.persistence.workflow_runs.base import WorkflowRunStore
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.nodes import build_default_registry
from deerflow.workflows.runtime import (
CancelToken,
PersistingWorkflowEventSink,
RunContext,
WorkflowEngine,
WorkflowPaused,
WorkflowRuntimeDeps,
)
from deerflow.workflows.schemas import WorkflowGraph
logger = logging.getLogger(__name__)
_HEARTBEAT_INTERVAL_SECONDS = 15
_CANCEL_POLL_SECONDS = 2.0
_MAX_SUBWORKFLOW_DEPTH = 3
@dataclass
class LeaseParams:
run_id: str
lease_owner: str
attempt: int = 1
class WorkflowRunExecutor:
def __init__(
self,
app: Any,
*,
run_store: WorkflowRunStore,
event_store: WorkflowEventStore,
workflow_store: Any,
data_source_store: Any = None,
live_publisher: Any = None,
config: WorkflowConfig | None = None,
) -> None:
self._app = app
self._runs = run_store
self._events = event_store
self._workflows = workflow_store
self._data_sources = data_source_store
self._publish = live_publisher
self._config = config or WorkflowConfig()
self._registry = build_default_registry(self._config)
self._tasks: dict[str, asyncio.Task] = {}
# ── public API ──────────────────────────────────────────────────────
def start(self, params: LeaseParams) -> bool:
"""Launch a background task for a leased run. Idempotent per run id."""
existing = self._tasks.get(params.run_id)
if existing is not None and not existing.done():
return False
task = asyncio.create_task(self._guarded(params), name=f"workflow-run-{params.run_id}")
self._tasks[params.run_id] = task
task.add_done_callback(lambda _t, rid=params.run_id: self._tasks.pop(rid, None))
return True
async def drain(self, timeout: float = 5.0) -> None:
tasks = [t for t in self._tasks.values() if not t.done()]
if not tasks:
return
for task in tasks:
task.cancel()
await asyncio.wait(tasks, timeout=timeout)
# ── run driver ──────────────────────────────────────────────────────
async def _guarded(self, params: LeaseParams) -> None:
try:
await self._execute_lease(params)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - never let a run kill the dispatcher
logger.exception("workflow run %s crashed outside the engine", params.run_id)
await self._fail(
params,
WorkflowError("WORKFLOW_INTERNAL", "运行发生内部错误,请查看服务端日志"),
)
async def _execute_lease(self, params: LeaseParams) -> None:
run = await self._runs.get_run(params.run_id)
if run is None:
return
sink = self._sink(run)
graph = await self._load_graph(run)
if graph is None:
await self._fail(
params,
WorkflowError("WORKFLOW_VERSION_NOT_FOUND", "运行引用的工作流版本不存在"),
sink=sink,
)
return
cancel = CancelToken()
resume_payload = (run.get("context") or {}).get("resume_payload")
preloaded, loop_state = await self._replay_state(params.run_id)
ctx = RunContext(
run_id=run["id"],
workflow_id=run["workflow_id"],
version_id=run["workflow_version_id"],
owner_id=run["owner_id"],
graph=graph,
inputs=run.get("input") or {},
deps=self._deps(depth=int((run.get("context") or {}).get("depth") or 0)),
cancel=cancel,
emit=sink.emit,
resume_payload=resume_payload if isinstance(resume_payload, dict) else None,
)
await sink.emit(
"run.started" if not preloaded else "run.resumed",
data={"attempt": params.attempt, "replayedNodes": sorted(preloaded)},
)
engine = WorkflowEngine(self._registry, run_store=self._runs)
main = asyncio.create_task(
asyncio.wait_for(
engine.run(ctx, preloaded=preloaded, loop_state=loop_state, attempt=params.attempt),
timeout=min(graph.settings.run_timeout_seconds, self._config.run_timeout_seconds),
)
)
heartbeat = asyncio.create_task(self._heartbeat(params, main))
watcher = asyncio.create_task(self._watch_cancel(params, cancel, main))
try:
result = await main
except WorkflowPaused as paused:
await self._pause(params, paused, ctx, sink)
return
except TimeoutError:
await self._fail(
params,
WorkflowError(
"WORKFLOW_TIMEOUT",
f"运行超时({graph.settings.run_timeout_seconds}s)",
details={"timeoutSeconds": graph.settings.run_timeout_seconds},
),
sink=sink,
)
return
except WorkflowError as exc:
if exc.code == "WORKFLOW_CANCELLED" or cancel.cancelled:
await self._cancel(params, sink)
else:
await self._fail(params, exc, sink=sink)
return
except asyncio.CancelledError:
# Lease lost or process shutting down: leave the run claimable again.
await self._runs.update_run(params.run_id, lease_owner=params.lease_owner, status="queued")
raise
finally:
heartbeat.cancel()
watcher.cancel()
for task in (heartbeat, watcher):
try:
await task
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
await self._complete(params, result, sink)
# ── terminal transitions ────────────────────────────────────────────
async def _complete(self, params: LeaseParams, result: Any, sink: Any) -> None:
await self._runs.update_run(
params.run_id,
lease_owner=params.lease_owner,
status="completed",
output_json=result.output,
context_json={"loop_state": result.loop_state, "steps": result.steps},
finished_at=datetime.now(UTC),
)
await sink.emit("run.completed", data={"output": result.output, "steps": result.steps})
async def _fail(self, params: LeaseParams, error: WorkflowError, *, sink: Any = None) -> None:
run = await self._runs.get_run(params.run_id)
if run is None:
return
body = error.to_body().model_dump(by_alias=True)
retry = error.retryable and int(run.get("attempt") or 1) < int(run.get("max_attempts") or 1)
sink = sink or self._sink(run)
if retry:
await self._runs.update_run(
params.run_id,
lease_owner=params.lease_owner,
status="queued",
error_json=body,
)
await sink.emit("run.queued", data={"retryAfterError": body, "attempt": run.get("attempt")})
return
await self._runs.update_run(
params.run_id,
lease_owner=params.lease_owner,
status="failed",
error_json=body,
finished_at=datetime.now(UTC),
)
await sink.emit("run.failed", data={"error": body})
async def _cancel(self, params: LeaseParams, sink: Any) -> None:
await self._runs.request_cancel(params.run_id)
finalized = await self._runs.finalize_cancel(params.run_id)
if finalized is None:
await self._runs.update_run(params.run_id, lease_owner=params.lease_owner, status="cancelled")
await sink.emit("run.cancelled", data={})
async def _pause(self, params: LeaseParams, paused: WorkflowPaused, ctx: RunContext, sink: Any) -> None:
token = secrets.token_urlsafe(24)
# One conditional update writes status + token + pending descriptor +
# run context together; a separate follow-up context write could clobber
# a resume that lands in between.
updated = await self._runs.set_awaiting_input(
params.run_id,
lease_owner=params.lease_owner,
pending_input=paused.pending_input,
resume_token=token,
context={"loop_state": dict(ctx.loop_state), "resume_payload": None},
)
if updated is None:
# Lost the lease (cancel or steal) — do not fabricate a pause.
logger.info("workflow run %s could not enter awaiting_input", params.run_id)
return
await sink.emit(
"run.awaiting_input",
data={
# Flattened wire contract: the full descriptor keeps living in
# the run row (pending_input), the event exposes exactly what a
# client needs to render the intervention card.
"resumeToken": token,
"prompt": paused.pending_input.get("prompt") or "",
"formSchema": paused.pending_input.get("formSchema") or {},
"actions": paused.pending_input.get("actions") or ["submit"],
},
node_id=paused.node_id,
)
# ── helpers ─────────────────────────────────────────────────────────
def _sink(self, run: dict[str, Any]) -> PersistingWorkflowEventSink:
return PersistingWorkflowEventSink(
self._events,
run_id=run["id"],
workflow_id=run["workflow_id"],
version_id=run["workflow_version_id"],
publish=self._publish,
)
async def _load_graph(self, run: dict[str, Any]) -> WorkflowGraph | None:
# Draft test-runs carry their execution graph in run.context (version id
# is the literal "draft", no published row exists for it).
draft_graph = (run.get("context") or {}).get("draftGraph")
if draft_graph:
try:
return WorkflowGraph.model_validate(draft_graph)
except Exception: # noqa: BLE001
logger.warning("draft run %s has an invalid graph", run["id"], exc_info=True)
return None
version = await self._workflows.get_version(run["workflow_version_id"])
if version is None or version.get("workflow_id") != run["workflow_id"]:
return None
try:
return WorkflowGraph.model_validate(version.get("graph") or {})
except Exception: # noqa: BLE001
logger.warning("workflow version %s has an invalid graph", run["workflow_version_id"], exc_info=True)
return None
async def _replay_state(self, run_id: str) -> tuple[dict[str, Any], dict[str, int]]:
run = await self._runs.get_run(run_id)
loop_state = {}
raw_loop = (run or {}).get("context", {}).get("loop_state")
if isinstance(raw_loop, dict):
loop_state = {str(k): int(v) for k, v in raw_loop.items() if isinstance(v, int)}
completed: dict[str, Any] = {}
for row in await self._runs.list_node_runs(run_id, current_only=True):
if row.get("status") != "completed":
continue
output = row.get("output")
if isinstance(output, dict):
completed[str(row["node_id"])] = output
return completed, loop_state
def _deps(self, *, depth: int = 0) -> WorkflowRuntimeDeps:
from app.gateway.workflow_agent_runner import WorkflowAgentRunner
agent_runner = WorkflowAgentRunner(self._app)
async def save_artifact(**kwargs: Any) -> dict[str, Any]:
return await self._runs.add_artifact(kwargs)
async def resolve_data_source(source_id: str) -> dict[str, Any] | None:
if self._data_sources is None:
return None
return await self._data_sources.resolve_dsn(source_id)
async def resolve_credential(ref: str) -> dict[str, Any] | None:
"""HTTP credentials are data-source rows of kind ``http`` whose
secret is a JSON blob of headers to inject."""
if self._data_sources is None:
return None
resolved = await self._data_sources.resolve_dsn(ref)
if not resolved:
return None
try:
payload = json.loads(resolved.get("dsn") or "{}")
except json.JSONDecodeError:
return None
headers = payload.get("headers") if isinstance(payload, dict) else None
return {"headers": headers} if isinstance(headers, dict) else None
async def run_subworkflow(**kwargs: Any) -> dict[str, Any]:
if depth + 1 > _MAX_SUBWORKFLOW_DEPTH:
raise WorkflowError(
"WORKFLOW_LIMIT_EXCEEDED",
f"子工作流嵌套超过 {_MAX_SUBWORKFLOW_DEPTH} 层",
details={"depth": depth + 1},
)
return await self._run_child(depth=depth + 1, **kwargs)
return WorkflowRuntimeDeps(
run_agent=agent_runner.run_agent,
save_artifact=save_artifact,
resolve_data_source=resolve_data_source,
resolve_credential=resolve_credential,
run_subworkflow=run_subworkflow,
)
async def _run_child(
self,
*,
depth: int,
parent_run_id: str,
node_id: str,
owner_id: str,
workflow_id: str,
version_id: str,
inputs: dict[str, Any],
cancel: CancelToken | None = None,
) -> dict[str, Any]:
"""Run a child workflow inline: same process, its own run row and events."""
child, _ = await self._runs.create_run(
{
"workflow_id": workflow_id,
"workflow_version_id": version_id,
"owner_id": owner_id,
"input": inputs,
"status": "running",
"context": {
"depth": depth,
"parent_run_id": parent_run_id,
"parent_node_id": node_id,
},
}
)
graph = await self._load_graph(child)
if graph is None:
raise WorkflowError("WORKFLOW_VERSION_NOT_FOUND", "子工作流版本不存在", node_id=node_id)
sink = self._sink(child)
ctx = RunContext(
run_id=child["id"],
workflow_id=workflow_id,
version_id=version_id,
owner_id=owner_id,
graph=graph,
inputs=inputs,
deps=self._deps(depth=depth),
cancel=cancel or CancelToken(),
emit=sink.emit,
)
await sink.emit("run.started", data={"parentRunId": parent_run_id, "depth": depth})
engine = WorkflowEngine(self._registry, run_store=self._runs)
try:
result = await engine.run(ctx)
except WorkflowPaused as exc:
await self._runs.update_run(child["id"], status="failed")
await sink.emit(
"run.failed",
data={
"error": {
"code": "WORKFLOW_SUBWORKFLOW_FAILED",
"message": "子工作流不支持人工节点",
}
},
)
raise WorkflowError(
"WORKFLOW_SUBWORKFLOW_FAILED",
"子工作流不支持人工输入节点",
node_id=node_id,
details={"childRunId": child["id"], "pausedAt": exc.node_id},
) from exc
except WorkflowError as exc:
await self._runs.update_run(child["id"], status="failed", error_json=exc.to_body().model_dump(by_alias=True))
await sink.emit("run.failed", data={"error": exc.to_body().model_dump(by_alias=True)})
raise
await self._runs.update_run(child["id"], status="completed", output_json=result.output)
await sink.emit("run.completed", data={"output": result.output})
return {"run_id": child["id"], "status": "completed", "output": result.output}
async def _heartbeat(self, params: LeaseParams, main: asyncio.Task) -> None:
ttl = max(30, self._config.lease_ttl_seconds)
while not main.done():
await asyncio.sleep(_HEARTBEAT_INTERVAL_SECONDS)
if main.done():
return
ok = await self._runs.renew_lease(
params.run_id,
lease_owner=params.lease_owner,
lease_until=datetime.now(UTC) + timedelta(seconds=ttl),
)
if not ok:
logger.warning("workflow run %s lost its lease; aborting", params.run_id)
main.cancel()
return
async def _watch_cancel(self, params: LeaseParams, cancel: CancelToken, main: asyncio.Task) -> None:
while not main.done():
await asyncio.sleep(_CANCEL_POLL_SECONDS)
run = await self._runs.get_run(params.run_id)
if run is None:
return
if run.get("status") in ("cancel_requested", "cancelled"):
cancel.cancel()
return
__all__ = ["LeaseParams", "WorkflowRunExecutor"]
@@ -0,0 +1,59 @@
"""In-process fan-out of workflow events to connected SSE clients.
The durable log in ``workflow_run_events`` is the source of truth; this hub only
shortens latency for the tail. Slow consumers lose the *oldest* queued frame
rather than blocking the executor, and the SSE endpoint reconciles any gap from
the database using the ``seq`` cursor.
"""
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
from collections.abc import Mapping
from typing import Any
logger = logging.getLogger(__name__)
class WorkflowLiveHub:
def __init__(self, *, queue_size: int = 512) -> None:
self._subscribers: dict[str, set[asyncio.Queue[dict[str, Any]]]] = defaultdict(set)
self._queue_size = queue_size
def subscribe(self, run_id: str) -> asyncio.Queue[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=self._queue_size)
self._subscribers[run_id].add(queue)
return queue
def unsubscribe(self, run_id: str, queue: asyncio.Queue[dict[str, Any]]) -> None:
subscribers = self._subscribers.get(run_id)
if not subscribers:
return
subscribers.discard(queue)
if not subscribers:
self._subscribers.pop(run_id, None)
def subscriber_count(self, run_id: str) -> int:
return len(self._subscribers.get(run_id) or ())
async def publish(self, event: Mapping[str, Any]) -> None:
run_id = str(event.get("runId") or "")
subscribers = self._subscribers.get(run_id)
if not run_id or not subscribers:
return
payload = dict(event)
for queue in list(subscribers):
if queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty: # pragma: no cover - race with a reader
pass
try:
queue.put_nowait(payload)
except asyncio.QueueFull: # pragma: no cover - race with a reader
logger.debug("workflow live queue still full for run %s", run_id)
__all__ = ["WorkflowLiveHub"]
@@ -0,0 +1,301 @@
"""App-layer publish-time resource checks for workflow graphs.
The harness validator stays store-agnostic via its ``resource_checker`` hook;
this module is where the Gateway binds that hook to the real agent / skill /
data-source / published-version stores. It pre-fetches the catalogs the caller
is allowed to see, then exposes a **sync** checker so it can be passed straight
into ``validate_workflow_graph``.
Never imports from ``packages/harness`` internals beyond the public workflow
API, and the harness never imports this module.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from fastapi import Request
from deerflow.config.workflow_config import WorkflowConfig
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.security.sql_policy import assert_read_only, assert_tables_allowed
# Header-ish keys that must never be configured as literal values on the canvas:
# credentials belong in an http data source referenced by ``credentialRef``.
_LITERAL_SECRET_KEYS = frozenset(
{
"authorization",
"proxy-authorization",
"cookie",
"x-api-key",
"apikey",
"api_key",
"secret",
"client_secret",
"password",
"access_token",
"refresh_token",
}
)
_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
@dataclass
class ResourceCatalog:
"""Everything the checker is allowed to reference, prefetched."""
agent_ids: set[str] = field(default_factory=set)
# agent_id -> cached skill whitelist from the agent store (empty = no
# restriction recorded).
agent_skills: dict[str, set[str]] = field(default_factory=dict)
enabled_skills: set[str] = field(default_factory=set)
# data source id -> public row (kind / enabled / allowed_* / max_rows).
data_sources: dict[str, dict[str, Any]] = field(default_factory=dict)
# workflow_id -> set of published version ids.
published_subworkflows: dict[str, set[str]] = field(default_factory=dict)
def _issue(message: str, field_path: str) -> dict[str, Any]:
return {"message": message, "field": field_path}
async def build_resource_catalog(request: Request, *, user_id: str) -> ResourceCatalog:
catalog = ResourceCatalog()
agent_store = getattr(request.app.state, "agent_store", None)
if agent_store is not None:
try:
if hasattr(agent_store, "list_visible"):
rows = await agent_store.list_visible(user_id)
elif hasattr(agent_store, "list_all"):
rows = await agent_store.list_all()
else:
rows = []
catalog.agent_ids = {
str(row.get("id") or row.get("agent_id") or "") for row in rows or [] if row.get("id") or row.get("agent_id")
}
if hasattr(agent_store, "get_extras_for"):
extras = await agent_store.get_extras_for([aid for aid in catalog.agent_ids if aid])
for agent_id, extra in (extras or {}).items():
skills = (extra or {}).get("skills") or []
if skills:
catalog.agent_skills[str(agent_id)] = {str(s) for s in skills}
except Exception: # noqa: BLE001 - publish checks degrade to strict-by-empty
catalog.agent_ids = set()
try:
from deerflow.skills import get_or_new_skill_storage
catalog.enabled_skills = {
str(getattr(skill, "name"))
for skill in get_or_new_skill_storage().load_skills(enabled_only=True) or []
if getattr(skill, "name", None)
}
except Exception: # noqa: BLE001
catalog.enabled_skills = set()
ds_store = getattr(request.app.state, "workflow_data_source_store", None)
if ds_store is not None:
try:
for row in await ds_store.list_sources(owner_id=user_id, include_shared=True) or []:
catalog.data_sources[str(row.get("id"))] = row
except Exception: # noqa: BLE001
catalog.data_sources = {}
wf_store = getattr(request.app.state, "workflow_store", None)
if wf_store is not None:
try:
for row in await wf_store.list_published_summaries() or []:
versions = catalog.published_subworkflows.setdefault(str(row.get("workflow_id")), set())
versions.add(str(row.get("version_id")))
except Exception: # noqa: BLE001
catalog.published_subworkflows = {}
return catalog
def _check_agent(config: dict[str, Any], catalog: ResourceCatalog) -> dict[str, Any] | None:
# Empty agentId means the runtime's built-in default agent — a real,
# executable fallback, not missing configuration.
agent_id = str(config.get("agentId") or config.get("agent_id") or "") or "default"
if agent_id != "default" and agent_id not in catalog.agent_ids:
return _issue("未选择可访问的智能体", "config.agentId")
if not str(config.get("promptTemplate") or config.get("prompt_template") or "").strip():
return _issue("智能体节点的提示词不能为空", "config.promptTemplate")
mode = str(config.get("responseMode") or config.get("response_mode") or "text")
schema = config.get("responseSchema") or config.get("response_schema")
if mode == "json":
if not isinstance(schema, dict) or not schema:
return _issue("JSON 输出模式必须配置 responseSchema 对象", "config.responseSchema")
if schema.get("type") not in (None, "object"):
return _issue("responseSchema 目前仅支持 object 类型", "config.responseSchema")
return None
def _check_skill(config: dict[str, Any], catalog: ResourceCatalog) -> dict[str, Any] | None:
mode = str(config.get("mode") or "agent_skill")
if mode != "agent_skill":
return _issue("callable_skill 模式暂未开放执行,请改用 agent_skill", "config.mode")
agent_id = str(config.get("agentId") or config.get("agent_id") or "") or "default"
if agent_id != "default" and agent_id not in catalog.agent_ids:
return _issue("技能节点引用的智能体不可访问", "config.agentId")
skill_names = [str(s) for s in (config.get("skillNames") or config.get("skill_names") or [])]
if not skill_names:
return _issue("技能节点至少需要选择一个技能", "config.skillNames")
for name in skill_names:
if name not in catalog.enabled_skills:
return _issue(f"技能不存在或未启用:{name}", "config.skillNames")
whitelist = catalog.agent_skills.get(agent_id)
if whitelist:
missing = [name for name in skill_names if name not in whitelist]
if missing:
return _issue(f"技能不在智能体白名单内:{'、'.join(missing)}", "config.skillNames")
return None
def _check_sql(config: dict[str, Any], catalog: ResourceCatalog, cfg: WorkflowConfig) -> dict[str, Any] | None:
source_id = str(config.get("dataSourceId") or config.get("data_source_id") or "")
if not source_id:
return _issue("SQL 节点未选择数据源", "config.dataSourceId")
source = catalog.data_sources.get(source_id)
if source is None:
return _issue("数据源不存在或无权访问", "config.dataSourceId")
if not source.get("enabled", True):
return _issue("数据源已被禁用", "config.dataSourceId")
if str(source.get("kind") or "sql") != "sql":
return _issue("该资源不是 SQL 数据源", "config.dataSourceId")
statement = str(config.get("statement") or config.get("queryTemplate") or config.get("query_template") or "")
if not statement.strip():
return _issue("SQL 节点缺少查询语句", "config.statement")
try:
assert_read_only(statement)
assert_tables_allowed(statement, list(source.get("allowed_tables") or []))
except WorkflowError as exc:
return _issue(str(exc.message if hasattr(exc, "message") else exc), "config.statement")
parameters = config.get("parameters")
if parameters is not None and not isinstance(parameters, dict):
return _issue("SQL 参数必须是对象", "config.parameters")
max_rows = config.get("maxRows") or config.get("max_rows")
if max_rows is not None:
try:
rows = int(max_rows)
except (TypeError, ValueError):
return _issue("maxRows 必须是整数", "config.maxRows")
ceiling = min(int(cfg.sql.max_rows), int(source.get("max_rows") or cfg.sql.max_rows))
if rows < 1 or rows > ceiling:
return _issue(f"maxRows 超过上限 {ceiling}", "config.maxRows")
return None
def _check_http(config: dict[str, Any], catalog: ResourceCatalog) -> dict[str, Any] | None:
method = str(config.get("method") or "GET").upper()
if method not in _HTTP_METHODS:
return _issue(f"不支持的 HTTP 方法:{method}", "config.method")
headers = config.get("headers")
if headers is not None:
if not isinstance(headers, dict):
return _issue("请求头必须是字符串到字符串的映射", "config.headers")
for name in headers:
if str(name).strip().lower() in _LITERAL_SECRET_KEYS:
return _issue(f"请勿在节点上明文配置 {name},改用凭据引用", "config.headers")
url = str(config.get("url") or "").strip()
credential_ref = str(config.get("credentialRef") or config.get("credential_ref") or "").strip()
if not url and not credential_ref:
return _issue("HTTP 节点缺少 URL 或凭据引用", "config.url")
if credential_ref:
source = catalog.data_sources.get(credential_ref)
if source is None:
return _issue("引用的凭据(HTTP 数据源)不存在或无权访问", "config.credentialRef")
if not source.get("enabled", True):
return _issue("引用的凭据已被禁用", "config.credentialRef")
if str(source.get("kind") or "") != "http":
return _issue("引用的凭据不是 HTTP 类型数据源", "config.credentialRef")
allowed = {str(m).upper() for m in (source.get("allowed_methods") or [])}
if allowed and method not in allowed:
return _issue(f"方法 {method} 不在凭据资源允许的方法列表内", "config.method")
return None
def _check_code(config: dict[str, Any], cfg: WorkflowConfig) -> dict[str, Any] | None:
if not cfg.code.enabled:
return _issue("代码节点未启用(需管理员在 config.yaml 中开启 workflows.code.enabled)", "")
language = config.get("language")
if language is not None and str(language) != "python":
return _issue("代码节点目前仅支持 python", "config.language")
source = str(config.get("source") or "")
if not source.strip():
return _issue("代码节点没有源代码", "config.source")
timeout = config.get("timeoutSeconds") or config.get("timeout_seconds")
if timeout is not None:
try:
seconds = int(timeout)
except (TypeError, ValueError):
return _issue("timeoutSeconds 必须是整数", "config.timeoutSeconds")
if seconds < 1 or seconds > int(cfg.code.timeout_seconds):
return _issue(f"timeoutSeconds 超过上限 {cfg.code.timeout_seconds}", "config.timeoutSeconds")
return None
def _check_output(config: dict[str, Any]) -> dict[str, Any] | None:
mapping = config.get("mapping")
if mapping is None:
return None
if not isinstance(mapping, dict):
return _issue("输出映射必须是对象", "config.mapping")
for key, template in mapping.items():
if not isinstance(template, str) or not template.strip():
return _issue(f"输出字段 {key} 的映射模板为空", f"config.mapping.{key}")
return None
def _check_subworkflow(config: dict[str, Any], catalog: ResourceCatalog, *, current_workflow_id: str) -> dict[str, Any] | None:
workflow_id = str(config.get("workflowId") or config.get("workflow_id") or "")
if not workflow_id:
return _issue("子工作流节点缺少 workflowId", "config.workflowId")
version_id = str(config.get("versionId") or config.get("version_id") or "")
if not version_id:
return _issue("子工作流节点缺少 versionId", "config.versionId")
if workflow_id == current_workflow_id:
return _issue("子工作流不能引用自身", "config.workflowId")
versions = catalog.published_subworkflows.get(workflow_id)
if not versions:
return _issue("子工作流未发布或无权访问", "config.workflowId")
if version_id not in versions:
return _issue("子工作流版本不存在或已下线", "config.versionId")
return None
def make_resource_checker(
catalog: ResourceCatalog,
*,
config: WorkflowConfig,
current_workflow_id: str = "",
):
"""Build the sync ``resource_checker`` consumed by ``validate_workflow_graph``."""
def checker(node_type: str, node_config: dict[str, Any]):
try:
if node_type == "agent":
return _check_agent(node_config, catalog)
if node_type == "skill":
return _check_skill(node_config, catalog)
if node_type == "sql_read":
return _check_sql(node_config, catalog, config)
if node_type == "http":
return _check_http(node_config, catalog)
if node_type == "code":
return _check_code(node_config, config)
if node_type == "output":
return _check_output(node_config)
if node_type == "subworkflow":
return _check_subworkflow(node_config, catalog, current_workflow_id=current_workflow_id)
except Exception: # noqa: BLE001 - a broken check must fail closed
return _issue("资源配置校验失败,请检查节点配置", "")
return None
return checker
__all__ = ["ResourceCatalog", "build_resource_catalog", "make_resource_checker"]
@@ -0,0 +1,104 @@
"""Periodic retention sweep for workflow runs, events, and artifacts.
Consumes ``workflows.retention.event_retention_days`` /
``run_retention_days``. Events older than the event window are dropped first;
terminal runs older than the run window are deleted with their node-run and
artifact rows, and any leftover events for those run ids are cleaned up so the
event log cannot outlive the run it describes.
Artifact *files* stay on the existing sandbox/artifact lifecycle — this job
only removes the workflow catalog rows.
"""
from __future__ import annotations
import asyncio
import logging
from deerflow.config.workflow_config import WorkflowConfig
from deerflow.persistence.workflow_events.base import WorkflowEventStore
from deerflow.persistence.workflow_runs.base import WorkflowRunStore
logger = logging.getLogger(__name__)
_INTERVAL_SECONDS = 60 * 60
_MAX_BATCHES_PER_SWEEP = 8
_SQL_PURGE_BATCH = 500
class WorkflowRetentionCleaner:
def __init__(
self,
runs: WorkflowRunStore,
events: WorkflowEventStore,
*,
config: WorkflowConfig | None = None,
) -> None:
self._runs = runs
self._events = events
self._config = config or WorkflowConfig()
self._task: asyncio.Task | None = None
self._stopping = False
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="workflow-retention")
logger.info(
"workflow retention cleaner started (events=%sd runs=%sd)",
self._config.retention.event_retention_days,
self._config.retention.run_retention_days,
)
async def stop(self) -> None:
self._stopping = True
if self._task is not None:
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
self._task = None
async def sweep(self) -> dict[str, int]:
"""One pass. Safe to call from tests without starting the loop."""
event_days = max(1, self._config.retention.event_retention_days)
run_days = max(1, self._config.retention.run_retention_days)
events_deleted = await self._events.purge_before(older_than_days=event_days)
runs_deleted = 0
for _ in range(_MAX_BATCHES_PER_SWEEP):
ids = await self._runs.purge_terminal_before(older_than_days=run_days)
if not ids:
break
runs_deleted += len(ids)
events_deleted += await self._events.purge_for_runs(ids)
if len(ids) < _SQL_PURGE_BATCH:
break
if events_deleted or runs_deleted:
logger.info("workflow retention sweep: events=%s runs=%s", events_deleted, runs_deleted)
return {"events": events_deleted, "runs": runs_deleted}
async def _loop(self) -> None:
try:
await self.sweep()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - the loop must survive a bad pass
logger.exception("workflow retention sweep failed")
while not self._stopping:
try:
await asyncio.sleep(_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
if self._stopping:
return
try:
await self.sweep()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("workflow retention sweep failed")
__all__ = ["WorkflowRetentionCleaner"]
+55 -1
View File
@@ -32,7 +32,19 @@ concurrency:
heartbeat_interval_seconds: 20 heartbeat_interval_seconds: 20
fail_open: true fail_open: true
models: models:
- name: qwen3.8-flash
display_name: qwen3.8-flash
use: langchain_openai:ChatOpenAI
model: qwen3.8-flash
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_reasoning_effort: false
supports_vision: false
- name: qwen3.6 - name: qwen3.6
display_name: qwen3.6 display_name: qwen3.6
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
@@ -647,3 +659,45 @@ task_deeplink:
taskDirection: "正向引导 + 风险预警" taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判" taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。" taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
# Workflow Studio (phase 1: definitions/publish; runtime later)
workflows:
enabled: true
max_steps: 500
max_loop_iterations: 20
max_parallelism: 16
run_timeout_seconds: 3600
node_timeout_seconds: 600
max_concurrent_runs_per_user: 5
lease_ttl_seconds: 60
embed:
allowed_origins: []
ticket_ttl_seconds: 60
# http 节点出网策略:默认拒绝内网/环回/云元数据,只放行公网。
# allowed_hosts 非空时变为白名单模式(精确域名或 .suffix)。
http:
enabled: true
allowed_hosts: []
blocked_hosts: []
allow_private_networks: false
allowed_ports: [80, 443, 8080, 8443]
max_response_bytes: 2097152
max_redirects: 0
timeout_seconds: 60
# sql_read 节点:仅单条 SELECT/WITH,参数绑定,强制 LIMIT。
sql:
enabled: true
max_rows: 1000
statement_timeout_seconds: 30
max_cell_chars: 4000
# code 节点默认关闭:本地执行器是加固子进程而非容器,需管理员显式开启。
code:
enabled: false
timeout_seconds: 30
max_source_chars: 20000
max_output_chars: 100000
memory_limit_mb: 512
allow_network: false
retention:
event_retention_days: 30
run_retention_days: 90
@@ -22,6 +22,16 @@ This directory contains detailed documentation for the DeerFlow backend.
| [plan_mode_usage.md](plan_mode_usage.md) | Plan mode with TodoList | | [plan_mode_usage.md](plan_mode_usage.md) | Plan mode with TodoList |
| [AUTO_TITLE_GENERATION.md](AUTO_TITLE_GENERATION.md) | Automatic title generation | | [AUTO_TITLE_GENERATION.md](AUTO_TITLE_GENERATION.md) | Automatic title generation |
## Workflow Studio
| Document | Description |
|----------|-------------|
| [WORKFLOW_STUDIO_BACKEND_DEV_ZH.md](WORKFLOW_STUDIO_BACKEND_DEV_ZH.md) | 后端开发基线方案 + 各阶段完成状态 |
| [adr/0001-workflow-studio-runtime.md](adr/0001-workflow-studio-runtime.md) | ADR:Coze 只做画布 / ChatDev 只作参考 / 自建 DAG 调度器(LangGraph 仅作智能体内核) |
| [WORKFLOW_SCHEMA_V1_ZH.md](WORKFLOW_SCHEMA_V1_ZH.md) | 内部图 Schema v1.0 |
| [WORKFLOW_SSE_V1_ZH.md](WORKFLOW_SSE_V1_ZH.md) | SSE 事件协议 v1.0 |
| [WORKFLOW_OPENAPI_DRAFT_V1.yaml](WORKFLOW_OPENAPI_DRAFT_V1.yaml) | 接口 OpenAPI 草案 |
## Development ## Development
| Document | Description | | Document | Description |
@@ -0,0 +1,740 @@
openapi: 3.0.3
info:
title: DeerFlow Workflow Studio API (Draft v1)
version: "1.0.0-draft"
description: |
Wire models: deerflow.workflows.schemas / events / errors.
Paths marked `x-implemented: false` are still sketches; everything else is
mounted today (app/gateway/routers/workflow*.py). Where the phase-0 sketch
and the implementation disagreed, this file follows the implementation.
servers:
- url: http://localhost:8001
description: Local Gateway
tags:
- name: workflows
- name: runs
- name: resources
- name: data-sources
- name: embed
- name: coze-compat
paths:
/api/workflows:
get:
tags: [workflows]
summary: List workflow definitions
responses:
"200":
description: OK
post:
tags: [workflows]
summary: Create workflow definition
responses:
"201":
description: Created
/api/workflows/{workflow_id}:
get:
tags: [workflows]
summary: Get workflow definition
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"200":
description: OK
patch:
tags: [workflows]
summary: Update workflow metadata
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"200":
description: OK
delete:
tags: [workflows]
summary: Soft-delete / archive
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"204":
description: Archived
/api/workflows/{workflow_id}/copy:
post:
tags: [workflows]
summary: Duplicate a workflow as a new draft owned by the caller
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"200":
description: Created copy
/api/workflows/{workflow_id}/draft:
put:
tags: [workflows]
summary: Save draft (optimistic lock)
parameters:
- $ref: "#/components/parameters/WorkflowId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expectedRevision, graph]
properties:
expectedRevision:
type: integer
graph:
$ref: "#/components/schemas/WorkflowGraph"
responses:
"200":
description: Saved
"409":
description: Draft revision conflict
/api/workflows/{workflow_id}/validate:
post:
tags: [workflows]
summary: Validate draft without publishing
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"200":
description: Validation result
/api/workflows/{workflow_id}/publish:
post:
tags: [workflows]
summary: Publish immutable version
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"201":
description: Version created
/api/workflows/{workflow_id}/versions:
get:
tags: [workflows]
summary: List published versions
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"200":
description: OK
/api/workflows/{workflow_id}/versions/{version_id}:
get:
tags: [workflows]
summary: Get one published version snapshot
parameters:
- $ref: "#/components/parameters/WorkflowId"
- $ref: "#/components/parameters/VersionId"
responses:
"200":
description: OK
/api/workflows/node-types:
get:
tags: [resources]
summary: List supported node types
responses:
"200":
description: OK
/api/workflows/resources/agents:
get:
tags: [resources]
summary: Agents available to workflow nodes
responses:
"200":
description: OK
/api/workflows/resources/skills:
get:
tags: [resources]
summary: Skills available to workflow nodes
responses:
"200":
description: OK
/api/workflows/resources/data-sources:
get:
tags: [resources]
summary: Registered read-only data sources
responses:
"200":
description: OK
/api/workflows/resources/subworkflows:
get:
tags: [resources]
summary: Published workflows usable as subworkflows
responses:
"200":
description: OK
/api/workflows/data-sources:
get:
tags: [data-sources]
summary: List data sources visible to the caller (own + shared)
description: |
Never returns the DSN or credential headers — only `masked_target`.
responses:
"200":
description: OK
post:
tags: [data-sources]
summary: Register a data source (`shared: true` is admin-only)
description: |
`kind: sql` requires `dsn`; `kind: http` requires `headers`. The secret
is encrypted on write and only ever decrypted inside the run executor.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DataSourceCreate"
responses:
"200":
description: Created (masked view)
"503":
description: WORKFLOW_SECRET_KEY not configured
/api/workflows/data-sources/{id}:
get:
tags: [data-sources]
summary: Get one data source (masked view)
parameters:
- $ref: "#/components/parameters/DataSourceId"
responses:
"200":
description: OK
put:
tags: [data-sources]
summary: Update data source (owner or admin)
parameters:
- $ref: "#/components/parameters/DataSourceId"
responses:
"200":
description: OK
delete:
tags: [data-sources]
summary: Delete data source (owner or admin)
parameters:
- $ref: "#/components/parameters/DataSourceId"
responses:
"200":
description: '{ "deleted": true }'
/api/workflows/data-sources/{id}/introspect:
post:
tags: [data-sources]
summary: Introspect whitelisted schema/table/column metadata
description: Returns the configured table allowlist only. Never returns sample rows or secrets.
parameters:
- $ref: "#/components/parameters/DataSourceId"
responses:
"200":
description: '{ dataSourceId, kind, tables, schemas }'
/api/workflows/data-sources/{id}/validate-query:
post:
tags: [data-sources]
summary: Validate SQL against read-only policy (no execution)
parameters:
- $ref: "#/components/parameters/DataSourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [statement]
properties:
statement:
type: string
responses:
"200":
description: '{ ok: true, statement }'
"400":
description: WORKFLOW_SQL_POLICY_DENIED
/api/workflows/{workflow_id}/runs:
post:
tags: [runs]
summary: Start a run against a published version
parameters:
- $ref: "#/components/parameters/WorkflowId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/StartRunRequest"
responses:
"200":
description: |
Run view plus `created`; `created: false` means the idempotencyKey
matched an existing run (scoped per workflow).
"400":
description: WORKFLOW_INPUT_INVALID / WORKFLOW_VERSION_NOT_FOUND
"429":
description: WORKFLOW_LIMIT_EXCEEDED (per-user concurrent runs)
get:
tags: [runs]
summary: List runs for a workflow
parameters:
- $ref: "#/components/parameters/WorkflowId"
responses:
"200":
description: OK
/api/workflows/runs/{run_id}:
get:
tags: [runs]
summary: Get run status projection plus current node runs
description: |
Never includes `resumeToken` — that is delivered on the event stream only.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: OK
"403":
description: WORKFLOW_FORBIDDEN (not owner, not admin)
"404":
description: WORKFLOW_RUN_NOT_FOUND
/api/workflows/runs/{run_id}/events:
get:
tags: [runs]
summary: Page durable events
parameters:
- $ref: "#/components/parameters/RunId"
- name: after
in: query
description: Exclusive `seq` cursor. `after_seq` is accepted as an alias.
schema:
type: integer
default: 0
- name: after_seq
in: query
schema:
type: integer
- name: limit
in: query
schema:
type: integer
default: 200
maximum: 1000
responses:
"200":
description: '{ "events": [...], "lastSeq": 42 }'
/api/workflows/runs/{run_id}/stream:
get:
tags: [runs]
summary: SSE replay + live tail
description: |
Replays from `Last-Event-ID` (preferred) or `after` / `after_seq`, then
tails live. Closes on the first terminal event; if the run is already
terminal without one, a synthesised terminal frame is emitted so the
client never hangs.
parameters:
- $ref: "#/components/parameters/RunId"
- name: after
in: query
schema:
type: integer
default: 0
- name: after_seq
in: query
schema:
type: integer
- name: Last-Event-ID
in: header
schema:
type: string
responses:
"200":
description: text/event-stream
headers:
X-Workflow-Run-Status:
description: Run status at connection time.
schema:
type: string
content:
text/event-stream:
schema:
type: string
/api/workflows/runs/{run_id}/cancel:
post:
tags: [runs]
summary: Request cancellation (idempotent)
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: |
Run view plus `cancelled`; `false` means it was already terminal.
/api/workflows/runs/{run_id}/resume:
post:
tags: [runs]
summary: Resume after human_input
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ResumeRunRequest"
responses:
"200":
description: Requeued
"409":
description: WORKFLOW_RUN_NOT_RESUMABLE / WORKFLOW_RESUME_TOKEN_INVALID
/api/workflows/runs/{run_id}/retry:
post:
tags: [runs]
summary: Retry a failed run from the first unfinished node
description: |
Creates a new queued run with `retryOfRunId` pointing at the failed
original. Completed `workflow_node_runs` are copied so the engine
resumes from the failed node; if nothing is replayable it is a full
re-run of the same version and inputs. Only `failed` runs are retryable.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"201":
description: '{ ..., retryOfRunId, created: true }'
"409":
description: WORKFLOW_RUN_NOT_RETRYABLE
"429":
description: WORKFLOW_LIMIT_EXCEEDED
/api/workflows/runs/{run_id}/artifacts:
get:
tags: [runs]
summary: List artifacts for a run
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: OK
/api/workflows/embed/tickets:
post:
tags: [embed]
summary: Issue a short-lived iframe ticket (authenticated parent)
description: |
`origin` must exactly match `workflows.embed.allowed_origins`. The
response also carries `frameAncestors` for the host's CSP.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [workflowId, origin]
properties:
workflowId:
type: string
origin:
type: string
responses:
"200":
description: '{ ticket, expiresIn, origin, frameAncestors }'
"403":
description: WORKFLOW_EMBED_ORIGIN_DENIED / WORKFLOW_FORBIDDEN
"503":
description: No signing key configured
/api/workflows/embed/verify:
post:
tags: [embed]
summary: Verify a ticket (single-use, best effort per process)
description: |
A ticket proves "this origin may embed this workflow for this user right
now". It is NOT an API credential — embedded pages still authenticate
their API calls with the normal session.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ticket]
properties:
ticket:
type: string
origin:
type: string
responses:
"200":
description: '{ ok, workflowId, userId, origin, expiresAt }'
"401":
description: WORKFLOW_EMBED_TICKET_INVALID (expired or replayed)
"403":
description: WORKFLOW_EMBED_ORIGIN_DENIED (origin mismatch)
/api/workflow_api/canvas:
post:
tags: [coze-compat]
summary: Coze canvas load adapter
responses:
"200":
description: Coze DTO
/api/workflow_api/save:
post:
tags: [coze-compat]
summary: Coze canvas save adapter
responses:
"200":
description: Saved
/api/workflow_api/create:
post:
tags: [coze-compat]
summary: Coze create workflow adapter
responses:
"200":
description: Created
/api/workflow_api/node_template_list:
post:
tags: [coze-compat]
summary: Coze node template list adapter
responses:
"200":
description: Templates
components:
parameters:
WorkflowId:
name: workflow_id
in: path
required: true
schema:
type: string
VersionId:
name: version_id
in: path
required: true
schema:
type: string
RunId:
name: run_id
in: path
required: true
schema:
type: string
DataSourceId:
name: id
in: path
required: true
schema:
type: string
schemas:
WorkflowGraph:
type: object
description: Internal standard graph (see WORKFLOW_SCHEMA_V1_ZH.md)
required: [schemaVersion, id, nodes, edges]
properties:
schemaVersion:
type: string
enum: ["1.0"]
id:
type: string
name:
type: string
description:
type: string
inputSchema:
type: object
outputSchema:
type: object
nodes:
type: array
items:
$ref: "#/components/schemas/WorkflowNode"
edges:
type: array
items:
$ref: "#/components/schemas/WorkflowEdge"
settings:
$ref: "#/components/schemas/WorkflowGraphSettings"
WorkflowNode:
type: object
required: [id, type]
properties:
id:
type: string
type:
type: string
enum:
- start
- output
- agent
- skill
- http
- sql_read
- code
- transform
- condition
- merge
- human_input
- subworkflow
- loop
name:
type: string
config:
type: object
WorkflowEdge:
type: object
required: [id, source, target]
properties:
id:
type: string
source:
type: string
target:
type: string
sourcePort:
type: string
nullable: true
WorkflowGraphSettings:
type: object
properties:
runTimeoutSeconds:
type: integer
default: 1800
nodeTimeoutSeconds:
type: integer
default: 300
maxSteps:
type: integer
default: 200
maxLoopIterations:
type: integer
default: 5
maxParallelism:
type: integer
default: 4
NodeResult:
type: object
properties:
data:
type: object
messages:
type: array
items:
type: object
artifacts:
type: array
items:
$ref: "#/components/schemas/ArtifactRef"
metadata:
type: object
warnings:
type: array
items:
type: string
ArtifactRef:
type: object
required: [artifactId, name]
properties:
artifactId:
type: string
name:
type: string
mimeType:
type: string
path:
type: string
sizeBytes:
type: integer
nullable: true
preview:
type: string
nullable: true
StartRunRequest:
type: object
properties:
versionId:
type: string
description: Omit to use the latest published version.
inputs:
type: object
idempotencyKey:
type: string
maxLength: 128
executionMode:
type: string
enum: [normal]
default: normal
ResumeRunRequest:
type: object
required: [resumeToken]
properties:
resumeToken:
type: string
nodeId:
type: string
description: Defaults to the paused node from `pendingInput`.
action:
type: string
default: submit
values:
type: object
DataSourceCreate:
type: object
required: [name]
properties:
name:
type: string
description:
type: string
kind:
type: string
enum: [sql, http]
default: sql
dsn:
type: string
description: Write-only; required for `kind: sql`.
headers:
type: object
additionalProperties:
type: string
description: Write-only; required for `kind: http`.
maxRows:
type: integer
default: 1000
allowedTables:
type: array
items:
type: string
enabled:
type: boolean
default: true
shared:
type: boolean
default: false
description: Admin-only. Shared sources have a null owner.
WorkflowEventEnvelope:
type: object
required: [schemaVersion, runId, workflowId, versionId, seq, event]
properties:
schemaVersion:
type: string
enum: ["1.0"]
runId:
type: string
workflowId:
type: string
versionId:
type: string
seq:
type: integer
event:
type: string
nodeId:
type: string
nullable: true
nodeRunId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
type: object
WorkflowErrorBody:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: string
retryable:
type: boolean
nodeId:
type: string
nullable: true
details:
type: object
@@ -0,0 +1,93 @@
# 工作流图 Schema v1.0(冻结)
> 阶段 0 交付。实现源:`packages/harness/deerflow/workflows/schemas.py`
> 样例:`packages/harness/deerflow/workflows/samples/`
> ADR:[`adr/0001-workflow-studio-runtime.md`](adr/0001-workflow-studio-runtime.md)
## 原则
- Coze 画布 DTO **进入 Gateway 后**转换成此内部结构;运行时不依赖 Coze 私有字段。
- Wire JSON 使用 **camelCase**;Python 模型使用 snake_case(`populate_by_name=True`)。
- `schemaVersion` 固定为 `"1.0"`;破坏性变更必须升版本并双写迁移说明。
## 顶层 `WorkflowGraph`
| 字段 | 类型 | 说明 |
|------|------|------|
| `schemaVersion` | `"1.0"` | 协议版本 |
| `id` | string | 工作流逻辑 id(定义侧稳定标识) |
| `name` / `description` | string | 展示用 |
| `inputSchema` / `outputSchema` | JSON Schema object | 开始/输出校验 |
| `nodes` | `WorkflowNode[]` | 节点列表,`id` 唯一 |
| `edges` | `WorkflowEdge[]` | 边列表,`id` 唯一;`source`/`target` 必须引用已有节点 |
| `settings` | object | 见下 |
### `settings`
| 字段 | 默认 | 说明 |
|------|------|------|
| `runTimeoutSeconds` | 1800 | 整 run 超时 |
| `nodeTimeoutSeconds` | 300 | 单节点默认超时 |
| `maxSteps` | 200 | 最大节点步数 |
| `maxLoopIterations` | 5 | 全局循环上限 |
| `maxParallelism` | 4 | 同层并行度 |
发布时仍受系统级配额覆盖(阶段 6)。
## 节点类型(封闭集合)
`start` · `output` · `agent` · `skill` · `http` · `sql_read` · `code` · `transform` · `condition` · `merge` · `human_input` · `subworkflow` · `loop`
每类最小样例见 `workflows/samples/nodes/<type>.json`。
## `NodeResult`(统一节点结果)
```json
{
"data": {},
"messages": [],
"artifacts": [
{
"artifactId": "artifact_01",
"name": "专题报告.md",
"mimeType": "text/markdown",
"path": "/mnt/data/专题报告.md"
}
],
"metadata": {},
"warnings": []
}
```
大文本 / 二进制 / 超大查询结果写入 artifact,事件与 `output_json` 只传引用与有界预览。
## 运行状态
`queued` → `running` → `awaiting_input` | `completed` | `failed` | `cancel_requested` → `cancelled`
终态:`completed` / `failed` / `cancelled`。
## 安全表达式(约定,求值器阶段 2+)
允许只读引用:
- `{{ inputs.<field> }}`
- `{{ nodes.<nodeId>.data... }}`
- `{{ run.id }}`
禁止:`eval`、任意属性反射、字符串拼接构造 SQL/HTTP。条件节点运算符白名单:相等、包含、数值比较、空值、布尔组合。
## `graph_hash`
对规范化 camelCase JSON(`model_dump_json(by_alias=True, exclude_none=True)`)做 SHA-256。发布版本写入 `workflow_versions.graph_hash`;编译缓存可按 hash 做有界缓存,缓存对象不得含用户密钥。
## 样例图
| 文件 | 用途 |
|------|------|
| `samples/report_generation.json` | 专题报告端到端验收基线 |
| `samples/start_output.json` | 最小 start→output(运行骨架) |
| `samples/nodes/*.json` | 各节点最小 config |
| `samples/event_envelope_example.json` | SSE envelope 契约样例 |
加载:`from deerflow.workflows.samples import load_sample_graph`。
@@ -0,0 +1,107 @@
# 工作流 SSE / 事件协议 v1.0(冻结)
> 实现源:`packages/harness/deerflow/workflows/events.py`
> 错误体:`packages/harness/deerflow/workflows/errors.py`
> 事件清单与开发方案对齐:`WORKFLOW_STUDIO_BACKEND_DEV_ZH.md` §9
## 不变式
1. **先持久化,后发布** — 用户可见事件先写入 `workflow_run_events`,提交成功后再 fan-out。
2. **`seq` 严格单调** — 唯一键 `(run_id, seq)`。
3. **可重放** — `Last-Event-ID` 优先于 `after_seq`,否则从 `0`。
4. **增量合并** — 模型 token 建议 30–100ms 或约 1KB 合并落库。
5. **终态停连** — `run.completed` / `run.failed` / `run.cancelled` 且库已补齐后关闭 SSE。
6. **不泄密** — 无凭据、无完整认证头、无 DB 连接串、无隐藏思维链。
## 端点(阶段 2 已实现)
```http
GET /api/workflows/runs/{run_id}/stream?after=128
Accept: text/event-stream
Last-Event-ID: 128
```
实现:`app/gateway/routers/workflow_runs.py`。
- **库是排序的权威来源**:连接先按 `Last-Event-ID`(优先)或 `after` 从 `workflow_run_events` 回放,再挂 `WorkflowLiveHub` 订阅实时帧;`seq` 不大于已回放游标的实时帧被丢弃,下一轮 DB 轮询(0.8s)会带上它。断线重连因此不会看到空洞或乱序。
- **终态必闭**:读到 `run.completed` / `run.failed` / `run.cancelled` 即关闭。若运行已是终态却没有终态事件(例如崩在写事件之前),补发一条 `synthesised: true` 的终态帧再关闭,客户端不会永久挂住。
- **响应头** `X-Workflow-Run-Status` 给出连接建立瞬间的状态。
- Heartbeat:SSE comment `: heartbeat`(**不写库**),15s 静默后发送。
- 同一份事件也可用 `GET /api/workflows/runs/{run_id}/events?after=&limit=` 分页拉取(无长连接场景)。
## Envelope
```json
{
"schemaVersion": "1.0",
"runId": "run_01",
"workflowId": "wf_01",
"versionId": "wv_03",
"seq": 42,
"event": "node.output.delta",
"nodeId": "agent_writer",
"nodeRunId": "nr_09",
"timestamp": "2026-08-26T10:00:00+08:00",
"data": {}
}
```
SSE 帧:
```text
id: 42
event: node.output.delta
data: { ... envelope ... }
```
Python:`WorkflowEventEnvelope.to_sse_frame()`。
## 事件类型(封闭集合)
| 事件 | `data` 要点 |
|------|-------------|
| `run.created` | input 摘要、version |
| `run.queued` | queue 信息 |
| `run.started` | startedAt |
| `node.queued` | node 信息 |
| `node.started` | attempt、输入摘要 |
| `node.progress` | phase、message、percent |
| `node.output.delta` | channel、delta |
| `node.tool.started` | toolCallId、name、input 摘要 |
| `node.tool.finished` | toolCallId、status、output 摘要 |
| `artifact.created` | artifact 元数据 |
| `node.completed` | output 摘要、duration |
| `node.failed` | error code/message/retryable |
| `run.awaiting_input` | formSchema、actions、resumeToken |
| `run.resumed` | action |
| `run.cancel_requested` | requestedBy |
| `run.cancelled` | finishedAt |
| `run.completed` | output、artifacts |
| `run.failed` | error、failedNodeId |
## 统一错误体
```json
{
"code": "WORKFLOW_SQL_POLICY_DENIED",
"message": "SQL 节点只允许单条只读查询",
"retryable": false,
"nodeId": "sql_1",
"details": { "rule": "single_read_statement" }
}
```
完整错误码列表:`ALL_WORKFLOW_ERROR_CODES`(`deerflow.workflows.errors`)。
## `resumeToken` 只走事件流
`run.awaiting_input` 的 `data.resumeToken` 是恢复运行的唯一凭证,**只在事件流里出现**——`GET /api/workflows/runs/{run_id}` 的运行视图不包含它。恢复时 `POST /api/workflows/runs/{run_id}/resume` 携带该 token,服务端以「消费即失效」的方式原子翻转状态(同一个 token 不能恢复两次)。
## 前后端契约测试
前后端共用 fixture:
- `workflows/samples/event_envelope_example.json`
- 本文件事件名表
实现测试:`tests/test_workflow_runs.py`(回放游标、终态关闭、暂停/恢复的事件序列)。
@@ -3,7 +3,16 @@
> 文档状态:开发基线方案 > 文档状态:开发基线方案
> 适用项目:`offline-backend-20260512/backend` > 适用项目:`offline-backend-20260512/backend`
> 对接前端:Coze Studio 独立工作流页面 `frontend/apps/coze-studio/src/workflow-standalone/` > 对接前端:Coze Studio 独立工作流页面 `frontend/apps/coze-studio/src/workflow-standalone/`
> 更新时间:2026-08-26 > 更新时间:2026-08-29
> **阶段 0 状态:已完成** — 协议代码 `packages/harness/deerflow/workflows/`;文档见 `WORKFLOW_SCHEMA_V1_ZH.md` / `WORKFLOW_SSE_V1_ZH.md` / `WORKFLOW_OPENAPI_DRAFT_V1.yaml` / `adr/0001-workflow-studio-runtime.md`;契约测试 `tests/test_workflow_schemas.py`
> **阶段 1 状态:已完成** — 定义/版本持久化、草稿乐观锁、validate/publish、节点目录、Coze canvas/save/create/node_template 最小适配;路由 `/api/workflows*` + `/api/workflow_api*`;测试 `tests/test_workflow_definitions.py`(尚不执行运行)
> **阶段 2 状态:已完成** — run/node_run/event/artifact 表 + 迁移 `20260828_02`;幂等启动、租约调度、重启恢复、持久事件日志、SSE 回放/追尾。**编排内核改为自建 DAG 调度器,不再编译成 LangGraph**(理由见 `adr/0001` 阶段 2 修订)
> **阶段 3 状态:已完成** — start/output/transform/condition/merge + 安全表达式引擎;http 节点 SSRF 默认拒内网;sql_read 只读策略 + 数据源登记(密钥加密、只写不读,迁移 `20260828_03`);code 节点加固子进程执行器(**默认关闭**)
> **阶段 4 状态:已完成** — agent / skill 节点走 DeerFlow lead agent,模型增量与工具步骤映射为 `node.output.delta` / `node.tool.*`
> **阶段 5 状态:已完成** — `human_input` 暂停 + 一次性 resume token、子工作流(深度上限 3)、显式循环与 `max_loop_iterations` / `max_steps` 上限
> **阶段 6 状态:已完成** — iframe 票据(origin 白名单 + 60s 一次性 + `frameAncestors`)、按 owner/admin 的运行与数据源鉴权、每用户并发运行上限、结构化审计日志、事件/运行保留期配置与后台清理作业(`WorkflowRetentionCleaner`,每小时扫一次)、失败运行 `POST /retry`(复制已完成节点从失败点续跑,新 run 标 `retry_of_run_id`)、SSE `Last-Event-ID` 优先于 `after`。制品落盘文件仍跟随现有 sandbox/artifact 生命周期,不做工作流专属文件回收。多实例租约竞争 + SSE 重连有单机并发测试(`test_two_dispatchers_only_one_claims_a_run` / `test_sse_reconnect_storm_is_ordered_and_complete`),不是外部分布式压测。
> **审计整改(2026-08-29)状态:功能健壮性项已完成** — 按 `WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md`「0. 整改决策记录」:①人工恢复原子化(`resume_with_payload` 单条件更新 + `set_awaiting_input` 一次写入 checkpoint);②草稿保存 CAS 条件更新(409 带 `currentRevision`)+ 发布版本号计数器 `workflow_definitions.next_version_number`;③事件 seq 计数器 `workflow_runs.next_event_seq`(同事务取号,冲突自愈重试),事件落库失败**丢弃不发布**(删除负序号幽灵事件);④输入/输出完整 JSON Schema 校验(`workflows/schema_validation.py`,路由 400 + 节点错误均带字段路径);⑤测试纳入 Git(`.gitignore` 白名单)+ 新增 SQL 并发回归 `tests/test_workflow_concurrency.py`。迁移 `20260828_04_workflow_counter_columns.py` 增加两个计数器列(存量库由启动列同步自动补齐)。P0-01/02(内网可信环境,不实施)、P1-04/05(安全隔离,暂缓)见整改文档决策记录。
> 运行时测试:`tests/test_workflow_engine.py`(引擎、表达式、节点、安全策略)+ `tests/test_workflow_runs.py`(HTTP 生命周期、SSE、暂停恢复、租约回收、数据源、票据)+ `tests/test_workflow_concurrency.py`(整改并发回归:内存 + 真实 SQLite)
## 1. 目标与结论 ## 1. 目标与结论
@@ -189,6 +198,8 @@ ChatDev 适合参考的源码路径见第 15 节。
画布只保存 `data_source_id`。前端永远拿不到数据库密码,运行事件也不能回传连接串或认证头。 画布只保存 `data_source_id`。前端永远拿不到数据库密码,运行事件也不能回传连接串或认证头。
HTTP 接口资源使用同一张表:kind=http 时,base_url 和 allowed_methods 是可展示的非密钥元数据;请求头仍加密放在 credential blob,列表、详情、资源目录和运行事件均不得回显。创建时至少提供一个允许方法,地址只接受不含账户密码、查询参数或片段的 http(s) URL。迁移:20260829_01_workflow_http_source_metadata.py。
### 5.4 iframe 短时票据 ### 5.4 iframe 短时票据
如 Coze 页面与 DeerFlow 不同域,新增短时、一次性的嵌入票据: 如 Coze 页面与 DeerFlow 不同域,新增短时、一次性的嵌入票据:
@@ -581,6 +592,7 @@ DELETE /api/workflows/{workflow_id} # 软删除/归档
PUT /api/workflows/{workflow_id}/draft # draft_revision 乐观锁 PUT /api/workflows/{workflow_id}/draft # draft_revision 乐观锁
POST /api/workflows/{workflow_id}/validate POST /api/workflows/{workflow_id}/validate
POST /api/workflows/{workflow_id}/publish POST /api/workflows/{workflow_id}/publish
POST /api/workflows/{workflow_id}/copy
GET /api/workflows/{workflow_id}/versions GET /api/workflows/{workflow_id}/versions
GET /api/workflows/{workflow_id}/versions/{version_id} GET /api/workflows/{workflow_id}/versions/{version_id}
``` ```
@@ -602,18 +614,19 @@ GET /api/workflows/resources/subworkflows
### 10.3 数据源 ### 10.3 数据源
```text ```text
GET /api/workflow-data-sources GET /api/workflows/data-sources
POST /api/workflow-data-sources # 管理员或授权角色 POST /api/workflows/data-sources
GET /api/workflow-data-sources/{id} GET /api/workflows/data-sources/{id}
PATCH /api/workflow-data-sources/{id} PUT /api/workflows/data-sources/{id}
DELETE /api/workflow-data-sources/{id} DELETE /api/workflows/data-sources/{id}
POST /api/workflow-data-sources/{id}/test POST /api/workflows/data-sources/{id}/introspect
POST /api/workflow-data-sources/{id}/introspect POST /api/workflows/data-sources/{id}/validate-query
POST /api/workflow-data-sources/{id}/validate-query
``` ```
`introspect` 只返回白名单内 schema/table/column 元数据,不返回样例敏感数据。 `introspect` 只返回白名单内 schema/table/column 元数据,不返回样例敏感数据。
HTTP 接口资源的创建体使用 kind=http、baseUrl、allowedMethods 和可选 headers;headers 是只写字段,服务端加密后不会出现在响应。资源目录 GET /api/workflows/resources/data-sources 返回前端所需的 dataSourceId、baseUrl、allowedMethods、description 等精简字段。
### 10.4 运行 ### 10.4 运行
```text ```text
@@ -743,7 +756,7 @@ POST /api/workflow-embed/tickets/exchange
- SQL 只读:连接临时失败可有限重试;语法/权限错误不重试。 - SQL 只读:连接临时失败可有限重试;语法/权限错误不重试。
- HTTP:GET/HEAD 可按策略重试;POST 默认不重试,除非配置幂等键。 - HTTP:GET/HEAD 可按策略重试;POST 默认不重试,除非配置幂等键。
- 代码:资源不足或代码错误默认不重试。 - 代码:资源不足或代码错误默认不重试。
- 工作流级 retry 建议从失败节点恢复;若 checkpoint 不可靠则创建新 run 并标记 `retry_of_run_id`。 - 工作流级 retry:`POST /api/workflows/runs/{run_id}/retry` 仅接受 `failed` 运行。新 run 复制已完成节点输出并从失败节点续跑;没有任何可回放 checkpoint 时则整图重跑,并标记 `retry_of_run_id`。
### 12.3 取消 ### 12.3 取消
@@ -1083,6 +1096,8 @@ tests/test_workflow_migrations.py
- 多 Gateway 实例、租约竞争、数据库短暂失败、SSE 大量重连压测。 - 多 Gateway 实例、租约竞争、数据库短暂失败、SSE 大量重连压测。
- 事件保留、清理和 artifact 生命周期。 - 事件保留、清理和 artifact 生命周期。
阶段 6 已落地:`WorkflowRetentionCleaner` 消费 `workflows.retention.*`;`POST /api/workflows/runs/{run_id}/retry`;SSE `Last-Event-ID`;单机租约竞争与 SSE 重连风暴测试。制品文件仍走 sandbox/artifact,不另做工作流专属文件回收。
### 阶段 7:试点与扩展(持续) ### 阶段 7:试点与扩展(持续)
- 选择 1–2 个报告业务真实试点。 - 选择 1–2 个报告业务真实试点。
@@ -0,0 +1,674 @@
# Workflow Studio 后端审计整改方案
> 文档状态:已按 2026-08-29 决策部分实施(见「0. 整改决策记录」)
> 适用范围:`offline-backend-20260512/backend`
> 审核日期:2026-08-28(决策与实施:2026-08-29)
> 关联文档:[工作流编排与流式执行后端开发方案](./WORKFLOW_STUDIO_BACKEND_DEV_ZH.md)
> 适用对象:后端开发、测试、安全评审、运维发布
## 0. 整改决策记录(2026-08-29)
本轮整改以**功能健壮性**为唯一目标(用户决策:"咱们这次的整改主要是保证功能的健壮"),安全隔离类项目暂缓。逐项状态:
| 项目 | 状态 | 说明 |
|---|---|---|
| P1-01 人工介入恢复原子性 | ✅ 已实施 | `resume_with_payload` 单条件更新,并发恢复恰一个成功 |
| P1-02 草稿乐观锁 + 发布版本号 | ✅ 已实施 | 草稿 CAS 条件更新;`next_version_number` 计数器分配版本号 |
| P1-03 事件序号 + SSE 一致性 | ✅ 已实施 | `next_event_seq` 计数器;落库失败**丢弃不发布**(无幽灵事件) |
| P1-06 测试纳入版本控制 | ✅ 已实施 | `.gitignore` 白名单 + 新增 `tests/test_workflow_concurrency.py`(SQL 并发) |
| P2 §11.1 完整 JSON Schema 校验 | ✅ 已实施 | `schema_validation.py`(Draft202012,中文字段路径错误) |
| P0-01 数据源越权 / HTTP 凭证外泄 | ❌ 不实施 | **内网可信环境**,按 ID 共享数据源、HTTP 节点访问任意内网地址是既有使用方式,加 owner 校验 / host 白名单会直接破坏现网用法,风险由部署方接受 |
| P0-02 子工作流跨用户执行 | ❌ 不实施 | 同上:内网用户间按 ID 引用子工作流是预期用法 |
| P1-04 智能体/技能最小权限 | ⏸ 暂缓 | 安全隔离类,本轮不做(用户决策:"安全性的问题咱们暂时不用做") |
| P1-05 代码节点沙箱化 | ⏸ 暂缓 | 同上;`config.yaml` 中 `workflows.code.enabled` 保持默认 `false`,代码节点不开放 |
| P2 §11.2 iframe Redis nonce | ⏸ 暂缓 | 单实例部署,进程内 replay cache + 60s TTL 足够;多实例化时再引入 Redis |
已实施项的回归测试集中在 `tests/test_workflow_concurrency.py`(内存 + 真实 SQLite 双轨),外加 `tests/test_workflow_runs.py` 的路由级 schema 校验回归;全部 88 个 workflow 测试通过。
## 1. 结论与整改目标
当前 Workflow Studio 的定义、发布、运行、SSE、SQL/HTTP、智能体、技能、人工介入等功能骨架已经具备,且基础测试可运行。但在共享资源授权、凭证使用边界、并发一致性、事件可靠性和代码执行隔离方面存在上线阻断问题。
本次整改的目标不是继续扩展节点功能,而是让现有节点满足以下底线:
1. 一个用户不能通过资源 ID、流程 JSON 或子工作流引用使用别人的私有资源。
2. HTTP 凭证只能发送至管理员明确批准的目标域名,不能被流程编辑者导出到任意 URL。
3. 人工介入恢复、草稿保存、发布版本和事件序号在多请求、多实例条件下保持一致。
4. SSE 中显示的业务事件必须能够从数据库重放;不能出现只在内存短暂存在的“幽灵事件”。
5. 智能体和技能节点只能使用流程配置所授予的能力。
6. 代码节点只有在隔离沙箱中才能开启,不能在 Gateway 宿主机执行用户代码。
在 P0 项全部完成前,生产环境不得向普通用户开放:共享 SQL 数据源、共享 HTTP 凭证、子工作流跨用户引用和工作流编辑/执行权限。
## 2. 总体整改策略
### 2.1 建立运行时资源授权边界
当前 API 层可以识别当前用户,但执行器通常只持有 `source_id`、`workflow_id` 或 `agent_id`。整改后必须将“谁在执行”作为运行时一等参数,从创建运行记录开始贯穿到每一个节点。
新增领域对象,建议放在 `packages/harness/deerflow/workflows/`:
```python
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class WorkflowExecutionPrincipal:
"""工作流运行时的可信执行身份,由 Gateway 创建,不接受前端传入。"""
user_id: str
is_admin: bool = False
tenant_id: str | None = None
```
运行创建时将发起人固化在 `workflow_runs.owner_id`,恢复、重试和子工作流也沿用原始运行的 `owner_id`,不得使用前端请求中的任意 `user_id`。
```python
principal = WorkflowExecutionPrincipal(
user_id=run.owner_id,
is_admin=await permission_service.is_admin(run.owner_id),
tenant_id=run.tenant_id,
)
```
所有需要使用资源的运行时入口必须接收 `principal`:
```python
data_source_store.resolve_for_execution(source_id, principal)
workflow_store.get_published_for_execution(workflow_id, version_id, principal)
agent_store.get_visible(agent_id, principal.user_id)
skill_registry.resolve_allowed(skill_id, principal, agent)
```
资源不存在、无权限、已禁用三种情况对普通用户应统一返回 `RESOURCE_NOT_AVAILABLE`,避免通过 UUID 枚举资源。
### 2.2 采用“发布校验 + 运行时校验”的双层防线
发布时校验是为了尽早提示编辑者;运行时校验是为了防止发布后授权被收回、资源被禁用、恶意请求绕过 UI 或历史版本继续引用资源。
| 阶段 | 应做的事 |
|---|---|
| 保存草稿 | 校验节点结构、字段格式,不因暂时无权限而破坏用户草稿 |
| 发布版本 | 校验每个外部资源对发布者是否可用,并将资源依赖写入版本元数据 |
| 创建运行 | 再次校验工作流版本和根工作流权限 |
| 节点执行 | 每次解析数据源、智能体、技能、子工作流时按 `principal` 校验 |
| 重试/恢复 | 使用原 run 的身份和已发布版本,不重新信任浏览器传参 |
## 3. P0-01:数据源越权与 HTTP 凭证外泄
> **决策(2026-08-29):不实施。** 本部署运行于内网可信环境,用户之间按数据源 ID 共享、HTTP 节点访问任意内网 URL 是既有且必需的使用方式;owner 校验、baseUrl+path 拆分、host 白名单会使现网流程全部失效。风险由部署方明确接受("这个改了我们就没法用了")。
### 3.1 问题说明
当前运行时在 `app/gateway/workflow_executor.py` 中按数据源 ID 直接解析 DSN/凭证;持久化层的 `resolve_dsn` 也没有传入 owner 或授权上下文。HTTP 节点可由流程 JSON 提供任意 URL,再把已解析的认证头附加到该 URL。
因此存在两类风险:
1. 知道或获得他人私有数据源 ID 的用户,可能在自己的流程中引用该数据源。
2. 可使用共享 HTTP 凭证的用户,可能把请求 URL 写为攻击者控制的公网域名,从而收到服务端附带的 API Key、Bearer Token 或 Basic Auth。
### 3.2 数据模型调整
在 `workflow_data_sources` 中增加显式共享与用途约束。字段命名可按现有 ORM 风格调整:
```sql
ALTER TABLE workflow_data_sources
ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'private',
ADD COLUMN allowed_user_ids JSON NULL,
ADD COLUMN allowed_tenant_ids JSON NULL,
ADD COLUMN allowed_hosts JSON NULL,
ADD COLUMN allowed_methods JSON NULL;
```
字段语义:
| 字段 | 含义 |
|---|---|
| `visibility` | `private`、`shared`、`system` |
| `allowed_user_ids` | 共享给指定用户;第一期可先支持此项 |
| `allowed_tenant_ids` | 多租户部署时按租户共享;单租户可暂不启用 |
| `allowed_hosts` | HTTP 数据源允许请求的 hostname 白名单,必须填写 |
| `allowed_methods` | HTTP 数据源可执行的方法白名单,例如 `GET`、`POST` |
SQL 数据源的数据库账号仍必须由 DBA 创建为只读账号;应用层 SQL 解析和只读 SQL policy 是第二道防线,不能替代数据库权限。
### 3.3 Store 接口调整
将现有无身份的解析接口废弃:
```python
# 禁止继续在节点执行路径使用
resolve_dsn(source_id: str)
```
替换为:
```python
async def resolve_for_execution(
self,
source_id: str,
principal: WorkflowExecutionPrincipal,
expected_kind: Literal["sql", "http"],
) -> WorkflowDataSource:
...
```
实现规则:
1. 资源必须存在且 `enabled=True`。
2. `kind` 必须与节点类型匹配。
3. 允许 owner、被显式共享的用户、同租户授权用户或管理员访问。
4. 禁止仅凭“已发布工作流”绕过资源权限;每一次运行均需检查。
5. 无权限统一抛 `ResourceNotAvailableError`,日志中可记录真实原因和 source ID。
示例伪代码:
```python
def can_execute(source: WorkflowDataSource, principal: WorkflowExecutionPrincipal) -> bool:
if principal.is_admin:
return True
if source.owner_id == principal.user_id:
return True
if source.visibility == "shared" and principal.user_id in source.allowed_user_ids:
return True
if source.visibility == "system" and principal.tenant_id in source.allowed_tenant_ids:
return True
return False
```
需要同步修改:
- `packages/harness/deerflow/persistence/workflow_data_sources/store.py`
- `packages/harness/deerflow/persistence/workflow_data_sources/sql.py`
- 内存 store 实现与测试 fake
- `app/gateway/workflow_executor.py`
- 使用数据源的 SQL / HTTP 节点执行器
### 3.4 HTTP 节点配置改造
不要继续允许流程节点同时填写任意完整 URL 与 `credentialRef`。推荐将 HTTP 数据源定义为受控目标,节点只填写相对路径:
```json
{
"type": "http",
"dataSourceId": "crm-production-api",
"method": "GET",
"path": "/v1/customers",
"query": {
"page": "{{ inputs.page }}"
}
}
```
数据源的机密配置中保存:
```json
{
"baseUrl": "https://api.company.com",
"allowedHosts": ["api.company.com"],
"allowedMethods": ["GET", "POST"],
"auth": {
"type": "bearer",
"secret": "加密存储"
}
}
```
执行顺序必须固定:
1. 读取经授权的数据源。
2. 变量渲染 `path`、query、body。
3. 由 `baseUrl + path` 构造最终 URL,拒绝绝对 URL 和 `//host/path` 形式。
4. 校验最终 host、端口、DNS 解析结果、方法、请求体上限。
5. 最后才注入认证头。
6. 每次重定向重新执行 host/SSRF 校验。
7. 一旦发生跨 origin 重定向,移除 `Authorization`、Cookie、`X-API-Key` 等敏感头;建议默认拒绝跨 origin 重定向。
若业务确实需要任意 URL 调用,应提供另一种**无凭证 HTTP 节点**,并且其网络范围仍受全局 SSRF policy 约束。
### 3.5 验收测试
- 用户 A 创建私有 SQL 数据源;用户 B 使用其 ID 运行,返回 `RESOURCE_NOT_AVAILABLE`。
- 用户 A 发布引用私有数据源的流程后,用户 B 即使复制 JSON 也不能运行。
- 共享 HTTP 数据源只能请求 `allowed_hosts` 中的地址。
- URL 模板渲染为不在白名单中的主机时被拒绝,且认证头没有发送。
- 302/307 跨域跳转时凭证不会跟随;推荐断言请求整体被拒绝。
- SQL 节点使用只读账号执行 `INSERT`、`CALL`、多语句均被数据库和 SQL policy 双重拒绝。
## 4. P0-02:子工作流跨用户执行
> **决策(2026-08-29):不实施。** 内网可信环境下按 workflow/version ID 跨用户引用子工作流是预期用法,运行时沿用原 run 的 `owner_id` 已满足功能正确性;完整 ACL 见 P0-01 同批决策,一并接受风险。
### 4.1 工作流 ACL
在 workflow 主表增加与数据源一致的共享模型:
```sql
ALTER TABLE workflows
ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'private',
ADD COLUMN allowed_user_ids JSON NULL,
ADD COLUMN allowed_tenant_ids JSON NULL;
```
建议第一期仅实现:`private`、`shared`、`system`。不要把“已发布”当作“所有用户可执行”;发布只代表版本不可变并可被授权对象使用。
### 4.2 接口改动
以下接口均应由 `principal` 过滤:
| 接口能力 | 整改要求 |
|---|---|
| 已发布工作流目录 | 仅返回当前用户可执行的工作流 |
| 获取版本详情 | owner / 被分享对象 / 管理员才可读 |
| 创建根运行 | 检查 workflow 与 version 的 ACL |
| 子工作流节点 | 按父 run 的 principal 再次检查子流程 ACL |
| 复制流程 | 允许复制公开定义不等于允许继承数据源和凭证引用 |
子工作流执行应改为:
```python
child_version = await workflow_store.get_published_for_execution(
workflow_id=node.workflow_id,
version_id=node.version_id,
principal=context.principal,
)
if child_version is None:
raise ResourceNotAvailableError(node.workflow_id)
```
并在 child run 写入:
```python
parent_run_id=parent_run.id
owner_id=parent_run.owner_id
tenant_id=parent_run.tenant_id
```
### 4.3 验收测试
- 用户 B 的资源目录不包含用户 A 的私有已发布流程。
- 用户 B 手工构造 A 的 workflow/version ID 创建运行,被拒绝。
- 用户 B 的父流程引用 A 的子流程,被拒绝。
- A 共享子流程给 B 后,B 可运行;收回授权后,历史草稿仍可保存但新运行失败。
## 5. P1-01:人工介入恢复的原子性
> **状态(2026-08-29):已实施。** Store 新增 `resume_with_payload(run_id, resume_token, payload, *, context=None)`(SQL/memory 双实现),暂停侧 `set_awaiting_input` 扩展 `context` 参数一次性写入 checkpoint。路由 `resume_run` 与执行器 `_pause` 均已切换到单条条件更新;`consume_resume_token` 仅为兼容保留。回归测试 `tests/test_workflow_concurrency.py`(内存 + SQLite 并发恢复、错 token、单 winner)。
### 5.1 问题与目标
当前恢复操作先写 `resume_payload`,再消费 token。两个并发请求可能先后覆盖 payload,最终执行的数据与收到成功响应的请求不一致。
目标是把“校验 token、写入输入、清除 token、变更状态”为单条条件更新。
### 5.2 Store 方法
新增方法,替代 `update_run(...) + consume_resume_token(...)` 组合调用:
```python
async def resume_with_payload(
self,
run_id: str,
resume_token: str,
payload: dict[str, Any],
) -> WorkflowRun | None:
"""成功时返回已恢复的 run,token 无效或已消费时返回 None。"""
```
SQL 示例,JSON 写法按当前数据库方言封装:
```sql
UPDATE workflow_runs
SET
context_json = :new_context_json,
resume_token = NULL,
pending_human_node_id = NULL,
status = 'queued',
updated_at = NOW()
WHERE id = :run_id
AND status = 'awaiting_input'
AND resume_token = :resume_token;
```
应用层规则:
1. `rowcount != 1` 时返回 409,且不得投递调度器。
2. `rowcount == 1` 后再投递调度器。
3. 投递失败时保留 `queued` 状态,由扫描器补投;不要把 token 恢复。
4. 对人工输入 payload 先完成 schema 校验、大小限制和脱敏审计,再进入事务。
暂停节点也应使用单次 store 调用,同时写入:状态、token、等待节点 ID 和上下文,避免执行器后续的独立 `update_run` 覆盖已恢复的数据。
### 5.3 验收测试
- 两个协程并发用同一 token 提交不同 payload,只有一个 200,另一个 409。
- run context 中只存在 200 对应 payload。
- worker 最终读取的 payload 与成功响应一致。
- token 被消费后,多次重试均为 409。
## 6. P1-02:草稿与发布版本并发控制
> **状态(2026-08-29):已实施。** 草稿保存改为 `UPDATE ... WHERE id AND draft_revision = :expected`,rowcount=0 时回读当前 revision 抛 `WorkflowDraftConflictError`(409 带 `currentRevision`)。发布版本号改由 `workflow_definitions.next_version_number` 计数器 CAS 分配(`max(counter, MAX(version)+1)` 兼容存量行),唯一约束兜底、冲突重试而非 500。计数器列由迁移 `20260828_04_workflow_counter_columns.py` 增加(server_default,存量库由启动列同步自动补列)。回归测试:并发草稿双写单 winner、5 并发发布得相邻版本号、存量版本行计数器自愈。
### 6.1 草稿乐观锁
禁止“先读取 revision,再修改 ORM 对象,再 commit”的方式。改为带 revision 的条件更新:
```sql
UPDATE workflows
SET graph_json = :graph_json,
draft_revision = draft_revision + 1,
updated_at = NOW()
WHERE id = :workflow_id
AND draft_revision = :expected_revision;
```
若受影响行数为零,则读取当前 revision,并返回:
```json
{
"code": "WORKFLOW_DRAFT_CONFLICT",
"currentRevision": 18
}
```
前端收到 409 后应提示“流程已被其他编辑者保存”,支持刷新、比较或另存副本;不能静默覆盖。
### 6.2 发布版本号
禁止 `MAX(version_number) + 1`。建议在 workflow 行维护 `next_version_number`,在一个事务内锁定并递增:
```sql
SELECT next_version_number FROM workflows WHERE id = :id FOR UPDATE;
INSERT INTO workflow_versions (..., version_number) VALUES (..., :next_number);
UPDATE workflows
SET next_version_number = next_version_number + 1
WHERE id = :id;
```
若现有数据库不方便 `FOR UPDATE`,可采用具备唯一约束的 sequence/counter 表,捕获唯一冲突后重试;不要将冲突直接抛为 500。
### 6.3 验收测试
- 两个独立 SQLAlchemy session 同时保存同一 revision,严格只有一个成功。
- 两个发布请求同时执行,生成相邻但不同的版本号。
- 冲突被转换为稳定的 409 业务错误,而非数据库异常。
## 7. P1-03:事件持久化、序号和 SSE 一致性
> **状态(2026-08-29):已实施(采用 7.2 计数器方案 + 7.3 的"丢弃不发布"策略)。** `workflow_runs.next_event_seq` 计数器在同一事务内 `UPDATE ... SET next_event_seq = next_event_seq + 1` 后读自增并插入事件,行写锁串行化并发追加(seq 严格递增、唯一、无空洞);唯一约束冲突时 `_resync_counter` 以独立事务回写 `MAX(seq)` 自愈存量行后重试。落库失败的事件**直接丢弃、绝不向 live hub 发布**(原负序号幽灵事件实现已删除),信封带 `eventPersistenceDegraded` 标记、`sink.degraded_count` 计数并记结构化告警日志;SSE 端点对无终态事件的终态 run 合成关闭帧。回归测试:10 并发追加 seq 连续唯一、存量事件计数器自愈、落库失败零发布。
### 7.1 改造原则
业务事件必须遵循:**获得持久序号 → 持久化成功 → 实时发布**。不能生成负序号临时事件,也不能让前端看到无法通过重连补回的事件。
### 7.2 序号分配
不要使用 `MAX(seq) + 1`。在 `workflow_runs` 增加计数器:
```sql
ALTER TABLE workflow_runs
ADD COLUMN next_event_seq BIGINT NOT NULL DEFAULT 1;
```
追加事件时在同一个数据库事务中:
1. 对 run 原子递增 `next_event_seq` 并得到当前 seq。
2. 以该 seq 插入 `workflow_events`。
3. 提交事务。
4. 仅提交成功后调用 `live_hub.publish(event)`。
不同数据库可选择 `UPDATE ... RETURNING` 或者用 run 行锁实现,但不可回退为 `MAX(seq)` 查询。
### 7.3 持久化失败处理
事件库短暂失败时:
1. 在有限次数内重试;
2. 仍失败则将运行标记为 `retryable_failure` 或 `paused_system`;
3. 记录包含 `run_id`、node ID、异常分类的结构化日志和告警;
4. SSE 至少发送一个已成功持久化的终态或系统状态事件;
5. 用户重连后只能从数据库事件日志恢复,不依赖当前进程内存。
不要使用“事件落库失败后仍向 live hub 发负数 seq”的实现,因为按 cursor 追尾的 SSE 客户端通常会忽略它。
### 7.4 验收测试
- 并行节点大量写事件,序号严格递增且无重复。
- 模拟一次插入事件失败,前端不会收到不可回放事件。
- 客户端从 `Last-Event-ID=N` 重连,得到所有 `seq>N` 的事件且顺序一致。
- 多实例下任一实例订阅 SSE,均可通过数据库补放完整事件。
## 8. P1-04:智能体和技能最小权限
> **决策(2026-08-29):暂缓。** 属安全隔离类项目,本轮整改只做功能性优化(用户决策:"P1-04/P1-05 偏安全隔离不用做,安全性的问题咱们暂时不用做,咱们只改功能性的,优化的")。后续若对外开放多租户再评估。
### 8.1 智能体可见性
`agent` 节点收到 `agent_id` 后,必须在 `WorkflowAgentRunner` 内部再次调用可见性查询:
```python
agent = await agent_store.get_visible(
agent_id=node.agent_id,
user_id=context.principal.user_id,
)
if agent is None or not agent.enabled:
raise ResourceNotAvailableError(node.agent_id)
```
不能仅依赖资源目录接口已过滤,也不能仅依赖发布阶段校验。
### 8.2 技能白名单
现有将 `skill_names` 传给 Lead Agent 的方式必须核实是否被真正消费。建议新增语义明确的运行时参数:
```python
configurable["workflow_allowed_skill_ids"] = selected_skill_ids
```
并在 Lead Agent 加载技能、MCP 工具和工具调用路由之前强制过滤:
```python
requested = set(configurable.get("workflow_allowed_skill_ids", []))
available = [skill for skill in all_enabled_skills if skill.id in requested]
```
还应校验:
1. 选中的 skill 对当前用户可见。
2. skill 是否允许在 workflow 场景执行。
3. skill 的网络、数据库、文件权限是否与工作流 principal 相兼容。
4. 系统级高风险 skill 是否只能由管理员在数据源/策略中启用。
### 8.3 验收测试
- 用户手工填写不可见 agent ID,运行被拒绝。
- 未选中的技能不出现在模型可调用工具列表中。
- 提示词诱导模型调用未选技能,调用被拒绝且记录审计事件。
- 选中的技能正常可执行,节点事件中仅暴露必要的脱敏参数。
## 9. P1-05:代码节点沙箱化
> **决策(2026-08-29):暂缓,保持现状即安全。** 本轮不迁移到容器沙箱;`config.yaml` 中 `workflows.code.enabled` 保持默认 `false`(代码节点全局关闭,`tests/test_workflow_engine.py::test_code_node_disabled_by_default` 已锁定该默认值),如需启用由运维在受限宿主上自行开启。现有 `code_runner.py` 子进程加固(净化环境、临时目录、rlimit、断网、超时杀进程组)在可信内网下作为过渡方案。
### 9.1 上线策略
在 DeerFlow 沙箱实现并通过安全验证前,保持:
```yaml
workflows:
code:
enabled: false
```
不得以本地 `exec`、`subprocess` 或 monkey-patch `socket` 作为生产隔离方案。用户代码可借助原生扩展、子进程、文件系统或环境变量绕过此类限制。
### 9.2 目标接口
代码节点改为只调用现有 DeerFlow sandbox 抽象,而不自行启动宿主解释器:
```python
result = await sandbox.run(
code=rendered_code,
language="python",
timeout_seconds=30,
memory_limit_mb=256,
cpu_limit=1,
max_processes=8,
network_enabled=False,
writable_paths=["/mnt/user-data/workflows/{run_id}"],
)
```
沙箱最低要求:
- 独立容器或受限命名空间,不能读取 Gateway 项目、环境变量与宿主文件。
- 默认无网络;若未来允许网络,也需复用 HTTP 节点的数据源、SSRF 和凭证策略。
- CPU、内存、执行时长、磁盘、进程数和文件数限制。
- stdout/stderr 使用流式读取并在读取中截断,例如各 1MB;禁止 `communicate()` 后再截断。
- 制品只能输出到工作流专属目录,并通过既有 artifact 机制登记。
- 记录代码 hash、镜像版本、资源消耗、退出码,日志不得泄露源代码中的密钥。
### 9.3 验收测试
- 代码读取 `/etc`、项目目录、环境变量失败。
- 导入 socket、创建子进程、连接外网失败。
- 死循环超时结束,运行正确失败或可重试。
- 大量 stdout/stderr 不造成 Gateway 内存膨胀。
- 制品只能写入限定目录。
## 10. P1-06:测试纳入版本控制与 CI
> **状态(2026-08-29):已实施。** 采用方案 1(白名单):仓库根 `.gitignore` 对 `tests/test_workflow_{schemas,definitions,engine,runs}.py` 逐个 `!` 白名单,并新增 `tests/test_workflow_concurrency.py`(本轮整改的内存 + 真实 SQLite 并发回归,含 P1-01/02/03 与 schema 校验)。CI 需在该清单基础上加 `tests/test_workflow_concurrency.py`。
当前工作流测试文件位于后端 `tests/`,但仓库根目录的 `.gitignore` 规则会忽略这一目录内容。必须在合并前处理,否则本地通过的测试不会进入提交和 CI。
推荐方案:
1. 调整 `.gitignore`,仅忽略真正的生成物,不要忽略后端测试源码;或
2. 若忽略规则短期不能改,使用 `git add -f` 纳入现有测试文件,但仍应补充注释说明原因;
3. CI 显式执行:
```bash
cd offline-backend-20260512/backend
PYTHONPATH=. uv run pytest \
tests/test_workflow_schemas.py \
tests/test_workflow_definitions.py \
tests/test_workflow_engine.py \
tests/test_workflow_runs.py -v
```
4. 新增 SQL 集成测试 job,至少覆盖真实事务并发;不能只依赖 memory store。
## 11. P2:契约校验与 iframe 票据
> **状态(2026-08-29):§11.1 已实施;§11.2 暂缓**(单实例部署,进程内 replay cache + 60s TTL 足够,多实例化时再引入 Redis)。
### 11.1 完整 JSON Schema 校验
> **实施说明(2026-08-29)**:新增纯函数模块 `packages/harness/deerflow/workflows/schema_validation.py` 的 `schema_issues(value, schema)`(Draft202012,中文短消息、字段路径、去重、上限 20 条、schema 本身非法时返回友好错误而非 500)。接入点:路由 `start_run`(启动前 400 `WORKFLOW_INPUT_INVALID` + `details.errors`)、引擎 Start/Output 节点(`WORKFLOW_INPUT_INVALID` / `WORKFLOW_OUTPUT_SCHEMA_MISMATCH`)。空 schema 不强制(未声明即不校验)。回归测试见 `tests/test_workflow_concurrency.py` 与 `tests/test_workflow_runs.py::test_typed_input_schema_violations_return_structured_400`。
输入节点和输出节点不应只校验 `required` 字段。应使用完整 JSON Schema validator,至少支持:`type`、`enum`、`items`、`properties`、`required`、`additionalProperties`、嵌套对象和数组。
```python
from jsonschema import Draft202012Validator
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(value), key=lambda item: list(item.path))
if errors:
raise WorkflowSchemaValidationError(
errors=[{"path": list(err.path), "message": err.message} for err in errors]
)
```
错误响应需要包含节点 ID、字段路径和可展示的消息;不应把内部 Python trace 返回给前端。
### 11.2 iframe 一次性票据
“一次性”语义不能使用单进程内存字典。若部署多实例,应使用 Redis 或数据库的原子写入:
```text
SET workflow_embed_nonce:{jti} consumed NX EX 60
```
只有返回成功的实例可以继续验证。服务端还必须读取真实请求 `Origin`,并要求以下三者一致:
1. ticket 签发时绑定的 origin;
2. 浏览器请求头中的 `Origin`;
3. 后台 `workflows.embed.allowed_origins` 白名单。
不能只比较前端 body/query 参数中的 origin。
## 12. 建议实施顺序与分批发布
> 2026-08-29 实况:第二批全部落地;第四批仅 §11.1 落地(§11.2 暂缓);第一批(P0-01/02)经部署方决策不实施;第三批(P1-04/05)暂缓。
### 第一批:安全阻断项
范围:资源 principal、数据源授权、HTTP host 绑定、子工作流 ACL。
发布策略:先迁移数据库字段,默认所有历史资源为 `private`;只允许管理员手工将必要资源改为 shared。此批完成并通过越权测试后,才允许开放 SQL/HTTP/子工作流节点。
### 第二批:一致性与可靠性
范围:人工恢复原子操作、草稿 CAS、发布版本锁、事件序号与持久化失败策略。
发布策略:需要停写窗口或兼容性迁移时,先双写/双读一版;对运行中的旧 run 保持旧 schema 可读,新增 run 使用新序号和新恢复逻辑。
### 第三批:能力隔离
范围:agent 可见性、skill 白名单、代码节点沙箱。
发布策略:代码节点继续全局关闭;先灰度给管理员和测试租户。技能节点在完成真实白名单前,不得宣称“只调用所选技能”。
### 第四批:契约与嵌入完善
范围:JSON Schema 完整校验、iframe Redis nonce、接口错误码统一、运维监控。
## 13. 发布准入清单
> 2026-08-29 更新:本清单按原始审计口径保留。结合「0. 整改决策记录」,P0 两项经部署方决策**不实施**(内网可信环境,风险已接受),与 P0 相关的准入项在内网部署中视为豁免;✅ 为本轮已达成项。
以下条件全部满足才可开放给普通用户:
- [ ] 所有资源解析接口都需要 `WorkflowExecutionPrincipal`。(❌ 不实施——内网可信环境,见 §3/§4 决策)
- [ ] 私有数据源、私有工作流、私有智能体均有跨用户拒绝测试。(❌ 不实施——同上)
- [ ] HTTP 凭证绑定 host/method,跨域重定向不携带敏感认证头。(❌ 不实施——内网需访问任意地址,见 §3 决策)
- [ ] SQL 数据源使用数据库只读账号,且连接/查询审计可追踪到 run ID。(部分:SQL 只读校验已有;账号隔离不在本轮范围)
- [ ] 子工作流按运行身份校验 ACL。(❌ 不实施——见 §4 决策)
- [x] 人工输入恢复是单事务条件更新,并发测试通过。(✅ `test_workflow_concurrency.py`)
- [x] 草稿保存和发布版本具备数据库级并发保护。(✅ 同上)
- [x] 事件序号单调、唯一、可重放;没有负序号临时事件。(✅ 同上)
- [ ] agent / skill 白名单在实际工具加载链路生效。(⏸ 暂缓——P1-04)
- [x] 代码节点运行在受限 sandbox;否则保持关闭。(✅ 保持关闭:`workflows.code.enabled: false` 为默认且被测试锁定)
- [x] 所有 workflow 测试文件已纳入 Git 与 CI。(✅ `.gitignore` 白名单 + 新增并发回归)
- [x] 安全测试、SQL 并发集成测试、SSE 重连测试全部通过。(✅ 88 个 workflow 测试全绿;安全类测试随 P0/P1-04 决策豁免)
## 14. 相关代码入口
| 主题 | 当前主要代码入口 |
|---|---|
| 路由与运行 API | `app/gateway/routers/workflow_runs.py` |
| 工作流执行与依赖装配 | `app/gateway/workflow_executor.py` |
| 智能体/技能运行 | `app/gateway/workflow_agent_runner.py` |
| 数据源 API | `app/gateway/routers/workflow_data_sources.py` |
| 工作流资源目录 | `app/gateway/routers/workflow_resources.py` |
| iframe ticket | `app/gateway/routers/workflow_embed.py` |
| 数据源持久化 | `packages/harness/deerflow/persistence/workflow_data_sources/` |
| 工作流/版本持久化 | `packages/harness/deerflow/persistence/workflows/` |
| 运行与租约持久化 | `packages/harness/deerflow/persistence/workflow_runs/` |
| 事件持久化 | `packages/harness/deerflow/persistence/workflow_events/` |
| SQL/HTTP 节点 | `packages/harness/deerflow/workflows/nodes/integrations.py` |
| 事件 sink | `packages/harness/deerflow/workflows/runtime/sink.py` |
| 代码节点运行器 | `packages/harness/deerflow/workflows/runtime/code_runner.py` |
| 工作流安全策略 | `packages/harness/deerflow/workflows/security/` |
| 数据库迁移 | `packages/harness/deerflow/persistence/migrations/` |
| 现有工作流测试 | `tests/test_workflow_*.py` |
## 15. 非目标与注意事项
- 本整改不改变 Coze Studio 画布的基础交互,也不要求先改前端;前端仅需后续适配资源权限失败、409 冲突和结构化 schema 错误。
- 不要把资源密钥、数据库 DSN、人工输入 token 或工具原始参数写入前端 SSE 事件。
- 不要依赖“前端资源选择器只显示可见资源”作为授权机制;浏览器请求和画布 JSON 均视为不可信输入。
- 新字段上线后,历史资源默认按私有处理,避免一次迁移导致所有历史凭证意外共享。
- 每项整改完成后都要更新 `WORKFLOW_STUDIO_BACKEND_DEV_ZH.md` 中对应的“已完成”状态,避免文档声明与实际安全边界不一致。
@@ -0,0 +1,486 @@
# Workflow Studio:Coze 独立前端对接 DeerFlow 后端适配开发文档
> 状态:待实施
> 日期:2026-08-29
> 前端范围:F:/react01/code-stutsiu/frontend/apps/coze-studio/src/workflow-standalone/
> 后端范围:offline-backend-20260512/backend/
> 关联文档:
>
> - docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md:工作流运行时总体设计与已有能力。
> - docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md:安全、并发、租约与事件持久化整改。
>
> 本文只处理“当前独立 Coze 页面与真实 DeerFlow 后端之间”的适配,不重新设计工作流运行内核。
## 1. 目标与结论
当前独立页的交互、三栏布局、资源卡、运行历史、SSE reducer 与 mock 演示已可用,但其保存、校验、发布和部分运行事件仍使用 mock 协议。真实 DeerFlow 后端采用 WorkflowGraph v1.0 和持久化运行时,两者不能直接互换。
本次适配目标:
1. Coze 画布能够保存、重新加载、校验、发布,并由 DeerFlow 运行。
2. 画布上的 Agent、Skill、SQL、HTTP、Code、Input、Output 节点都有真实、可审计的执行配置。
3. 左侧对话运行和右侧执行过程能从同一条 DeerFlow SSE 事件流稳定恢复工具步骤、人工介入与文件制品。
4. 不把 Coze 的内部数值节点类型、任意画布 JSON、凭据明文或完整工具参数泄漏到运行时协议中。
结论:不能仅在现有 POST /api/workflows/{id}/runs 中增加几个兼容字段。必须先建立“画布表示”和“执行图表示”的明确边界;否则保存成功的流程仍无法发布或运行。
## 2. 当前差异清单
| 编号 | 当前独立页行为 | DeerFlow 当前行为 | 影响 | 所属 |
| --- | --- | --- | --- | --- |
| A01 | 保存 body 为 schemaJson,读取 workflowId / draftRevision / schemaJson | 草稿接口要求 graph,读取返回 id / draft_graph / draft_revision | 自动保存、冲突恢复、发布均无法真实联调 | 前端主改,后端提供工作室 DTO |
| A02 | 校验期待 valid 字段 | 后端返回 ok 字段 | 前端始终判定校验不通过 | 前后端契约统一 |
| A03 | Coze JSON 被原样持久化 | 后端运行时只接受 WorkflowGraph v1.0 | Coze 数值节点类型不可执行 | 前端编译,后端持久化与验证 |
| A04 | 拖入资源只预填节点标题 | Agent / Skill 执行器要求 agentId、skillNames、promptTemplate 等 | 资源卡看起来可用,真实运行必失败 | 前端表单 + 后端发布校验 |
| A05 | 工具事件读取 name、status | 后端发送 toolName、ok | 工具步骤丢失,失败状态错误 | 后端规范化,前端兼容旧字段 |
| A06 | 人工介入读取顶层 prompt / formSchema | 后端放在 pendingInput 内 | 介入卡没有问题文本和表单 | 后端事件扁平化,前端兼容 |
| A07 | 文件预览请求单个 artifact 内容接口 | 后端只提供制品列表 | 文件卡可见但无法打开或下载 | 后端新增制品内容接口 |
| A08 | 资源 hook 请求 credentials 目录 | 后端没有该路由 | 整个资源加载 Promise.all 可能显示失败 | 后端补只读引用目录,或前端暂停请求 |
| A09 | UI 可选“当前草稿试运行”和 mock 场景 | 后端忽略 target / scenario,只运行发布版本 | 用户以为执行草稿,实际执行旧版本 | 前端先隐藏;后端后续可实现草稿快照 |
| A10 | 连续输入会创建新的 run | 后端无 conversation / turn 模型 | 不是 ChatDev 式多轮会话 | 二期可选能力 |
## 3. 必须冻结的总体设计
### 3.1 两种表示,两个职责
同一工作流必须保留两份有明确用途的数据,不能让任何一份冒充另一份。
| 数据 | 责任方 | 用途 | 是否可直接执行 |
| --- | --- | --- | --- |
| canvasSchemaJson | Coze 前端产生;后端仅作为受限 JSON 文档保存 | 画布恢复、节点位置、Coze 表单内部字段、编辑体验 | 否 |
| executionGraph | 前端适配器编译;后端以 WorkflowGraph 校验并版本化 | 发布校验、权限检查、调度、恢复、SSE、审计 | 是 |
约束:
1. DeerFlow harness 只接收 executionGraph,绝不解析 Coze StandardNodeType 或 Coze 内部服务字段。
2. canvasSchemaJson 不进入 SSE,不进入运行快照,不作为表达式或节点配置来源。
3. 发布版本至少冻结 executionGraph;建议同时保存其来源 canvasSchemaJson 的哈希,便于审计与排错。
4. Coze 到 WorkflowGraph 的逐节点转换放在前端 adapters/coze-canvas-adapter.ts 或其同级编译器中。后端不耦合 Coze 的数值节点类型。
5. 后端应拒绝“只有 canvasSchemaJson、没有合法 executionGraph”的发布请求,不能静默将其当作空图。
### 3.2 推荐的工作室适配层
保持现有 /api/workflows 的标准运行时 API 语义,不在其核心请求中混入 Coze 专属字段。新增一层仅面向独立页的 studio facade:
GET /api/workflows/{workflow_id}/studio-document
PUT /api/workflows/{workflow_id}/studio-draft
POST /api/workflows/{workflow_id}/studio-validate
POST /api/workflows/{workflow_id}/studio-publish
现有以下端点继续作为运行时和其他客户端的标准接口:
POST /api/workflows/{workflow_id}/runs
GET /api/workflows/{workflow_id}/runs
GET /api/workflows/runs/{run_id}/events
GET /api/workflows/runs/{run_id}/stream
这样既避免污染通用 API,也让前端能获得它需要的 camelCase DTO。
## 4. Studio facade API 契约
### 4.1 获取文档
请求:
GET /api/workflows/{workflow_id}/studio-document
响应:
{
"workflowId": "wf_001",
"name": "周报生成",
"draftRevision": 12,
"publishedVersionId": "ver_009",
"canvasSchemaJson": "{...}",
"executionGraph": { "schemaVersion": "1.0", "id": "wf_001", "nodes": [], "edges": [] },
"updatedAt": "2026-08-29T10:00:00+08:00"
}
要求:
- 从现有 definition 的 id、draft_revision、draft_graph、published_version_id 等字段适配生成。
- canvasSchemaJson 为空时返回空画布 JSON,而不是 null。
- 权限沿用现有 owner/admin 规则。
- 不返回 DSN、HTTP 请求头、resume token、完整 agent 配置或任何私密字段。
### 4.2 保存草稿
请求:
PUT /api/workflows/{workflow_id}/studio-draft
{
"expectedRevision": 12,
"canvasSchemaJson": "{...}",
"executionGraph": {
"schemaVersion": "1.0",
"id": "wf_001",
"nodes": [],
"edges": []
}
}
响应:
{ "revision": 13, "updatedAt": "2026-08-29T10:01:00+08:00" }
规则:
1. 先做 WorkflowGraph 结构解析;无法解析返回 400,错误码 WORKFLOW_SCHEMA_INVALID。
2. 画布 JSON 只做大小、JSON 格式、深度和危险字段控制,不做执行解释。
3. canvasSchemaJson 与 executionGraph 必须在同一次 CAS 更新中落库,不能出现画布和执行图不同 revision。
4. revision 冲突返回 409:
{
"code": "WORKFLOW_DRAFT_CONFLICT",
"message": "草稿版本冲突,请刷新后重试或另存副本",
"currentRevision": 13
}
5. 不建议让旧 PUT /draft 同时接受 schemaJson 与 graph。若为灰度必须暂时兼容,兼容逻辑只能位于 facade 路由,且必须在一个发布周期后删除。
### 4.3 校验与发布
校验请求:
POST /api/workflows/{workflow_id}/studio-validate
{ "executionGraph": { ... } }
校验响应:
{
"valid": false,
"issues": [
{
"code": "WORKFLOW_RESOURCE_MISSING",
"nodeId": "agent_writer",
"field": "config.agentId",
"message": "未选择可访问的智能体"
}
],
"graphHash": null
}
发布请求:
POST /api/workflows/{workflow_id}/studio-publish
{ "expectedRevision": 13, "changeNote": "配置报告智能体" }
发布响应:
{ "versionId": "ver_010", "publishedAt": "2026-08-29T10:02:00+08:00" }
发布必须复用标准发布版本表和不可变 graph 快照。facade 只负责 DTO 命名、画布关联和调用标准发布服务,不能复制一套版本逻辑。
## 5. 后端实施项
### 5.1 持久化 canvas schema 与草稿原子更新
现有 workflow_definitions 只有 draft_graph_json。新增独立字段,例如 draft_canvas_schema_json;不要把 Coze 画布塞进 draft_graph_json。
需要修改:
| 路径 | 改动 |
| --- | --- |
| packages/harness/deerflow/persistence/workflows/model.py | WorkflowDefinitionRow 增加 draft_canvas_schema_json;如需审计可增加 published_canvas_schema_hash |
| packages/harness/deerflow/persistence/migrations/versions/ | 新增 Alembic migration,存量行默认 {} |
| packages/harness/deerflow/persistence/workflows/base.py | save_studio_draft 或扩展 save_draft 的原子接口,输入两份文档和 expected_revision |
| packages/harness/deerflow/persistence/workflows/sql.py | 单条 CAS UPDATE 同时更新两份文档、revision、updated_at |
| packages/harness/deerflow/persistence/workflows/memory.py | 同步实现,保证测试环境语义一致 |
禁止:
- 先保存 canvas、后保存 graph 的两次写入。
- 在 SQL store 之外用“先读 revision、再无条件写”的方式实现 CAS。
- 把 canvasSchemaJson 输出到运行事件或 run context。
### 5.2 Studio facade 路由与 DTO
新增 app/gateway/routers/workflow_studio.py,负责:
1. studio-document、studio-draft、studio-validate、studio-publish 四个端点。
2. camelCase DTO 与现有 persistence snake_case 字段之间的映射。
3. 统一错误结构和 owner/admin 鉴权。
4. 调用通用 WorkflowGraph 结构校验与 app 层资源校验。
在 app/gateway/app.py 或当前工作流 router 注册位置挂载该 router。不要让 packages/harness/deerflow 导入 app.gateway。
前端移交项:
- api/workflows.ts 改为调用 studio facade。
- coze-canvas-adapter.ts 产出 executionGraph,不再只做 JSON.stringify。
- 画布新增或修改节点时,同时更新可编译的节点配置。
### 5.3 发布前资源和权限校验
现有 validator 支持 resource_checker 钩子,但通用 harness 不应直接导入应用层的 agent、skill、data-source store。应在 app 层构造 checker 后传入。
在 studio-validate、studio-publish 和标准 publish 入口都执行:
| 节点 | 必检字段 | 后端校验 |
| --- | --- | --- |
| agent | agentId、promptTemplate、responseSchema | 智能体存在、调用者可见、提示词非空、输出 schema 合法 |
| skill | mode、agentId、skillNames、promptTemplate | 技能存在且启用;agent_skill 必须属于智能体白名单;callable_skill 未开放时明确拒绝 |
| sql_read | dataSourceId、queryTemplate、parameters | 数据源可见、只读、表白名单、SQL 解析与 maxRows 上限 |
| http | dataSourceId 或 credentialRef、method、path、headers 映射 | 资源可见、方法白名单、URL/内网策略、凭据引用可用 |
| code | language、source、timeoutSeconds、output mapping | 沙箱已启用、语言白名单、超时和输出目录限制 |
| output | schema、field mapping | 输出字段可解析,输出 schema 可校验 |
| subworkflow | workflowId、versionId | 子工作流已发布、调用者有权限、深度限制 |
校验必须返回 nodeId 与字段路径,便于前端定位 Coze 节点表单。运行时仍保留二次校验,防止资源被禁用或权限在发布后撤销。
### 5.4 资源目录与凭据引用
保留现有:
- app/gateway/routers/workflow_resources.py:Agent、Skill、数据源目录。
- app/gateway/routers/workflow_data_sources.py:SQL/HTTP 数据源创建、加密与只读策略。
补充:
GET /api/workflows/resources/credentials
响应只允许:
{
"credentials": [
{
"credentialId": "http_source_001",
"name": "CRM 接口密钥",
"type": "http_headers",
"sourceKind": "http"
}
]
}
绝不返回 headers、token、DSN、加密密文或可逆提示。若一期 UI 没有凭据选择器,前端必须停止请求该端点,不能让一个 404 使整个资源面板显示“加载失败”。
### 5.5 运行目标、输入 schema 与草稿试运行
一期决策:
1. POST /runs 只接受发布版本或明确的 versionId。
2. 前端隐藏“当前草稿(试运行快照)”和 mock scenario 选择器。
3. 后端若收到 target=draft,返回 422 WORKFLOW_DRAFT_RUN_UNSUPPORTED,禁止静默回退到发布版本。
二期若需要草稿试运行,新增明确的 test-run API,而不是篡改正式 runs:
POST /api/workflows/{workflow_id}/test-runs
流程为:CAS 读取草稿 → 生成不可变 test snapshot → 记录 snapshot id → 运行 → 保留期清理。运行记录必须注明 test=true,不能被误认为发布版本运行。
运行输入:
- studio-document 响应必须带 inputSchema。
- 前端根据 inputSchema 渲染输入,而非永远发送 input.query。
- POST /runs 继续使用后端 JSON Schema 验证;错误返回 field/path/expected,前端在表单附近展示。
### 5.6 SSE 事件契约收敛
后端事件是前端执行过程唯一事实来源。所有事件须先持久化再广播,seq 单调递增且可 REST 重放。
规范化以下 payload:
node.tool.started
{
"name": "web_search",
"toolCallId": "call_001",
"title": "联网检索",
"args": { "path": "/mnt/user-data/outputs/report.md" }
}
node.tool.finished
{
"name": "web_search",
"toolCallId": "call_001",
"status": "succeeded",
"summary": "获得 12 条结果"
}
run.awaiting_input
{
"resumeToken": "one-time-token",
"nodeId": "review",
"prompt": "请确认是否发布",
"formSchema": { "type": "object", "properties": {} },
"actions": ["approve", "reject"]
}
artifact.created
{
"artifactId": "artifact_001",
"name": "weekly-report.docx",
"kind": "file",
"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"sizeBytes": 1234
}
规则:
- 工具字段统一为 name;不要只发 toolName。
- 失败工具必须明确 status=failed 与脱敏 error。
- pendingInput 可以继续存储在数据库,但对外事件须扁平化为 prompt、formSchema、actions。
- agent 节点继续发送安全展示元数据 agent、parallelGroupId、roundIndex;不发送系统提示词、技能白名单、凭据和完整配置。
- 不将每个模型 token 当作 aria/live 或持久化事件;继续使用节流后的 node.output.delta。
- 事件类型表若仍不含 node.tool.input_delta,应从前端 mock 中删除该依赖,或后端正式实现该事件;不能只有 mock 有。
涉及路径:
| 路径 | 改动 |
| --- | --- |
| packages/harness/deerflow/workflows/nodes/agents.py | on_tool 输出 name、status、最小安全 args |
| app/gateway/workflow_executor.py | run.awaiting_input 对外 payload 扁平化 |
| packages/harness/deerflow/workflows/events.py | 冻结事件类型与 payload schema 样例 |
| app/gateway/routers/workflow_runs.py | REST 重放与 SSE 输出保持同一 envelope |
| packages/harness/deerflow/workflows/runtime/engine.py | 保持 node.started 的 agent / parallelGroupId / roundIndex 安全投影 |
### 5.7 制品内容、预览和下载
新增:
GET /api/workflows/runs/{run_id}/artifacts/{artifact_id}
GET /api/workflows/runs/{run_id}/artifacts/{artifact_id}/download
内容接口返回受限预览:
{
"artifactId": "artifact_001",
"name": "weekly-report.md",
"kind": "markdown",
"mimeType": "text/markdown",
"text": "# 周报",
"sizeBytes": 2048
}
策略:
1. 每次读取先校验 run owner/admin,再校验 artifact 属于该 run。
2. 文本、JSON、表格只返回大小受限预览;大文件返回 truncated=true。
3. 二进制文件不塞入 JSON;download 接口以附件或受控 inline 响应流返回。
4. 路径只能从已登记的 artifact id 解析,禁止接收任意 sandbox 路径。
5. 制品存储生命周期沿用现有 sandbox/artifact 机制;工作流表只存引用与元数据。
涉及路径:
- app/gateway/routers/workflow_runs.py:新增两个路由。
- packages/harness/deerflow/persistence/workflow_runs/*:按 run_id + artifact_id 查询的 store 方法。
- app/gateway/routers/artifacts.py:仅可参考现有 thread artifact 的鉴权和文件响应,不可直接跨 thread 复用。
### 5.8 取消状态和事件游标
统一运行状态:
| 后端状态 | 前端显示 | 说明 |
| --- | --- | --- |
| queued | 排队中 | 已入队未领取 |
| running | 运行中 | 正在执行 |
| awaiting_input | 等待人工介入 | 暂停并持有一次性 token |
| cancel_requested | 取消中 | 等待运行器确认,非终态 |
| completed / failed / cancelled | 终态 | SSE 停止重连 |
后端工作:
1. _public_run 输出 lastSeq 时映射 persistence 的 next_event_seq,不能读取不存在的 last_seq。
2. 前端 RunStatus 类型和 RunStatusBadge 增加 cancel_requested;后端不应把它伪装为 cancelled。
3. list events 空页必须返回请求 cursor,防止前端游标回退。
### 5.9 连续对话运行(二期,可选)
当前“对话运行”是单个 run 的视图投影:每次发送创建一个新 run。若业务要 ChatDev 式连续问答,需要独立设计,不能复用 run_id 作为会话 id。
建议新增:
workflow_conversations
workflow_conversation_turns
workflow_runs.conversation_id (nullable)
接口:
POST /api/workflows/{workflow_id}/conversations
GET /api/workflows/{workflow_id}/conversations
POST /api/workflows/conversations/{conversation_id}/turns
每个 turn 创建一个 run,并记录输入、run id、可展示摘要和制品引用。默认不把完整模型上下文拼接给每个节点;由 graph 明确配置哪些字段、哪些历史摘要可见。
## 6. 推荐实施顺序
### 阶段 A:真实保存与发布闭环(阻断项)
1. 增加 canvas schema 持久化迁移和 store 原子接口。
2. 实现 studio facade 四个端点。
3. 前端实现 Coze JSON 到 WorkflowGraph 编译,并改接 facade。
4. 完成输入/输出、代码、Agent、Skill、SQL、HTTP 的最小节点配置表单。
5. 加入发布前 app 层资源检查。
验收:编辑一个“Input → SQL → Agent → Output”流程,刷新后画布与配置不丢失,发布后执行的是同一版本的标准图。
### 阶段 B:真实执行过程闭环
1. 统一工具、人工介入、制品的 SSE payload。
2. 实现制品内容与下载接口。
3. 修复 lastSeq、cancel_requested。
4. 增加跨 REST 重放、SSE 断线重连、人工介入恢复的契约测试。
验收:报告工作流在 iframe 刷新后能恢复步骤、工具摘要、文件卡和人工介入状态。
### 阶段 C:草稿试运行与连续会话
1. 先由产品确认是否真的需要草稿试运行;需要时实现独立 test-run snapshot。
2. 再由业务确认是否需要跨 run 的上下文记忆;需要时实现 conversation/turn。
在阶段 C 前,前端必须隐藏对应入口,禁止用“看似可点、实际忽略”的体验替代能力。
## 7. 测试与验收
### 7.1 后端新增测试
建议新增:
| 文件 | 重点场景 |
| --- | --- |
| tests/test_workflow_studio_facade.py | studio DTO、camelCase、CAS、错误结构、owner/admin 鉴权 |
| tests/test_workflow_studio_graph_contract.py | Coze 编译产物可被 WorkflowGraph 接受;非法 graph 被拒绝 |
| tests/test_workflow_resource_validation.py | Agent/Skill/SQL/HTTP/Code/Output 配置与权限校验 |
| tests/test_workflow_event_contract.py | tool、awaiting_input、artifact、parallel agent payload 与 REST/SSE 一致 |
| tests/test_workflow_artifact_content.py | 文本预览、二进制下载、越权、路径穿越、超大文件 |
| tests/test_workflow_runs.py | target=draft 显式拒绝、cancel_requested、lastSeq |
保留并扩展:
- tests/test_workflow_engine.py:并行、循环、节点 started 元数据。
- tests/test_workflow_runs.py:运行、取消、恢复、SSE、重放。
- tests/test_workflow_data_sources.py:SQL 只读、HTTP allowlist、秘密不回显。
### 7.2 前后端契约 fixture
在 backend/tests/fixtures/workflow_studio/ 固化 JSON fixture,并让前端测试复制或通过生成脚本消费:
1. studio-document。
2. 保存成功与 409 冲突。
3. 资源缺失校验。
4. Agent 工具 started/finished。
5. human_input 暂停与恢复。
6. markdown/json/table/docx 制品。
7. 并行三智能体与取消中状态。
禁止前后端各自手写近似 mock 事件。fixture 变更必须同时更新双方测试。
### 7.3 发布准入
- [ ] Coze 画布和 executionGraph 采用两份数据、一次 CAS 保存。
- [ ] 真实后端不再依赖 schemaJson、valid 等 mock 专用字段。
- [ ] 所有可拖入资源都能在 graph 中找到真实绑定字段。
- [ ] 发布前可定位到节点和字段的资源/权限错误。
- [ ] 工具、人工介入、制品在 SSE 与 REST replay 中字段一致。
- [ ] 制品内容和下载经过 run 级鉴权,不能按任意路径读取。
- [ ] target=draft 不会被静默执行为发布版本。
- [ ] typecheck、后端 pytest、ruff 与至少一条 iframe E2E 全部通过。
## 8. 明确不做的事项
1. 不把整个 Coze Go 后端或 ChatDev 后端迁入 DeerFlow。
2. 不在 DeerFlow harness 中导入 app.gateway 或 Coze 前端类型。
3. 不为了兼容 mock 而让标准 WorkflowGraph API 永久接受任意 schemaJson。
4. 不向浏览器返回数据库 DSN、HTTP headers、token、resume token、沙箱真实文件路径或模型隐藏思维链。
5. 不在未实现草稿快照、连续会话前保留误导性的 UI 入口。
@@ -0,0 +1,54 @@
# ADR 0001:工作流编排运行时边界
- **状态**:Accepted(阶段 0 冻结;决策 3 已按阶段 2 实现修订,见文末修订记录)
- **日期**:2026-08-28
- **关联**:`docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`
## 背景
需要在 DeerFlow 中支持可持久化、可恢复、可审计的工作流运行,并用 Coze Studio 独立画布作为编辑器。可选路径包括:整包接入 Coze 后端、整包接入 ChatDev 2.0、或在 DeerFlow 内自建运行时。
## 决策
1. **Coze Studio 只负责画布与节点配置 UI**
不复用其完整后端协议、内置试运行、Trace/Debug。Gateway 仅提供薄兼容层(canvas/save/create/node_template)。
2. **ChatDev 2.0 只作语义参考**
可参考 DAG、并行、循环、动态边、会话执行边界;**不**引入其 WebSocket、SQLite 存储、宿主机 `subprocess` 代码执行、自有 Agent/模型 runtime。
3. **执行内核 = DeerFlow 自建 DAG 调度器;LangGraph 只作智能体内核**
见下方「阶段 2 修订」。智能体 / 技能 / 沙箱 / 模型 / 制品全部复用现有 harness 能力。`deerflow.workflows` 不得导入 `app.*`。
4. **流式协议 = 持久事件日志 + SSE**
先写 `workflow_run_events`,再推 live;断线用 `Last-Event-ID` / `after_seq` 补放。不与 ChatDev WebSocket 并存。
## 阶段 0 配套确认
| 议题 | 结论 |
|------|------|
| SQL 节点驱动 | 首期仅 **MySQL 和/或 PostgreSQL**(与项目实际库一致);不抽象全数据库 |
| 凭据存储 | 画布只存 `credentialRef` / `dataSourceId`;明文密钥进服务端凭据存储,不进 JSON / SSE / 日志 |
| iframe 域名 | 由 `config.yaml` → `workflows.embed.allowed_origins`(阶段 6 落地配置项);票据绑定 origin + workflow + user + actions,约 60s 一次性兑换 |
| 应用库 vs 业务查询库 | DeerFlow 自身业务库 **不得**直接暴露给 SQL 节点;必须管理员登记独立只读数据源 |
## 阶段 2 修订:工作流级编排不编译成 LangGraph
阶段 0 原定「标准图编译为 `StateGraph`」。实现阶段 2 时改为**自建 DAG 调度器**(`deerflow/workflows/runtime/engine.py`),LangGraph 退到**单个 `agent` / `skill` 节点内部**继续做智能体内核。
**原因**:运行状态必须只有一个权威来源。工作流的可恢复性建立在 `workflow_node_runs`(已完成节点的输出)+ `workflow_run_events`(事件日志)之上——重新租约或人工恢复时,执行器从这两张表回放,只跑缺失的节点。若同时用 LangGraph checkpoint 保存工作流级状态,就会出现两份状态需要保持一致(checkpoint 与 node_runs 谁说了算?租约被抢走后 checkpoint 归谁?),恢复语义无法自证。此外 `human_input` 的暂停需要把 `resume_token` 与 `pending_input` 暴露到 HTTP/SSE 层,条件分支需要「未选中的端口整条子图跳过」的显式边状态,循环需要按 `bodyEntry` 精确失效 body 内的 node_runs——这些都要求调度器直接读写运行存储。
**代价**:调度、并行度、重试、超时、循环上限这些机制要自己实现和测试(`tests/test_workflow_engine.py`、`tests/test_workflow_runs.py`)。**收益**:崩溃恢复、取消、暂停/恢复都只有一条真相路径,且不需要为工作流额外引入一套 checkpoint 生命周期。
调度规则本身很小:每条边有 `pending` / `active` / `pruned` 三态,节点在「非回边入边都已定型且至少一条 `active`」时就绪,全部 `pruned` 则跳过并继续下推剪枝。`loop` 节点的 `continue` 回边是唯一允许的环,不参与就绪判定。
## 后果
- 短期需自建 schema / store / dispatcher / SSE,工作量高于“整包移植”。
- 长期只维护一套模型调用、鉴权、沙箱与审计,避免双运行时分叉。
- 前端时间线必须与本仓库冻结的事件 envelope(`deerflow.workflows.events`)契约一致。
## 否决方案
- 把 Coze `workflow_api` 全量试运行当作执行引擎。
- 把 ChatDev `python_executor.py`(宿主机 subprocess)当作代码节点。
- 在画布 JSON 或 URL query 中传递 JWT / DB 密码 / API Key。
@@ -57,7 +57,7 @@ _UPSTREAM_PATTERNS = ("bad_response_status_code", "bad response status",
"expecting ',' delimiter", "expecting value", "expecting property name", "expecting ',' delimiter", "expecting value", "expecting property name",
"unexpected character", "unterminated string", "jsondecodeerror") "unexpected character", "unterminated string", "jsondecodeerror")
_TRANSIENT_EXC_NAMES = {"APITimeoutError", "APIConnectionError", "InternalServerError", _TRANSIENT_EXC_NAMES = {"APITimeoutError", "APIConnectionError", "InternalServerError",
"ReadError", "RemoteProtocolError", "TimeoutError"} "ReadError", "RemoteProtocolError", "ReadTimeout", "ConnectTimeout", "TimeoutError"}
def classify_exception(exc: BaseException) -> str: def classify_exception(exc: BaseException) -> str:
@@ -43,6 +43,41 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _parent_isolated_run_config(run_name: str) -> dict[str, Any]:
"""Keep LangGraph runtime, drop only inherited LLM streaming callbacks.
``StreamWriter`` reads ``get_config()[configurable]`` at emit time. Replacing
the child config with a blank dict makes ``report_delta`` / ``report_thinking``
raise and get swallowed, so the sandbox only appears after the file lands.
"""
parent: dict[str, Any] = {}
try:
from langchain_core.runnables.config import var_child_runnable_config
current = var_child_runnable_config.get()
if isinstance(current, dict):
parent = dict(current)
except Exception: # noqa: BLE001 — isolation must never fail the completion
parent = {}
tags = [tag for tag in (parent.get("tags") or []) if tag != "nostream"]
tags.append("nostream")
parent["callbacks"] = []
parent["tags"] = tags
parent["run_name"] = run_name
return parent
def _clear_inherited_runnable_config(isolated: dict[str, Any]) -> Callable[[], None]:
"""Install the isolated config for the duration of ``astream``."""
try:
from langchain_core.runnables.config import var_child_runnable_config
except ImportError:
return lambda: None
token = var_child_runnable_config.set(isolated)
return lambda: var_child_runnable_config.reset(token)
# ── message conversion ────────────────────────────────────────────────────── # ── message conversion ──────────────────────────────────────────────────────
@@ -347,26 +382,39 @@ class DeerFlowCompletionBackend:
text_parts: list[str] = [] text_parts: list[str] = []
usage: dict[str, Any] = {} usage: dict[str, Any] = {}
inline_think_filter = InlineThinkTagFilter() inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(to_langchain_messages(messages), **kwargs): # Isolate from the parent LangGraph run: inherited callbacks would
if self._cancel is not None: # replay every report token into the chat ``messages`` stream, so the
self._cancel.raise_if_cancelled() # left-hand bubble and the sandbox would write the same prose at once.
reasoning = _reasoning_delta(chunk) # Custom ``report_delta`` events remain the only live preview channel.
if reasoning and on_reasoning is not None: isolated_config = _parent_isolated_run_config(f"deep_research:{operation}")
await on_reasoning(reasoning) reset_parent_config = _clear_inherited_runnable_config(isolated_config)
# A few OpenAI-compatible models put their reasoning in the try:
# ordinary content channel instead of ``reasoning_content``. async for chunk in model.astream(
# Split it from Markdown even when tags span tokens: callers may to_langchain_messages(messages),
# display a live thought trace, but it never joins ``text``. config=isolated_config,
delta, inline_reasoning = inline_think_filter.push_parts(_content_to_text(chunk)) **kwargs,
if inline_reasoning and on_reasoning is not None: ):
await on_reasoning(inline_reasoning) if self._cancel is not None:
if delta: self._cancel.raise_if_cancelled()
text_parts.append(delta) reasoning = _reasoning_delta(chunk)
if on_delta is not None: if reasoning and on_reasoning is not None:
await on_delta(delta) await on_reasoning(reasoning)
chunk_usage = _extract_usage(chunk) # A few OpenAI-compatible models put their reasoning in the
if any(chunk_usage.values()): # ordinary content channel instead of ``reasoning_content``.
usage = chunk_usage # Split it from Markdown even when tags span tokens: callers may
# display a live thought trace, but it never joins ``text``.
delta, inline_reasoning = inline_think_filter.push_parts(_content_to_text(chunk))
if inline_reasoning and on_reasoning is not None:
await on_reasoning(inline_reasoning)
if delta:
text_parts.append(delta)
if on_delta is not None:
await on_delta(delta)
chunk_usage = _extract_usage(chunk)
if any(chunk_usage.values()):
usage = chunk_usage
finally:
reset_parent_config()
tail, inline_reasoning_tail = inline_think_filter.finish_parts() tail, inline_reasoning_tail = inline_think_filter.finish_parts()
if inline_reasoning_tail and on_reasoning is not None: if inline_reasoning_tail and on_reasoning is not None:
@@ -72,9 +72,7 @@ async def harvest_thread_materials(
visible text (its collection小结) and doubles as a fallback material when visible text (its collection小结) and doubles as a fallback material when
the thread produced no harvestable tool results. the thread produced no harvestable tool results.
""" """
checkpoint_tuple = await checkpointer.aget_tuple( checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": runtime_thread_id, "checkpoint_ns": ""}})
{"configurable": {"thread_id": runtime_thread_id, "checkpoint_ns": ""}}
)
if checkpoint_tuple is None: if checkpoint_tuple is None:
logger.warning("deep-research harvest: no checkpoint for thread %s", runtime_thread_id) logger.warning("deep-research harvest: no checkpoint for thread %s", runtime_thread_id)
return [], "" return [], ""
@@ -121,9 +119,14 @@ def harvest_messages_materials(
def _harvest_tool_messages(messages: list[Any], *, query: str) -> list[ResearchMaterial]: def _harvest_tool_messages(messages: list[Any], *, query: str) -> list[ResearchMaterial]:
"""Convert every harvestable tool result in the thread into materials.""" """Convert every harvestable tool result in the thread into materials.
每条材料在 ``metadata["message_index"]`` 记录其来源工具消息在对话中的
位置——写作管线据此区分「课题请求之后检索的(属于当前课题)」与
「课题切换前的旧检索」(后者才做词面相关度过滤)。
"""
materials: list[ResearchMaterial] = [] materials: list[ResearchMaterial] = []
for message in messages: for message_index, message in enumerate(messages):
if _message_type(message) != "tool": if _message_type(message) != "tool":
continue continue
tool_name = str(_message_name(message) or "") tool_name = str(_message_name(message) or "")
@@ -140,15 +143,20 @@ def _harvest_tool_messages(messages: list[Any], *, query: str) -> list[ResearchM
if isinstance(item, dict): if isinstance(item, dict):
material = result_to_material(item, query=envelope_query) material = result_to_material(item, query=envelope_query)
material.metadata["retrieval_channel"] = "chat_web_search" material.metadata["retrieval_channel"] = "chat_web_search"
material.metadata["message_index"] = message_index
materials.append(material) materials.append(material)
continue continue
materials.extend( materials.extend(_harvest_generic_payload(payload, tool_name=tool_name, query=query, message_index=message_index))
_harvest_generic_payload(payload, tool_name=tool_name, query=query)
)
return materials return materials
def _harvest_generic_payload(payload: Any, *, tool_name: str, query: str) -> list[ResearchMaterial]: def _harvest_generic_payload(
payload: Any,
*,
tool_name: str,
query: str,
message_index: int | None = None,
) -> list[ResearchMaterial]:
"""Normalize a non-web_search tool payload (skill/knowledge/bash/MCP).""" """Normalize a non-web_search tool payload (skill/knowledge/bash/MCP)."""
source_type = _TOOL_SOURCE_TYPES.get(tool_name, "other") source_type = _TOOL_SOURCE_TYPES.get(tool_name, "other")
materials: list[ResearchMaterial] = [] materials: list[ResearchMaterial] = []
@@ -177,6 +185,7 @@ def _harvest_generic_payload(payload: Any, *, tool_name: str, query: str) -> lis
metadata={ metadata={
"retrieval_channel": "chat_tool", "retrieval_channel": "chat_tool",
"tool_name": tool_name, "tool_name": tool_name,
**({"message_index": message_index} if message_index is not None else {}),
}, },
) )
) )
@@ -198,35 +198,63 @@ class BasicRunner:
cancellation.raise_if_cancelled() cancellation.raise_if_cancelled()
if not all_materials: if not all_materials:
knowledge_fill = _allows_knowledge_fill(request.config.report_instruction)
await events.emit( await events.emit(
"warning", "warning",
phase="curating", phase="curating",
payload={"code": "no_materials", "message": "未收集到任何研究材料", "recoverable": False}, payload={
"code": "no_materials",
"message": (
"未收集到外部研究材料,改用领域知识撰写"
if knowledge_fill
else "未收集到任何研究材料"
),
"recoverable": knowledge_fill,
},
) )
report_md = f"# {request.query}\n\n未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。" if not knowledge_fill:
await adapters.artifacts.write_text("report.md", report_md) report_md = (
await events.emit("report_completed", phase="writing", payload={"reportVersion": 1, "sourceCount": 0}) f"# {request.query}\n\n"
return DeepResearchResult(report_markdown=report_md, source_ids=[], plan=plan) f"未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。"
)
await adapters.artifacts.write_text("report.md", report_md)
await events.emit(
"report_completed",
phase="writing",
payload={"reportVersion": 1, "sourceCount": 0},
)
return DeepResearchResult(report_markdown=report_md, source_ids=[], plan=plan)
deduped: list[ResearchMaterial] = []
selected_ids: list[str] = []
context_list: list[dict[str, Any]] = []
context_text = _KNOWLEDGE_FILL_EMPTY_CONTEXT
else:
from deerflow.agents.deep_research.adapters.material import dedupe_materials
from deerflow.agents.deep_research.adapters.material import dedupe_materials deduped = dedupe_materials(all_materials)
context_comp.set_materials(deduped)
deduped = dedupe_materials(all_materials) selected_ids = [m.id for m in deduped]
context_comp.set_materials(deduped) await events.emit(
"sources_curated",
phase="curating",
payload={"selectedIds": selected_ids, "removedIds": [], "reason": "deduped"},
)
selected_ids = [m.id for m in deduped] # ── 4. compress context ────────────────────────────────────────
await events.emit( await events.emit(
"sources_curated", "phase_changed",
phase="curating", phase="compressing",
payload={"selectedIds": selected_ids, "removedIds": [], "reason": "deduped"}, payload={"from": "curating", "to": "compressing"},
) )
if cancellation:
cancellation.raise_if_cancelled()
# ── 4. compress context ──────────────────────────────────────── context_list = await context_comp.async_get_context(
await events.emit("phase_changed", phase="compressing", payload={"from": "curating", "to": "compressing"}) request.query,
if cancellation: max_results=min(len(deduped), config.max_search_results_per_query * 2),
cancellation.raise_if_cancelled() )
context_text = _format_context(context_list, deduped)
context_list = await context_comp.async_get_context(request.query, max_results=min(len(deduped), config.max_search_results_per_query * 2))
context_text = _format_context(context_list, deduped)
# ── 5. writing: generate report ──────────────────────────────── # ── 5. writing: generate report ────────────────────────────────
await events.emit("phase_changed", phase="writing", payload={"from": "compressing", "to": "writing"}) await events.emit("phase_changed", phase="writing", payload={"from": "compressing", "to": "writing"})
@@ -403,6 +431,13 @@ class BasicRunner:
) )
outline_block = _report_outline_instruction(outline) outline_block = _report_outline_instruction(outline)
requirements_block = _report_requirements_instruction(request.config.report_instruction) requirements_block = _report_requirements_instruction(request.config.report_instruction)
if _allows_knowledge_fill(request.config.report_instruction):
system_extra = (
"用户已要求在材料不足时用模型自身的公开领域知识写成完整报告。"
"禁止把「材料不足/未提供文献」写成章节主线;参考文献可标注为基于公开领域知识。"
)
else:
system_extra = ""
if request.config.structure_mode == "fixed": if request.config.structure_mode == "fixed":
# Recency bias: keep the skeleton immediately before "请撰写报告". # Recency bias: keep the skeleton immediately before "请撰写报告".
context_block = ( context_block = (
@@ -427,6 +462,8 @@ class BasicRunner:
if filled_preamble: if filled_preamble:
await emitter.add(filled_preamble + "\n\n") await emitter.add(filled_preamble + "\n\n")
system = _SYSTEM system = _SYSTEM
if system_extra:
system += system_extra
if request.config.structure_mode == "fixed" and (outline or "").strip(): if request.config.structure_mode == "fixed" and (outline or "").strip():
system += "撰写时必须原样沿用用户大纲中的章节标题与层级,不得改写成通用报告套式。" system += "撰写时必须原样沿用用户大纲中的章节标题与层级,不得改写成通用报告套式。"
result = await llm.stream_complete( result = await llm.stream_complete(
@@ -624,12 +661,31 @@ def ensure_markdown_standalone_lines(text: str) -> str:
return "\n".join(output) return "\n".join(output)
_KNOWLEDGE_FILL_EMPTY_CONTEXT = (
"(本次未收集到外部研究材料。请基于模型自身的公开领域知识撰写完整、有实质内容的研究报告,"
"含架构说明;用户要求流程图时输出 Mermaid。"
"禁止只写「无法生成报告」或把「材料不足」写成全文。)"
)
_KNOWLEDGE_FILL_MARKERS = ("必须基于模型自身的领域知识", "基于模型自身的领域知识")
def _allows_knowledge_fill(report_instruction: str | None) -> bool:
text = report_instruction or ""
return any(marker in text for marker in _KNOWLEDGE_FILL_MARKERS)
def _report_requirements_instruction(report_instruction: str | None) -> str: def _report_requirements_instruction(report_instruction: str | None) -> str:
"""Render confirmed generation requirements as bounded, untrusted content.""" """Render confirmed generation requirements as bounded, untrusted content."""
if not report_instruction: if not report_instruction:
return "User generation requirements: none provided." return "User generation requirements: none provided."
evidence_note = (
"these requirements override the default 'do not go beyond materials' rule "
"when they ask to write from model knowledge"
if _allows_knowledge_fill(report_instruction)
else "content only; system and evidence rules still win"
)
return ( return (
"User generation requirements (content only; system and evidence rules still win):\n" f"User generation requirements ({evidence_note}):\n"
f"<user-report-requirements>\n{report_instruction}\n</user-report-requirements>" f"<user-report-requirements>\n{report_instruction}\n</user-report-requirements>"
) )
@@ -1,5 +1,5 @@
import hashlib import hashlib
import logging import logging
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
@@ -38,45 +38,112 @@ logger = logging.getLogger(__name__)
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。 # 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。 # 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
WRITING_SETUP_MAX_ROUNDS = 8 WRITING_SETUP_MAX_ROUNDS = 8
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8 REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">' _ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
# 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
}
def _log_es_query_routing_registration( _ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
*,
system_prompt: str, _NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
app_config: AppConfig, <orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".strip()
def _enforce_coordinator_only_tool_exclusions(
agent_id: str | None, agent_id: str | None,
available_skills: set[str] | None, excluded_tools: list[str] | None,
) -> None: ) -> list[str] | None:
"""Log whether the exact configured ES routing block reached this Q&A prompt.""" """Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
routing = app_config.skills.es_query_routing if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
configured_prompt = (routing.prompt or "").strip() return excluded_tools
expected_block = ( effective = list(excluded_tools or [])
f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n</skill_routing>" if "agent_orchestration" not in effective:
if configured_prompt effective.append("agent_orchestration")
else "" return effective
)
eligible = available_skills is None or "es_query" in available_skills
registered = bool(expected_block) and expected_block in system_prompt
fingerprint = (
hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12]
if configured_prompt
else "-"
)
log = logger.info if registered or not routing.enabled or not eligible else logger.warning
log( def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
"ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s " """Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
"registered=%s prompt_chars=%d prompt_sha256=%s", if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
agent_id or "default", return system_prompt
routing.enabled, return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
eligible,
registered,
len(configured_prompt), def _filter_available_skills_for_excluded_tools(
fingerprint, available_skills: list[str] | None,
) excluded_tools: list[str] | None,
) -> list[str] | None:
"""让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。"""
if not excluded_tools:
return available_skills
excluded = {name.strip().lower() for name in excluded_tools if name}
blocked = {
skill_name
for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items()
if tool_name in excluded
for skill_name in skill_names
}
if not blocked:
return available_skills
if available_skills is None:
# ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。
# 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。
try:
from deerflow.skills.storage import get_or_new_skill_storage
available_skills = [
skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True)
]
except Exception: # noqa: BLE001
logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True)
available_skills = []
return [name for name in available_skills if name.strip().lower() not in blocked]
def _log_es_query_routing_registration(
*,
system_prompt: str,
app_config: AppConfig,
agent_id: str | None,
available_skills: set[str] | None,
) -> None:
"""Log whether the exact configured ES routing block reached this Q&A prompt."""
routing = app_config.skills.es_query_routing
configured_prompt = (routing.prompt or "").strip()
expected_block = (
f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n</skill_routing>"
if configured_prompt
else ""
)
eligible = available_skills is None or "es_query" in available_skills
registered = bool(expected_block) and expected_block in system_prompt
fingerprint = (
hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12]
if configured_prompt
else "-"
)
log = logger.info if registered or not routing.enabled or not eligible else logger.warning
log(
"ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s "
"registered=%s prompt_chars=%d prompt_sha256=%s",
agent_id or "default",
routing.enabled,
eligible,
registered,
len(configured_prompt),
fingerprint,
)
def _get_runtime_config(config: RunnableConfig) -> dict: def _get_runtime_config(config: RunnableConfig) -> dict:
@@ -426,6 +493,18 @@ def _build_middlewares(
if agent_name == FORCED_RESEARCH_AGENT_ID: if agent_name == FORCED_RESEARCH_AGENT_ID:
middlewares.append(ForcedResearchMiddleware()) middlewares.append(ForcedResearchMiddleware())
# 写作模式首轮:允许先聊/检索;只有模型开始写 md 时才改走 deep_research_report。
# 不设 tool_choice——thinking 模式的兼容接口会 400。
writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if writing_mode:
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
if not is_writing_mode_report_path(writing_artifact_path if isinstance(writing_artifact_path, str) else None):
from deerflow.agents.middlewares.writing_mode_report_middleware import WritingModeReportMiddleware
middlewares.append(WritingModeReportMiddleware())
# LoopDetectionMiddleware — detect and break repetitive tool call loops # LoopDetectionMiddleware — detect and break repetitive tool call loops
middlewares.append(LoopDetectionMiddleware()) middlewares.append(LoopDetectionMiddleware())
@@ -517,8 +596,17 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip(): if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
writing_artifact_path = None writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) # 只有写作模式产物 report.md 才走续写;其它 leftover .md 不得跳过管线。
requested_excluded_tools = cfg.get("excluded_tools") or None if writing_artifact_path is not None:
from deerflow.tools.builtins.deep_research_report_tool import is_writing_mode_report_path
if not is_writing_mode_report_path(writing_artifact_path):
writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = _enforce_coordinator_only_tool_exclusions(
agent_id,
cfg.get("excluded_tools") or None,
)
try: try:
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup else None agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup else None
@@ -567,10 +655,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills
runtime_available_skills = merge_collector_skill_allowlist( runtime_available_skills = merge_collector_skill_allowlist(
agent_id, runtime_available_skills, runtime_extra_skills(cfg) agent_id, runtime_available_skills, runtime_extra_skills(cfg)
) )
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None runtime_available_skills = _filter_available_skills_for_excluded_tools(
runtime_available_skills,
requested_excluded_tools,
)
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None
config["metadata"].update( config["metadata"].update(
{ {
@@ -651,13 +743,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Special bootstrap agent with minimal prompt for initial custom agent creation flow # Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=get_available_tools( tools=get_available_tools(
model_name=model_name, model_name=model_name,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False), is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools, excluded_tools=requested_excluded_tools,
app_config=resolved_app_config, allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
) app_config=resolved_app_config,
)
+ [setup_agent], + [setup_agent],
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config), middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(
@@ -680,21 +773,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
default_tools = get_available_tools( default_tools = get_available_tools(
model_name=model_name, model_name=model_name,
groups=agent_config.tool_groups if agent_config else None, groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False), is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools, excluded_tools=requested_excluded_tools,
app_config=resolved_app_config, allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
) app_config=resolved_app_config,
)
if agent_id == FORCED_RESEARCH_AGENT_ID: if agent_id == FORCED_RESEARCH_AGENT_ID:
default_tools = filter_forced_research_tools(default_tools) default_tools = filter_forced_research_tools(default_tools)
if is_notebook_mode: if is_notebook_mode:
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
default_tools = [notebook_search_tool] + default_tools default_tools = [notebook_search_tool] + default_tools
# 写作模式首轮(尚无 md 产物):挂深度研究报告工具,报告由管线生成而非 # 写作模式首轮(尚无 report.md):挂深度研究报告工具,报告由管线生成而非
# 模型手工 write_file。续写轮(已有 writing_artifact_path)不挂,保持 # 模型手工 write_file。续写轮(已有 report.md)不挂,保持原有「直接编辑
# 原有「直接编辑该文件」行为。不受 tool_groups/excluded_tools 过滤—— # 该文件」行为。不受 tool_groups/excluded_tools 过滤——它是写作模式合约
# 它是写作模式合约的核心交付手段。 # 的核心交付手段。
if writing_mode and not writing_artifact_path: if writing_mode and not writing_artifact_path:
from deerflow.tools.builtins.deep_research_report_tool import deep_research_report_tool from deerflow.tools.builtins.deep_research_report_tool import deep_research_report_tool
@@ -737,20 +831,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=prompt_available_skills, available_skills=prompt_available_skills,
app_config=resolved_app_config, app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled, memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled, rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode, is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids, notebook_source_ids=notebook_source_ids,
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)), is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
) )
_log_es_query_routing_registration( system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
system_prompt=system_prompt,
app_config=resolved_app_config,
agent_id=agent_id,
available_skills=prompt_available_skills,
)
return create_agent( _log_es_query_routing_registration(
system_prompt=system_prompt,
app_config=resolved_app_config,
agent_id=agent_id,
available_skills=prompt_available_skills,
)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=default_tools, tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config), middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
@@ -7,11 +7,11 @@ import threading
from datetime import datetime from datetime import datetime
from functools import lru_cache from functools import lru_cache
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from deerflow.config.agents_config import load_agent_soul from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names from deerflow.subagents import get_available_subagent_names
@@ -488,25 +488,19 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
<ordinary_qa_markdown_format> <ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式: 当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。 - 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。 - 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。
- 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。 - 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。 - 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。 - 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format> </ordinary_qa_markdown_format>
""" """
def _ordinary_qa_markdown_format_enabled() -> bool: def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch. """Return the admin-controlled formal Markdown prompt switch."""
Reading the small runtime settings document for each prompt build makes an
administrator's change effective for the next run without a process-level
cache or restart. Any missing/invalid setting safely falls back to off.
"""
try: try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled) return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception: except Exception:
@@ -640,15 +634,15 @@ You: "Deploying to staging..." [proceed]
{acp_section} {acp_section}
</working_directory> </working_directory>
<response_style> <response_style>
- Clear and Concise: Avoid over-formatting unless requested - Clear and Concise: Avoid over-formatting unless requested
- Natural Tone: Use paragraphs and prose, not bullet points by default - Natural Tone: Use paragraphs and prose, not bullet points by default
- Action-Oriented: Focus on delivering results, not explaining processes - Action-Oriented: Focus on delivering results, not explaining processes
</response_style> </response_style>
{ordinary_qa_format_section} {ordinary_qa_format_section}
<citations> <citations>
**CRITICAL: Always include citations when using web search results** **CRITICAL: Always include citations when using web search results**
- **When to Use**: MANDATORY after web_search, web_fetch, or any external information source - **When to Use**: MANDATORY after web_search, web_fetch, or any external information source
@@ -808,20 +802,18 @@ def apply_agent_only_prompt_template(
else "" else ""
) )
if writing_mode and not writing_artifact_path: if writing_mode and not writing_artifact_path:
# 与通用模板一致:对话行为照旧,交付时刻的「写 md」动作交给 # 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
# deep_research_report 管线;资料不足时先经 ask_clarification 问用户。
writing_mode_section = """<writing_mode> writing_mode_section = """<writing_mode>
Writing mode is enabled for this run (first report turn — no existing artifact). Writing mode is enabled for this run (first report turn — no existing artifact).
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — never write it yourself with `write_file`, never `present_files` it. - HARD RULE: the report file is produced ONLY by the `deep_research_report` tool — call it directly at delivery time and do NOT draft the full report body first (not in chat, not inside tool arguments). File-writing tools (`write_file` / `str_replace`) and the `bash` tool are NOT available in this run; any hand-write attempt is intercepted and redirected to `deep_research_report`. Never `present_files` the report.
- Work normally (chat, search, clarify) until the report is due; then produce it by calling the `deep_research_report` tool: - Work normally (chat, search, clarify) until the report is due; then produce it by calling the `deep_research_report` tool:
`topic` = the user's subject copied faithfully, `focus` = the user's extra requirements (if any). `topic` = the user's subject (the refined thesis after clarifications, not the raw first message), `focus` = the extra requirements from the user's answers (type, length, tone, must-cover points).
The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references) The tool reuses the conversation's collected materials and writes the full report (general-analysis structure with references)
to `/mnt/user-data/outputs/report.md`, presented automatically. to `/mnt/user-data/outputs/report.md`, presented automatically.
- If the tool reports insufficient materials, call `ask_clarification` to ask whether to search for more; after the user answers, - If harvested materials are thin, still call `deep_research_report` once and let it write immediately.
re-call the tool with the same `topic`/`focus` plus `retrieve=true` (search more) or `retrieve=false` (existing materials only). Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials.
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question. - Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for it. - After the tool returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the core conclusions. Do not rewrite even if you think materials were thin.
- After the tool returns, reply briefly (2-3 sentences) summarizing the core conclusions; do not duplicate the report inline.
- Only if the tool fails, tell the user and offer to retry. - Only if the tool fails, tell the user and offer to retry.
</writing_mode> </writing_mode>
""" """
@@ -1423,7 +1415,7 @@ def build_report_structure_setup_prompt(
""" """
def apply_prompt_template( def apply_prompt_template(
subagent_enabled: bool = False, subagent_enabled: bool = False,
max_concurrent_subagents: int = 3, max_concurrent_subagents: int = 3,
*, *,
@@ -1435,11 +1427,11 @@ def apply_prompt_template(
is_bootstrap: bool = False, is_bootstrap: bool = False,
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
memory_injection_enabled: bool = True, memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False, rag_mode_enabled: bool = False,
is_notebook_mode: bool = False, is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None, notebook_source_ids: list[int] | None = None,
is_scheduled_run: bool = False, is_scheduled_run: bool = False,
) -> str: ) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name storage_agent_id = agent_id if agent_id is not None else agent_name
# Get memory context # Get memory context
@@ -1489,29 +1481,25 @@ def apply_prompt_template(
else "" else ""
) )
if writing_mode and not writing_artifact_path: if writing_mode and not writing_artifact_path:
# 首轮:对话行为一切照旧,仅在交付时刻把「写 md」动作交给 # 对话行为一切照旧;只有开始写 md 时才把交付交给 deep_research_report。
# deep_research_report 管线(收割对话资料 + 通用分析结构)。资料不足时 # 资料不足也直接开写,不再经 ask_clarification 征询用户是否补充检索。
# 工具会返回指令,让模型经 ask_clarification 征询用户是否补充检索。
writing_mode_section = """ writing_mode_section = """
<writing_mode> <writing_mode>
Writing mode is enabled for this run (first report turn — no existing artifact). Writing mode is enabled for this run (first report turn — no existing artifact).
**Mandatory Output Contract** **Mandatory Output Contract**
- HARD RULE: the report file is produced ONLY by the `deep_research_report` tool. You are FORBIDDEN from writing the report yourself — do not call `write_file` - HARD RULE: the report file is produced ONLY by the `deep_research_report` tool. Call the tool directly when the report is due — do NOT draft the full report body first (not in chat, not inside tool arguments). File-writing tools (`write_file` / `str_replace`) and the `bash` tool are NOT available in this run; any hand-write attempt is intercepted and redirected to `deep_research_report`. Do not call `present_files` for the report.
(or any file-writing tool) to create the report, and do not call `present_files` for it.
- Treat the user's request as a research-report writing task. Work normally until delivery time: discuss, search, and clarify exactly as you always would. - Treat the user's request as a research-report writing task. Work normally until delivery time: discuss, search, and clarify exactly as you always would.
- When the report file is due — the user asks to start writing, or you have finished gathering what the conversation needs — call the `deep_research_report` tool: - When the report file is due — the user asks to start writing, or you have finished gathering what the conversation needs — call the `deep_research_report` tool directly:
- `topic`: the user's subject/thesis, copied faithfully. - `topic`: the user's subject/thesis — the refined one settled in the clarifications, not the raw first message.
- `focus`: the user's extra requirements (length, tone, must-cover points), if any. - `focus`: the extra requirements from the user's answers (type, length, tone, must-cover points), if any.
- The tool reuses the materials already collected in this conversation (and searches more only if the user agrees), then writes the full - The tool reuses the materials already collected in this conversation, then writes the full
report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically. report (general-analysis structure with a numbered reference section) to `/mnt/user-data/outputs/report.md`, presented automatically.
- If the tool replies that the harvested materials are insufficient, it returns instructions: immediately call `ask_clarification` asking - If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately.
whether to search for more materials, and after the user answers, re-call `deep_research_report` with the same `topic`/`focus` plus Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason.
`retrieve=true` (user agreed) or `retrieve=false` (user declined — write with what exists). - Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question. - While the tool runs, stay silent. After it returns successfully: do NOT call `read_file`, `write_file`, `str_replace`, or `present_files` on the report. Reply briefly (2-3 sentences) summarizing the report's core conclusions and point the user to the Markdown artifact. Do not duplicate the report inline, and do not rewrite even if you think materials were thin.
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for the report — the tool already handles both. - Only if the tool fails, tell the user what went wrong and offer to retry; do not silently fall back to writing the report by hand.
- While the tool runs, stay silent; after it returns, reply briefly (2-3 sentences) summarizing the report's core conclusions and point the user to the Markdown artifact. Do not duplicate the report inline.
- Only if the tool fails, tell the user what went wrong and offer to retry; do not silently fall back to writing the report by hand unless the user explicitly asks.
</writing_mode> </writing_mode>
""" """
else: else:
@@ -1544,7 +1532,7 @@ Writing mode is enabled for this run.
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set). # Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
network_policy_section = _build_network_policy_section() network_policy_section = _build_network_policy_section()
rag_mode_section = ( rag_mode_section = (
""" """
<rag_citation_mode> <rag_citation_mode>
RAG/reference display mode is enabled. RAG/reference display mode is enabled.
@@ -1557,8 +1545,8 @@ When a search, knowledge, skill, or MCP tool returns a result list:
</rag_citation_mode> </rag_citation_mode>
""" """
if rag_mode_enabled if rag_mode_enabled
else "" else ""
) )
ordinary_qa_format_section = ( ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION _ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if _ordinary_qa_markdown_format_enabled() if _ordinary_qa_markdown_format_enabled()
@@ -1566,10 +1554,10 @@ When a search, knowledge, skill, or MCP tool returns a result list:
and not writing_mode and not writing_mode
and not is_notebook_mode and not is_notebook_mode
and not is_scheduled_run and not is_scheduled_run
else "" else ""
) )
# Format the prompt with dynamic skills and memory # Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format( prompt = SYSTEM_PROMPT_TEMPLATE.format(
agent_name=agent_name or storage_agent_id or "CM 助手", agent_name=agent_name or storage_agent_id or "CM 助手",
soul=get_agent_soul(storage_agent_id), soul=get_agent_soul(storage_agent_id),
@@ -1582,10 +1570,10 @@ When a search, knowledge, skill, or MCP tool returns a result list:
network_policy_section=network_policy_section, network_policy_section=network_policy_section,
subagent_section=subagent_section, subagent_section=subagent_section,
subagent_reminder=subagent_reminder, subagent_reminder=subagent_reminder,
subagent_thinking=subagent_thinking, subagent_thinking=subagent_thinking,
acp_section=acp_and_mounts_section, acp_section=acp_and_mounts_section,
ordinary_qa_format_section=ordinary_qa_format_section, ordinary_qa_format_section=ordinary_qa_format_section,
) )
prompt = prompt + rag_mode_section + _current_time_context() prompt = prompt + rag_mode_section + _current_time_context()
@@ -226,6 +226,15 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
"InternalServerError", "InternalServerError",
"ReadError", # httpx.ReadError: connection dropped mid-stream "ReadError", # httpx.ReadError: connection dropped mid-stream
"RemoteProtocolError", # httpx: server closed connection unexpectedly "RemoteProtocolError", # httpx: server closed connection unexpectedly
# httpx.TimeoutException family escapes the streaming path unwrapped
# (seen live: httpx.ReadTimeout mid-generation after 200 OK — the
# provider stalled ~17s; that must retry, not fail the whole run).
"ReadTimeout",
"ConnectTimeout",
"WriteTimeout",
"PoolTimeout",
"TimeoutError", # asyncio chunk timeout / builtin timeout
"ConnectError", # httpx: connection refused / DNS blip
}: }:
return True, "transient" return True, "transient"
if status_code in _RETRIABLE_STATUS_CODES: if status_code in _RETRIABLE_STATUS_CODES:
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more