Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code
This commit is contained in:
commit
99f1ca6029
187 files changed
+24191
-1924
No files matched your search
+44
-26
@@ -87,39 +87,57 @@ offline-backend-20260512/backend/skill_evolution.json
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||
# Deep-research reliability regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||
# Deep-research reliability regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||
# Independent enterprise-research persistence, HTTP and race regressions.
|
||||
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
||||
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
||||
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
||||
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
||||
deploy
|
||||
# Roundtable coordinator/seat capability isolation regressions.
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
|
||||
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
|
||||
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
|
||||
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_engine.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
||||
# Writing-mode first-report pipeline enforcement regressions.
|
||||
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
|
||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||
# LLM call timeout retry classification regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
||||
# Writing-mode pipeline conversation-material regressions.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
# Cloned third-party repos (local only)
|
||||
|
||||
@@ -80,6 +80,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
@@ -73,6 +73,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
@@ -104,6 +106,9 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
@@ -184,6 +189,7 @@ const ChatBox: React.FC<{
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
openStreamedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
@@ -398,9 +404,12 @@ const ChatBox: React.FC<{
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||
selectArtifact(newest);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
}
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
@@ -432,8 +441,30 @@ const ChatBox: React.FC<{
|
||||
setArtifacts,
|
||||
availableArtifacts,
|
||||
disableArtifacts,
|
||||
thread.messages,
|
||||
writingModeEnabled,
|
||||
]);
|
||||
|
||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||
// the first content bytes — not only when that tool is the last CoT step.
|
||||
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||
const streamingWritePreview = useMemo(
|
||||
() =>
|
||||
disableArtifacts || !thread.isLoading
|
||||
? null
|
||||
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
|
||||
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!streamingWritePreview) return;
|
||||
openStreamedArtifact(
|
||||
streamingWritePreview.artifactPath,
|
||||
streamingWritePreview.previewKey,
|
||||
);
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, streamingWritePreview]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
|
||||
@@ -19,17 +19,16 @@ const TOOLBOX_ICON_TONES = [
|
||||
* 简洁模式右侧工具箱。
|
||||
*
|
||||
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
|
||||
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
|
||||
* 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
|
||||
* 收起后右侧出现竖排按钮,可再次展开。
|
||||
*/
|
||||
export function CompactToolbox() {
|
||||
const { toolboxItems } = useWorkspaceCompactNavItems();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => setOpen(false), [pathname]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -41,7 +40,6 @@ export function CompactToolbox() {
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
closeRef.current?.focus();
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
@@ -73,22 +71,8 @@ export function CompactToolbox() {
|
||||
<span className="text-xs font-semibold leading-4">工具箱</span>
|
||||
</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
|
||||
id="compact-toolbox-panel"
|
||||
role="dialog"
|
||||
aria-modal={open}
|
||||
aria-label="工具箱"
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
@@ -102,10 +86,9 @@ export function CompactToolbox() {
|
||||
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
|
||||
<h2 className="text-sm font-semibold">工具箱</h2>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
aria-label="关闭工具箱"
|
||||
title="关闭"
|
||||
aria-label="收起工具箱"
|
||||
title="收起"
|
||||
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"
|
||||
>
|
||||
@@ -131,10 +114,7 @@ export function CompactToolbox() {
|
||||
? "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",
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate(item.path ?? "/page/workspace/chats/new");
|
||||
}}
|
||||
onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
@@ -51,12 +52,13 @@ import {
|
||||
resolveSearchQuery,
|
||||
type SearchResultItem,
|
||||
} from "@/core/messages/search-results";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -92,7 +95,7 @@ export function MessageGroup({
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
@@ -100,12 +103,19 @@ export function MessageGroup({
|
||||
const [showLastThinking, setShowLastThinking] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const { skills } = useSkills();
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式的「拦截 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(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -197,11 +207,11 @@ export function MessageGroup({
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||||
@@ -209,10 +219,10 @@ export function MessageGroup({
|
||||
key={lastToolCallStep.id}
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
</ChainOfThoughtContent>
|
||||
@@ -754,7 +764,6 @@ function ArtifactWriteToolCall({
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -793,19 +802,20 @@ function ArtifactWriteToolCall({
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
result !== undefined ||
|
||||
!sourcePath ||
|
||||
!languageOfStreamingArtifact(sourcePath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
sourcePath,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -847,7 +857,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.type === "ai") {
|
||||
@@ -865,6 +875,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
if (tool_call.name === "task") {
|
||||
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 = {
|
||||
id: tool_call.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 { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
@@ -36,26 +34,26 @@ export function WorkspaceSidebar({
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
@@ -123,10 +121,10 @@ export function WorkspaceSidebar({
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
|
||||
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
|
||||
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
|
||||
全量模式本就不展示。 */}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -1,7 +1,246 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
|
||||
import {
|
||||
isWriteFileInFlight,
|
||||
latestInFlightWriteFilePreview,
|
||||
resolveWriteFilePath,
|
||||
} from "./write-file-in-flight";
|
||||
|
||||
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 标题\n",
|
||||
},
|
||||
},
|
||||
{ id: "p1", name: "present_files", args: { paths: ["/mnt/user-data/outputs/report.md"] } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m1:w1");
|
||||
assert.match(preview?.artifactPath ?? "", /write-file:/);
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview keeps markdown when a later json write is in flight", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 标题\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/task-reports.json",
|
||||
content: "[{",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m1:w1");
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown in writing mode", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
|
||||
content: "# 草稿\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
assert.equal(latestInFlightWriteFilePreview(messages, true), null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
|
||||
content: "# 草稿\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
|
||||
const preview = latestInFlightWriteFilePreview(
|
||||
[
|
||||
{ type: "human", content: "写一篇论文" },
|
||||
{
|
||||
id: "m-pipe",
|
||||
type: "ai",
|
||||
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
|
||||
},
|
||||
{
|
||||
id: "m-write",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 我自己写\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
|
||||
const preview = latestInFlightWriteFilePreview(
|
||||
[
|
||||
{
|
||||
id: "research-report-j1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "research-report-j1-write",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 报告\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
});
|
||||
|
||||
test("writing mode still previews follow-up-turn writes after a later user message", () => {
|
||||
// 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。
|
||||
const preview = latestInFlightWriteFilePreview(
|
||||
[
|
||||
{ type: "human", content: "写一篇论文" },
|
||||
{
|
||||
id: "m-pipe",
|
||||
type: "ai",
|
||||
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
|
||||
},
|
||||
{
|
||||
id: "m-result",
|
||||
type: "tool",
|
||||
tool_call_id: "p1",
|
||||
name: "deep_research_report",
|
||||
content: "研究报告已完成:已写入 report.md 并展示给用户。",
|
||||
},
|
||||
{ type: "human", content: "把第二章改写得更学术一些" },
|
||||
{
|
||||
id: "m-edit",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w2",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 修订后的报告\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m-edit:w2");
|
||||
});
|
||||
|
||||
test("normal mode keeps previewing pathless streaming drafts", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/task-reports.json",
|
||||
content: "[{}]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview skips write_file that already has a tool result", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } },
|
||||
],
|
||||
},
|
||||
{ type: "tool", tool_call_id: "w1" },
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("resolveWriteFilePath reads the virtual URL pathname", () => {
|
||||
assert.equal(
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
|
||||
import { isImageFile } from "../utils/files";
|
||||
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "./streaming-write-preview";
|
||||
|
||||
export function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
@@ -27,8 +36,12 @@ export function resolveWriteFilePath(filepath: string): string {
|
||||
|
||||
type WriteFileThreadLike = {
|
||||
messages: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
content?: unknown;
|
||||
tool_call_id?: string;
|
||||
additional_kwargs?: Record<string, unknown>;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -75,3 +88,67 @@ export function isWriteFileInFlight(
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest in-flight `write_file` that the sandbox can live-preview.
|
||||
* Scans all messages (not just the last CoT step) so a write that is
|
||||
* followed by `present_files` / bash in the same turn still opens the panel
|
||||
* while arguments stream in — same idea as position-roundtable's
|
||||
* `latestStreamingWriteFile`.
|
||||
*/
|
||||
export function latestInFlightWriteFilePreview(
|
||||
messages: WriteFileThreadLike["messages"],
|
||||
writingMode = false,
|
||||
): { artifactPath: string; previewKey: string } | null {
|
||||
const thread = { messages };
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (message?.type !== "ai" || !message.id || !message.tool_calls) {
|
||||
continue;
|
||||
}
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "write_file" || !toolCall.id) {
|
||||
continue;
|
||||
}
|
||||
if (hasToolResult(thread, toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as { path?: unknown; content?: unknown } | undefined;
|
||||
const rawPath = typeof args?.path === "string" ? args.path : "";
|
||||
const content = typeof args?.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
if (!sourcePath) {
|
||||
continue;
|
||||
}
|
||||
// Writing mode rewrites custom outputs/*.md to deep_research_report.
|
||||
// Opening that filename would 404-poll until report.md lands. Normal
|
||||
// mode never skips: the write is a real deliverable, not a rewrite.
|
||||
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
|
||||
continue;
|
||||
}
|
||||
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
|
||||
// switching the sandbox onto it freezes the main thread, then the rest
|
||||
// of the turn appears to dump at once. Keep showing markdown instead.
|
||||
if (!languageOfStreamingArtifact(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
artifactPath: buildWriteFileVirtualUrl({
|
||||
path: sourcePath,
|
||||
messageId: message.id,
|
||||
toolCallId: toolCall.id,
|
||||
}),
|
||||
previewKey: `${message.id}:${toolCall.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* embed-session.ts 浏览上下文隔离单测。
|
||||
* 运行方式(项目无 vitest):
|
||||
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
|
||||
* node --test .tmp-tests/embed-session.test.mjs
|
||||
@@ -82,14 +81,11 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(isChromeHidden(), true);
|
||||
assert.ok(storage.getItem("embed.session.nested"));
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
@@ -97,11 +93,9 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
|
||||
test("top ignores old nested embed.session without clearing it", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
@@ -113,15 +107,14 @@ test("top parent reclaims pollution from an old nested iframe (no topOwned)", ()
|
||||
isolate: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
// 内层老版仍要读到 embed=true
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("top sync after old nested pollution does not mark owner", () => {
|
||||
test("top sync does not wipe unowned embed.session", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
@@ -137,12 +130,11 @@ test("top sync after old nested pollution does not mark owner", () => {
|
||||
);
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("nested iframe reclaims a polluted shared embed.session key", () => {
|
||||
test("nested iframe leaves the shared embed.session for old inner pages", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
@@ -159,11 +151,11 @@ test("nested iframe reclaims a polluted shared embed.session key", () => {
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
assert.ok(storage.getItem("embed.session.nested"));
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,31 +176,7 @@ test("nested iframe keeps the top window's own embed session", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("nested reclaim strips embed but keeps top isolate", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: true,
|
||||
}),
|
||||
);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test("top reclaim strips embed but keeps isolate without nested help", () => {
|
||||
test("top ignore strips chrome in memory but keeps isolate", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
@@ -224,5 +192,6 @@ test("top reclaim strips embed but keeps isolate without nested help", () => {
|
||||
);
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
});
|
||||
});
|
||||
@@ -16,34 +16,17 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
|
||||
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
|
||||
*
|
||||
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏;
|
||||
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。
|
||||
*
|
||||
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响):
|
||||
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage
|
||||
* (跨标签共享 → 会污染正常页)。
|
||||
* - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、
|
||||
* 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。
|
||||
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
|
||||
* localStorage 偏好。
|
||||
*
|
||||
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
|
||||
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
|
||||
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
|
||||
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
|
||||
* 路由看起来却没变。因此:
|
||||
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
|
||||
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
|
||||
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
|
||||
* 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。
|
||||
* 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。
|
||||
* **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时
|
||||
* 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。
|
||||
* 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome
|
||||
* (见 `isHostNestedSurfacePath`),不依赖这份会话。
|
||||
*/
|
||||
|
||||
const EMBED_SESSION_KEY = "embed.session";
|
||||
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
|
||||
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
|
||||
/**
|
||||
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
|
||||
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
|
||||
*/
|
||||
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
|
||||
|
||||
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
|
||||
@@ -61,7 +44,6 @@ function getSessionStorage(): Storage | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
|
||||
function isTopLevelWindow(): boolean {
|
||||
const w = getWindow();
|
||||
if (!w) return true;
|
||||
@@ -78,28 +60,14 @@ function embedSessionKey(): string {
|
||||
|
||||
export type EmbedTheme = "light" | "dark";
|
||||
|
||||
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
|
||||
export interface EmbedSession {
|
||||
/** 嵌入模式:去全局 chrome + 强制主题。 */
|
||||
embed: boolean;
|
||||
/** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */
|
||||
theme: EmbedTheme;
|
||||
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
|
||||
hideJump: boolean;
|
||||
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
|
||||
hideHeader: boolean;
|
||||
/**
|
||||
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
|
||||
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
|
||||
* 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。
|
||||
*/
|
||||
hideChrome: boolean;
|
||||
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
|
||||
isolate: boolean;
|
||||
/**
|
||||
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
|
||||
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
|
||||
*/
|
||||
/** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */
|
||||
topOwned?: boolean;
|
||||
}
|
||||
|
||||
@@ -142,49 +110,12 @@ function emptySession(): EmbedSession {
|
||||
};
|
||||
}
|
||||
|
||||
function stripChromeFlags(session: EmbedSession): EmbedSession {
|
||||
return {
|
||||
...session,
|
||||
embed: false,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
topOwned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function persistTopSession(next: EmbedSession): void {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(EMBED_SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const payload: EmbedSession = {
|
||||
embed: next.embed,
|
||||
theme: next.theme,
|
||||
hideJump: next.hideJump,
|
||||
hideHeader: next.hideHeader,
|
||||
hideChrome: next.hideChrome,
|
||||
isolate: next.isolate,
|
||||
};
|
||||
if (next.topOwned) payload.topOwned = true;
|
||||
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function isTopChromeOwned(session?: EmbedSession | null): boolean {
|
||||
const w = getWindow();
|
||||
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
|
||||
return session?.topOwned === true;
|
||||
}
|
||||
|
||||
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
|
||||
function markTopChromeOwner(session: EmbedSession): void {
|
||||
const w = getWindow();
|
||||
if (!w || !isTopLevelWindow()) return;
|
||||
@@ -192,31 +123,25 @@ function markTopChromeOwner(session: EmbedSession): void {
|
||||
w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
|
||||
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
|
||||
*/
|
||||
function reclaimUnownedTopChrome(): void {
|
||||
if (!isTopLevelWindow()) return;
|
||||
function readStoredSession(): EmbedSession | null {
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur || !sessionHidesChrome(cur)) return;
|
||||
if (isTopChromeOwned(cur)) {
|
||||
const w = getWindow();
|
||||
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
return;
|
||||
}
|
||||
persistTopSession(stripChromeFlags(cur));
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
|
||||
} catch {
|
||||
/* noop */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readSharedTopSession(): EmbedSession | null {
|
||||
try {
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(EMBED_SESSION_KEY) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
|
||||
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
|
||||
* 旧版嵌套页写过共用键时,新版嵌套页复制一份到 nested 键自己用;**不删除**共用键,
|
||||
* 避免老版内层丢掉 embed 后把导航栏放出来。
|
||||
*/
|
||||
function migrateLegacySharedSessionIfNested(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
@@ -230,30 +155,12 @@ function migrateLegacySharedSessionIfNested(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimPollutedTopChrome(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
try {
|
||||
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur) return;
|
||||
// 顶层已认领(内存标记或 session.topOwned)则不动。
|
||||
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
|
||||
persistTopSession(stripChromeFlags(cur));
|
||||
} catch {
|
||||
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
|
||||
}
|
||||
}
|
||||
|
||||
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
|
||||
type FlagParam = "on" | "off" | null;
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
}
|
||||
|
||||
/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */
|
||||
function readFlagParam(search: string, key: string): FlagParam {
|
||||
const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase();
|
||||
if (!v) return null;
|
||||
@@ -262,32 +169,26 @@ function readFlagParam(search: string, key: string): FlagParam {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `embed` 三态。 */
|
||||
export function readEmbedParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "embed");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `hideJump` 三态。 */
|
||||
export function readHideJumpParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideJump");
|
||||
}
|
||||
|
||||
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
|
||||
export function readHideHeaderParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideHeader");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
|
||||
export function readHideChromeParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideChrome");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
|
||||
export function readIsolateParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "isolate");
|
||||
}
|
||||
|
||||
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
|
||||
export function readEmbedThemeParam(search: string): string {
|
||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||
}
|
||||
@@ -301,34 +202,32 @@ function searchEnablesChromeHide(search: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
|
||||
/**
|
||||
* 读本浏览上下文的嵌入会话。顶层若看到无主(非 topOwned)的 chrome 标志,视为同源
|
||||
* 嵌套页写的,**读取时忽略、不写回**,以免清掉内层老版仍在用的 `embed.session`。
|
||||
*/
|
||||
export function readEmbedSession(): EmbedSession | null {
|
||||
try {
|
||||
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
|
||||
reclaimUnownedTopChrome();
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
const s = readStoredSession();
|
||||
if (!s) return null;
|
||||
if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) {
|
||||
return {
|
||||
...s,
|
||||
embed: false,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
topOwned: false,
|
||||
};
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||
function writeEmbedSession(next: EmbedSession): void {
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const key = embedSessionKey();
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const payload: EmbedSession = {
|
||||
let payload: EmbedSession = {
|
||||
embed: next.embed,
|
||||
theme: next.theme,
|
||||
hideJump: next.hideJump,
|
||||
@@ -337,37 +236,56 @@ function writeEmbedSession(next: EmbedSession): void {
|
||||
isolate: next.isolate,
|
||||
};
|
||||
if (next.topOwned) payload.topOwned = true;
|
||||
|
||||
// 顶层未认领时,写 isolate 等字段不得覆盖内层老版仍占用的 chrome 标志。
|
||||
if (
|
||||
isTopLevelWindow() &&
|
||||
key === EMBED_SESSION_KEY &&
|
||||
!payload.topOwned &&
|
||||
getWindow()?.[TOP_CHROME_OWNER_FLAG] !== true
|
||||
) {
|
||||
const shared = readSharedTopSession();
|
||||
if (shared && sessionHidesChrome(shared) && !shared.topOwned) {
|
||||
payload = {
|
||||
...payload,
|
||||
embed: shared.embed,
|
||||
theme: shared.theme,
|
||||
hideJump: shared.hideJump,
|
||||
hideHeader: shared.hideHeader,
|
||||
hideChrome: shared.hideChrome,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!payload.embed &&
|
||||
!payload.hideJump &&
|
||||
!payload.hideHeader &&
|
||||
!payload.hideChrome &&
|
||||
!payload.isolate
|
||||
) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
store.setItem(key, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */
|
||||
export function isEmbedActive(): boolean {
|
||||
return readEmbedSession()?.embed === true;
|
||||
}
|
||||
|
||||
/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */
|
||||
export function isJumpBarHidden(): boolean {
|
||||
return readEmbedSession()?.hideJump === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tab should hide its global Header.
|
||||
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
|
||||
* deliberately leaves PageSidebar visible.
|
||||
*/
|
||||
export function isHeaderHidden(): boolean {
|
||||
const session = readEmbedSession();
|
||||
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
|
||||
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
|
||||
* 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。
|
||||
*/
|
||||
export function isChromeHidden(): boolean {
|
||||
const s = readEmbedSession();
|
||||
return s?.embed === true || s?.hideChrome === true;
|
||||
@@ -377,28 +295,38 @@ export function isHideChromeActive(): boolean {
|
||||
return readEmbedSession()?.hideChrome === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
|
||||
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
|
||||
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
|
||||
*/
|
||||
export function isIsolatedSession(): boolean {
|
||||
return readEmbedSession()?.isolate === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
|
||||
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
|
||||
* 之前置位的开关不会被抹掉。
|
||||
*
|
||||
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
|
||||
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
|
||||
*/
|
||||
export function syncEmbedSessionFromSearch(search: string): void {
|
||||
migrateLegacySharedSessionIfNested();
|
||||
if (isTopLevelWindow()) reclaimUnownedTopChrome();
|
||||
if (!isTopLevelWindow()) {
|
||||
migrateLegacySharedSessionIfNested();
|
||||
applySearchToSession(search, readStoredSession() ?? emptySession(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = readEmbedSession() ?? emptySession();
|
||||
const stored = readStoredSession() ?? emptySession();
|
||||
if (searchEnablesChromeHide(search)) {
|
||||
applySearchToSession(search, stored, true);
|
||||
return;
|
||||
}
|
||||
if (isTopChromeOwned(stored)) {
|
||||
applySearchToSession(search, stored, true);
|
||||
return;
|
||||
}
|
||||
// 无主共用键:只处理 isolate URL,绝不把 chrome 标志写成 false 覆盖内层。
|
||||
const isolateParam = readIsolateParam(search);
|
||||
if (isolateParam === null) return;
|
||||
const isolate = isolateParam === "on";
|
||||
writeEmbedSession({ ...stored, isolate, topOwned: false });
|
||||
}
|
||||
|
||||
function applySearchToSession(
|
||||
search: string,
|
||||
cur: EmbedSession,
|
||||
topOwned: boolean,
|
||||
): void {
|
||||
const next: EmbedSession = { ...cur };
|
||||
|
||||
const embedParam = readEmbedParam(search);
|
||||
@@ -425,27 +353,11 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
if (isolateParam === "on") next.isolate = true;
|
||||
else if (isolateParam === "off") next.isolate = false;
|
||||
|
||||
if (isTopLevelWindow()) {
|
||||
if (searchEnablesChromeHide(search)) {
|
||||
next.topOwned = true;
|
||||
} else if (!isTopChromeOwned(next)) {
|
||||
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
|
||||
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
|
||||
} else {
|
||||
next.topOwned = true;
|
||||
}
|
||||
}
|
||||
|
||||
next.topOwned = topOwned;
|
||||
writeEmbedSession(next);
|
||||
if (isTopLevelWindow()) markTopChromeOwner(next);
|
||||
else reclaimPollutedTopChrome();
|
||||
if (topOwned) markTopChromeOwner(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。
|
||||
* 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入,
|
||||
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
|
||||
*/
|
||||
function currentHashSearch(): string {
|
||||
const w = getWindow();
|
||||
if (!w) return "";
|
||||
@@ -454,18 +366,10 @@ function currentHashSearch(): string {
|
||||
return i >= 0 ? hash.slice(i) : "";
|
||||
}
|
||||
|
||||
/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */
|
||||
export function syncEmbedSessionFromCurrentUrl(): void {
|
||||
syncEmbedSessionFromSearch(currentHashSearch());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用:
|
||||
* - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁);
|
||||
* - URL 显式 `embed=0` → 不强制(undefined);
|
||||
* - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。
|
||||
* 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。
|
||||
*/
|
||||
export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined {
|
||||
const param = readEmbedParam(search);
|
||||
if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search));
|
||||
@@ -474,12 +378,7 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
|
||||
return session?.embed ? session.theme : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
|
||||
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
|
||||
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
|
||||
*/
|
||||
export function setIsolatedSession(on: boolean): void {
|
||||
const cur = readEmbedSession() ?? emptySession();
|
||||
writeEmbedSession({ ...cur, isolate: on });
|
||||
const stored = readStoredSession() ?? emptySession();
|
||||
writeEmbedSession({ ...stored, isolate: on });
|
||||
}
|
||||
@@ -56,6 +56,61 @@ test("write_file tool after 用户协助 opens a processing group instead of err
|
||||
]);
|
||||
});
|
||||
|
||||
test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => {
|
||||
const types = groupTypes([
|
||||
{ type: "human", id: "h1", content: "写一篇论文" } as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }],
|
||||
} as Message,
|
||||
{
|
||||
type: "tool",
|
||||
id: "t1",
|
||||
name: "deep_research_report",
|
||||
content: "ok",
|
||||
tool_call_id: "c1",
|
||||
} as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "progress",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } },
|
||||
],
|
||||
} as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "write",
|
||||
content: "",
|
||||
tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }],
|
||||
} as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "summary",
|
||||
content: "已生成多智能体论文,详见 report.md。",
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
|
||||
});
|
||||
|
||||
test("a lone deep-research progress bar still opens its own group", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
type: "ai",
|
||||
id: "progress",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } },
|
||||
],
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["assistant:processing"]);
|
||||
});
|
||||
|
||||
test("tool messages still attach to an open processing group", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
|
||||
@@ -166,14 +166,19 @@ export function groupMessages<T>(
|
||||
messages: [message],
|
||||
});
|
||||
} else if (hasOnlyDeepResearchProgress(message)) {
|
||||
// Each deep-research writing bar is its own group. Merging consecutive
|
||||
// processing messages would pin a new report's in-progress steps onto
|
||||
// the parent bar, which sits above the original file card.
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:processing",
|
||||
messages: [message],
|
||||
});
|
||||
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
|
||||
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
|
||||
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
|
||||
lastGroup.messages.push(message);
|
||||
} else {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:processing",
|
||||
messages: [message],
|
||||
});
|
||||
}
|
||||
} else if (hasSubagent(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@@ -426,6 +431,14 @@ export function hasOnlyDeepResearchProgress(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
function groupHasDeepResearchReport(group: MessageGroup): boolean {
|
||||
return group.messages.some(
|
||||
(message) =>
|
||||
message.type === "ai" &&
|
||||
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
buildResearchProgressToolCalls,
|
||||
buildResearchReportVirtualUrl,
|
||||
composeResearchDisplayMessages,
|
||||
clearResearchReportStream,
|
||||
finalizeResearchReportStream,
|
||||
ingestResearchReportEvent,
|
||||
@@ -139,15 +140,33 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
|
||||
sourceCount: 3,
|
||||
});
|
||||
const calls = buildResearchProgressToolCalls(active);
|
||||
assert.equal(calls.length, 2); // 拆解已完成 + 检索进行中
|
||||
assert.equal(calls.length, 2); // 梳理思路已完成 + 整理资料进行中
|
||||
assert.equal(calls[0].args.status, "complete");
|
||||
assert.equal(calls[0].args.label, "梳理写作思路");
|
||||
assert.equal(calls[1].args.status, "active");
|
||||
assert.equal(calls[1].args.label, "整理对话资料");
|
||||
assert.match(calls[1].args.detail, /1\/2/);
|
||||
|
||||
const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" });
|
||||
assert.equal(buildResearchProgressToolCalls(writing).length, 3);
|
||||
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
|
||||
|
||||
const completedByPhase = streamOf({ jobId: "j7b", phase: "done", reportText: "# x" });
|
||||
const completedCalls = buildResearchProgressToolCalls(completedByPhase);
|
||||
assert.equal(completedCalls.length, 3);
|
||||
assert.ok(completedCalls.every((call) => call.args.status === "complete"));
|
||||
assert.match(completedCalls[2].args.detail, /report\.md/);
|
||||
|
||||
const completedByStatus = streamOf({
|
||||
jobId: "j7c",
|
||||
phase: "writing",
|
||||
status: "success",
|
||||
reportText: "# x",
|
||||
});
|
||||
assert.ok(
|
||||
buildResearchProgressToolCalls(completedByStatus).every((call) => call.args.status === "complete"),
|
||||
);
|
||||
|
||||
const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
|
||||
const failedCalls = buildResearchProgressToolCalls(failed);
|
||||
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
|
||||
@@ -157,15 +176,73 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
|
||||
test("feed messages attach the write_file stub only once report text exists", () => {
|
||||
const noText = streamOf({ jobId: "j9", phase: "collecting" });
|
||||
assert.equal(buildResearchFeedMessages(noText).length, 1);
|
||||
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress");
|
||||
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls?.[0]?.name, "deep_research_progress");
|
||||
|
||||
const writingEmpty = streamOf({ jobId: "j9b", phase: "writing" });
|
||||
assert.equal(buildResearchFeedMessages(writingEmpty).length, 1);
|
||||
|
||||
const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
|
||||
const feed = buildResearchFeedMessages(withText);
|
||||
assert.equal(feed.length, 2);
|
||||
const stub = feed[1].tool_calls[0];
|
||||
assert.equal(stub.name, "write_file");
|
||||
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
|
||||
assert.equal(stub.args.content, "# 报告正文");
|
||||
const stub = feed[1].tool_calls?.[0];
|
||||
assert.equal(stub?.name, "write_file");
|
||||
assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
|
||||
assert.equal(stub?.args.content, "# 报告正文");
|
||||
|
||||
const done = streamOf({ jobId: "j10b", phase: "done", reportText: "# 报告正文" });
|
||||
const doneFeed = buildResearchFeedMessages(done);
|
||||
assert.equal(doneFeed.length, 3);
|
||||
assert.equal(doneFeed[2].type, "tool");
|
||||
assert.equal(doneFeed[2].tool_call_id, "research-report-j10b-write");
|
||||
});
|
||||
|
||||
test("report_completed flips phase to done and write step completes before tool_finished", () => {
|
||||
const tid = nextThread();
|
||||
ingestAll(tid, [
|
||||
{ type: "report_delta", jobId: "jdone", payload: { delta: "正文", offset: 0 } },
|
||||
{ type: "report_completed", jobId: "jdone", payload: { sourceCount: 2 } },
|
||||
]);
|
||||
const state = peekResearchReportStream(tid);
|
||||
assert.equal(state?.phase, "done");
|
||||
assert.equal(state?.status, "active");
|
||||
const calls = buildResearchProgressToolCalls(state!);
|
||||
assert.ok(calls.every((call) => call.args.status === "complete"));
|
||||
});
|
||||
|
||||
test("composeResearchDisplayMessages inserts the bar before the summary and drops leaked prose", () => {
|
||||
const feed = buildResearchFeedMessages(streamOf({
|
||||
jobId: "jsplice",
|
||||
phase: "done",
|
||||
reportText: "# 报告",
|
||||
}));
|
||||
const composed = composeResearchDisplayMessages(
|
||||
[
|
||||
{ id: "h", type: "human", content: "写论文" },
|
||||
{
|
||||
id: "ai-tool",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }],
|
||||
},
|
||||
{ id: "leak", type: "ai", content: "# 报告\n\n泄漏到消息列表的正文" },
|
||||
{ id: "tool", type: "tool", tool_call_id: "c1", content: "ok" },
|
||||
{ id: "summary", type: "ai", content: "已生成论文,详见 report.md。" },
|
||||
],
|
||||
feed,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
composed.map((message) => message.id),
|
||||
[
|
||||
"h",
|
||||
"ai-tool",
|
||||
"tool",
|
||||
"research-progress-jsplice",
|
||||
"research-report-jsplice",
|
||||
"research-report-jsplice-write-result",
|
||||
"summary",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
|
||||
|
||||
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
|
||||
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
|
||||
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
|
||||
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
|
||||
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
|
||||
* finalize/clear 立即 flush,结束态不滞后。
|
||||
* 10 次通知,流式观感不受影响。终态帧(tool_finished /
|
||||
* report_completed)与 finalize/clear 立即 flush,结束态不滞后。
|
||||
*/
|
||||
const EMIT_COALESCE_MS = 100;
|
||||
|
||||
@@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
|
||||
case "report_completed":
|
||||
next = {
|
||||
...base,
|
||||
phase: "writing",
|
||||
// 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
|
||||
phase: "done",
|
||||
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
|
||||
? payload.sourceCount
|
||||
: base.sourceCount,
|
||||
@@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
|
||||
if (next !== previous) {
|
||||
store.state = next;
|
||||
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
|
||||
if (frame.type === "tool_finished") {
|
||||
if (frame.type === "tool_finished" || frame.type === "report_completed") {
|
||||
emitNow(store);
|
||||
} else {
|
||||
emitCoalesced(store);
|
||||
@@ -280,33 +281,41 @@ export type ResearchProgressToolCall = {
|
||||
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
|
||||
const rank = phaseRank(state.phase);
|
||||
const finished = state.status !== "active";
|
||||
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
|
||||
// success 时必须用 length(越过 write 的 boundary),length-1 永远完不成最后一步。
|
||||
// report_completed 会先把 phase 置 done,此时工具可能还没返回,也视为写完。
|
||||
const succeeded =
|
||||
state.status === "success" || (state.status === "active" && state.phase === "done");
|
||||
const effectiveRank = succeeded ? PHASE_ORDER.length : rank;
|
||||
const stages = [
|
||||
{
|
||||
id: "plan",
|
||||
label: "拆解检索问题",
|
||||
// 写作管线不检索:只按课题拆论述角度、汇总对话已收集的资料再成稿。
|
||||
// 措辞避免「拆解检索问题/检索参考资料」,用户会误会管线在重新检索。
|
||||
label: "梳理写作思路",
|
||||
boundary: phaseRank("collecting"),
|
||||
detail: state.queries.length > 0
|
||||
? `已拆出 ${state.queries.length} 个检索子问题`
|
||||
: "正在围绕课题拆解检索子问题",
|
||||
? `已梳理出 ${state.queries.length} 个论述角度`
|
||||
: "正在围绕课题梳理写作思路",
|
||||
},
|
||||
{
|
||||
id: "collect",
|
||||
label: "检索参考资料",
|
||||
label: "整理对话资料",
|
||||
boundary: phaseRank("compressing"),
|
||||
detail: state.queries.length > 0
|
||||
? `已检索 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个子问题 · 累计 ${state.sourceCount} 条资料`
|
||||
? `已整理 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个论述角度的资料 · 累计 ${state.sourceCount} 条`
|
||||
: state.sourceCount > 0
|
||||
? `已检索到 ${state.sourceCount} 条资料`
|
||||
: "正在逐个子问题检索资料",
|
||||
? `已汇总 ${state.sourceCount} 条对话资料`
|
||||
: "正在汇总对话中已收集的资料",
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
label: "撰写研究报告",
|
||||
boundary: PHASE_ORDER.length,
|
||||
detail: state.reportText
|
||||
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
|
||||
: "正在整理资料并开始撰写报告",
|
||||
detail: succeeded
|
||||
? "已按「通用分析结构」写完并保存为 report.md"
|
||||
: state.reportText
|
||||
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
|
||||
: "正在整理资料并开始撰写报告",
|
||||
},
|
||||
];
|
||||
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
|
||||
@@ -330,19 +339,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
|
||||
|
||||
export type ResearchFeedMessage = {
|
||||
id: string;
|
||||
type: "ai";
|
||||
type: "ai" | "tool";
|
||||
content: string;
|
||||
tool_calls: {
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}[];
|
||||
};
|
||||
|
||||
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
|
||||
return state.status === "success" || state.phase === "done";
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
|
||||
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
|
||||
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
|
||||
* 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
|
||||
* (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
|
||||
* 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
|
||||
*/
|
||||
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
|
||||
const messages: ResearchFeedMessage[] = [
|
||||
@@ -353,23 +368,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
|
||||
tool_calls: buildResearchProgressToolCalls(state),
|
||||
},
|
||||
];
|
||||
if (state.reportText.trim()) {
|
||||
// 必须等正文首 token:思考阶段挂 stub 会提前显示「写入文件 report.md」。
|
||||
if (!state.reportText.trim()) {
|
||||
return messages;
|
||||
}
|
||||
const writeCallId = `research-report-${state.jobId}-write`;
|
||||
messages.push({
|
||||
id: `research-report-${state.jobId}`,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: writeCallId,
|
||||
name: "write_file",
|
||||
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (isResearchWriteFinished(state)) {
|
||||
messages.push({
|
||||
id: `research-report-${state.jobId}`,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: `research-report-${state.jobId}-write`,
|
||||
name: "write_file",
|
||||
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
|
||||
},
|
||||
],
|
||||
id: `research-report-${state.jobId}-write-result`,
|
||||
type: "tool",
|
||||
name: "write_file",
|
||||
tool_call_id: writeCallId,
|
||||
content: "ok",
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function isDeepResearchReportAi(message: { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }): boolean {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report"))
|
||||
);
|
||||
}
|
||||
|
||||
function deepResearchReportCallIds(message: { tool_calls?: Array<{ name?: string; id?: string }> }): Set<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 消息读流式内容)。 */
|
||||
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
|
||||
return new URL(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
/**
|
||||
* 聊天页「写作模式」深度研究进度注入。
|
||||
*
|
||||
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
|
||||
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
|
||||
* 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
|
||||
* 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
|
||||
*
|
||||
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
|
||||
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
|
||||
@@ -26,13 +26,16 @@ import {
|
||||
artifactPreviewKey,
|
||||
useArtifactsOptional,
|
||||
} from "@/components/workspace/artifacts/context";
|
||||
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
||||
|
||||
import {
|
||||
buildResearchFeedMessages,
|
||||
buildResearchReportVirtualUrl,
|
||||
composeResearchDisplayMessages,
|
||||
RESEARCH_REPORT_OUTPUT_PATH,
|
||||
useResearchReportStream,
|
||||
} from "./use-research-report-stream";
|
||||
import { isInterceptedWritingReportWrite } from "./writing-mode";
|
||||
|
||||
type FeedThreadLike = {
|
||||
messages: Message[];
|
||||
@@ -55,10 +58,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
||||
[thread.values?.artifacts],
|
||||
);
|
||||
|
||||
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
|
||||
// 预览,报告在沙箱里边写边流式渲染。
|
||||
// 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
|
||||
// 避免「写入文件 report.md」出现在模型还在想的时候。
|
||||
useEffect(() => {
|
||||
if (!research || research.status !== "active" || !research.reportText.trim()) {
|
||||
if (
|
||||
!research ||
|
||||
research.status !== "active" ||
|
||||
research.phase !== "writing" ||
|
||||
!research.reportText.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (autoOpenedJobRef.current === research.jobId) return;
|
||||
@@ -77,7 +85,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
||||
if (!artifacts) return;
|
||||
handedOverJobRef.current = research.jobId;
|
||||
const current = artifacts.selectedArtifact;
|
||||
if (current !== null && current !== buildResearchReportVirtualUrl(research)) {
|
||||
const virtualUrl = buildResearchReportVirtualUrl(research);
|
||||
const currentPath = current ? resolveWriteFilePath(current) : "";
|
||||
const keepUserSelection =
|
||||
current !== null &&
|
||||
current !== virtualUrl &&
|
||||
current !== RESEARCH_REPORT_OUTPUT_PATH &&
|
||||
// research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
|
||||
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
|
||||
if (keepUserSelection) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
|
||||
@@ -91,11 +107,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
|
||||
}, [hasRealArtifact, research]);
|
||||
|
||||
const displayThread = useMemo(() => {
|
||||
if (feedMessages.length === 0) return thread;
|
||||
const messages = [
|
||||
...thread.messages,
|
||||
...(feedMessages as unknown as Message[]),
|
||||
];
|
||||
const messages = composeResearchDisplayMessages(
|
||||
thread.messages,
|
||||
feedMessages as unknown as Message[],
|
||||
);
|
||||
if (messages === thread.messages) return thread;
|
||||
return {
|
||||
...thread,
|
||||
values: { ...thread.values, messages },
|
||||
|
||||
@@ -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
|
||||
hideHeaderSettings
|
||||
newTaskLabel="新建会话"
|
||||
step3Mode="report-only"
|
||||
hideDefaultStep1RightSidebar
|
||||
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
|
||||
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
|
||||
|
||||
@@ -45,6 +45,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import {
|
||||
Select,
|
||||
@@ -612,16 +613,13 @@ export default function AgentChatPage({
|
||||
} as typeof displayThread;
|
||||
}, [displayThread, showReportImportCard, taskId, threadId]);
|
||||
|
||||
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
|
||||
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
|
||||
// ChatPage 的回传逻辑对齐)。
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
return [...(thread.values.artifacts ?? [])]
|
||||
.reverse()
|
||||
.find((path) => isMarkdown(path));
|
||||
}, [thread.values.artifacts]);
|
||||
// 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
|
||||
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
|
||||
// .md 不得跳过管线(与 ChatPage 对齐)。
|
||||
const writingArtifactPath = useMemo(
|
||||
() => resolveWritingModeReportPath(thread.values.artifacts),
|
||||
[thread.values.artifacts],
|
||||
);
|
||||
|
||||
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
|
||||
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
|
||||
@@ -354,16 +355,11 @@ export default function ChatPage() {
|
||||
researchDisplayThread,
|
||||
]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
if (isMarkdown(selectedArtifact)) {
|
||||
return selectedArtifact ?? undefined;
|
||||
}
|
||||
return [...(thread.values.artifacts ?? [])]
|
||||
.reverse()
|
||||
.find((path) => isMarkdown(path));
|
||||
}, [selectedArtifact, thread.values.artifacts]);
|
||||
const writingArtifactPath = useMemo(
|
||||
() =>
|
||||
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
|
||||
[selectedArtifact, thread.values.artifacts],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Clock,
|
||||
DownloadIcon,
|
||||
MessageSquare,
|
||||
PenTool,
|
||||
Pin,
|
||||
PinOff,
|
||||
Search,
|
||||
Star,
|
||||
Users2,
|
||||
type LucideIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
@@ -42,9 +50,70 @@ import {
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import { useResearchSessions } from "@/strategy-components/api/deep-research";
|
||||
|
||||
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
|
||||
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
|
||||
import { ResearchHistoryTab } from "./chats-history/ResearchHistoryTab";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 「对话历史」页的四个历史 Tab:对话(通用问答/智能体会话)之外,把多智能体
|
||||
* 会商、AI写作、深度研究的记录也收纳进来(简洁模式侧栏不再展示后两者的入口,
|
||||
* 统一从这里查看)。
|
||||
*/
|
||||
type HistoryTab = "chats" | "roundtable" | "writing" | "research";
|
||||
type ChatFilter = "all" | "favorites" | "pinned";
|
||||
|
||||
const HISTORY_TABS: {
|
||||
id: HistoryTab;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}[] = [
|
||||
{
|
||||
id: "chats",
|
||||
label: "对话",
|
||||
description: "普通问答与智能体会话",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
id: "roundtable",
|
||||
label: "多智能体会商",
|
||||
description: "多角色协作研讨",
|
||||
icon: Users2,
|
||||
},
|
||||
{
|
||||
id: "writing",
|
||||
label: "AI写作",
|
||||
description: "写作任务与草稿",
|
||||
icon: PenTool,
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
label: "深度研究",
|
||||
description: "研究任务与报告",
|
||||
icon: Compass,
|
||||
},
|
||||
];
|
||||
|
||||
/** 非对话 Tab 的搜索占位文案(对话 Tab 沿用 i18n 的 t.chats.searchChats)。 */
|
||||
const TAB_SEARCH_PLACEHOLDER: Record<Exclude<HistoryTab, "chats">, string> = {
|
||||
roundtable: "搜索会商记录…",
|
||||
writing: "搜索写作记录…",
|
||||
research: "搜索研究记录…",
|
||||
};
|
||||
|
||||
const TAB_TOTAL_UNIT: Record<HistoryTab, string> = {
|
||||
chats: "条对话",
|
||||
roundtable: "条会商记录",
|
||||
writing: "篇写作",
|
||||
research: "项研究",
|
||||
};
|
||||
|
||||
export default function ChatsPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
@@ -54,16 +123,28 @@ export default function ChatsPage({
|
||||
const { t } = useI18n();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
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(() => {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
}, [t.pages.chats, t.pages.appName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRoundtableTabDrafts();
|
||||
void refreshWritingTabSessions();
|
||||
}, [refreshRoundtableTabDrafts, refreshWritingTabSessions]);
|
||||
|
||||
// Debounce the search box so each keystroke doesn't fire a backend query.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
@@ -73,26 +154,41 @@ export default function ChatsPage({
|
||||
// Reset to the first page whenever the filtered result set changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, onlyFavorites]);
|
||||
}, [debouncedSearch, chatFilter]);
|
||||
|
||||
// 切换 Tab 时回到第一页并清空旧 Tab 上报的计数。
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setTabTotal(null);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleTabTotalChange = useCallback((total: number) => {
|
||||
setTabTotal(total);
|
||||
}, []);
|
||||
|
||||
// Conversations are paginated on the backend: each page is one bounded
|
||||
// query (title search + favorites filter included), so a long history is
|
||||
// never fetched in one go. Pinned conversations come from their own small
|
||||
// query and are surfaced in a dedicated section on the first page only,
|
||||
// and dropped from the paginated rows so they don't repeat on every page.
|
||||
const favoriteFilter = useMemo(
|
||||
() => (onlyFavorites ? { is_favorite: true } : {}),
|
||||
[onlyFavorites],
|
||||
const threadFilter = useMemo(
|
||||
() =>
|
||||
chatFilter === "favorites"
|
||||
? { is_favorite: true }
|
||||
: chatFilter === "pinned"
|
||||
? { is_pinned: true }
|
||||
: {},
|
||||
[chatFilter],
|
||||
);
|
||||
const pinnedFilter = useMemo(
|
||||
() => ({ ...favoriteFilter, is_pinned: true }),
|
||||
[favoriteFilter],
|
||||
() => ({ ...threadFilter, is_pinned: true }),
|
||||
[threadFilter],
|
||||
);
|
||||
const { data: pageData } = useThreadsPage({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
metadata: threadFilter,
|
||||
});
|
||||
const { data: pinnedData } = useThreadsPage({
|
||||
page: 1,
|
||||
@@ -102,13 +198,28 @@ export default function ChatsPage({
|
||||
});
|
||||
const { data: totalCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
metadata: threadFilter,
|
||||
});
|
||||
const { data: allThreadsCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: {},
|
||||
});
|
||||
const { data: favoriteThreadsCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: { is_favorite: true },
|
||||
});
|
||||
const { data: pinnedThreadsCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: { is_pinned: true },
|
||||
});
|
||||
|
||||
const pinnedThreads = pinnedData?.threads ?? [];
|
||||
const pinnedThreads = chatFilter === "pinned" ? [] : (pinnedData?.threads ?? []);
|
||||
const pagedThreads = useMemo(
|
||||
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
|
||||
[pageData?.threads],
|
||||
() =>
|
||||
(pageData?.threads ?? []).filter(
|
||||
(thread) => chatFilter === "pinned" || !isThreadPinned(thread),
|
||||
),
|
||||
[chatFilter, pageData?.threads],
|
||||
);
|
||||
const currentPage = page;
|
||||
const hasMore = pageData?.hasMore ?? false;
|
||||
@@ -123,46 +234,67 @@ export default function ChatsPage({
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
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 && (
|
||||
<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))}
|
||||
</span>
|
||||
</div>
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{/* Row actions — kept out of the Link so clicks don't navigate. */}
|
||||
<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">
|
||||
{thread.updated_at && (
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"
|
||||
size="icon-sm"
|
||||
className="size-8"
|
||||
title={pinned ? t.common.unpin : t.common.pin}
|
||||
aria-label={pinned ? t.common.unpin : t.common.pin}
|
||||
onClick={() => togglePin(thread.thread_id, !pinned)}
|
||||
>
|
||||
{pinned ? (
|
||||
@@ -177,6 +309,15 @@ export default function ChatsPage({
|
||||
};
|
||||
|
||||
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 (
|
||||
<WorkspaceContainer>
|
||||
@@ -192,93 +333,280 @@ export default function ChatsPage({
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={onlyFavorites ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setOnlyFavorites(false)}
|
||||
<div className="relative w-full max-w-5xl px-4 sm:px-6">
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<div
|
||||
id="primary-tabs-nav"
|
||||
className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
|
||||
role="tablist"
|
||||
aria-label="历史记录类型"
|
||||
>
|
||||
{t.chats.allChats}
|
||||
</Button>
|
||||
<Button
|
||||
variant={onlyFavorites ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setOnlyFavorites(true)}
|
||||
>
|
||||
<Star className="size-4" />
|
||||
{t.chats.favorites}
|
||||
</Button>
|
||||
{HISTORY_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
const count = historyTabCounts[tab.id];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`history-tab-${tab.id}`}
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.threadShare.importChats}
|
||||
</Button>
|
||||
{activeTab === "chats" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.threadShare.importChats}
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
<main className="min-h-0 flex-1">
|
||||
<ScrollArea className="size-full py-4">
|
||||
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
||||
{currentPage === 1 && pinnedThreads.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t.chats.pinnedSection}
|
||||
<ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
|
||||
<section
|
||||
id={`history-panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
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>
|
||||
{pinnedThreads.map(renderThread)}
|
||||
</>
|
||||
)}
|
||||
{pagedThreads.map(renderThread)}
|
||||
{isEmpty && (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
||||
</div>
|
||||
)}
|
||||
{(currentPage > 1 || hasMore) && (
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t.chats.prevPage}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{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 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">
|
||||
共 <strong className="font-bold text-red-700">{displayTotal}</strong>{" "}
|
||||
{TAB_TOTAL_UNIT[activeTab]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
{activeTab === "chats" && (
|
||||
<div className="space-y-3 pt-5">
|
||||
{currentPage === 1 && pinnedThreads.map(renderThread)}
|
||||
{pagedThreads.map(renderThread)}
|
||||
{isEmpty && (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
{chatFilter === "favorites"
|
||||
? t.chats.noFavorites
|
||||
: chatFilter === "pinned"
|
||||
? "暂无置顶对话"
|
||||
: t.chats.noChats}
|
||||
</div>
|
||||
)}
|
||||
{(currentPage > 1 || hasMore) && (
|
||||
<div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t.chats.prevPage}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{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>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
useThreadStream,
|
||||
} from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -291,16 +292,11 @@ export default function IframeChatPage() {
|
||||
threadMetadataAgentId,
|
||||
]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
if (isMarkdown(selectedArtifact)) {
|
||||
return selectedArtifact ?? undefined;
|
||||
}
|
||||
return [...(thread.values.artifacts ?? [])]
|
||||
.reverse()
|
||||
.find((path) => isMarkdown(path));
|
||||
}, [selectedArtifact, thread.values.artifacts]);
|
||||
const writingArtifactPath = useMemo(
|
||||
() =>
|
||||
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
|
||||
[selectedArtifact, thread.values.artifacts],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
readHideHeaderParam,
|
||||
readHideChromeParam,
|
||||
} from "@/core/embed/embed-session";
|
||||
import { isHostNestedSurfacePath } from "@/core/page-layout/host-nested-surface";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
|
||||
@@ -91,23 +92,20 @@ function PageLayout() {
|
||||
location.pathname.startsWith("/page/workspace/chats/")
|
||||
);
|
||||
}, [location.pathname, location.search]);
|
||||
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
|
||||
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
|
||||
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
|
||||
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
|
||||
const forceChromeForDashboardAgent =
|
||||
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
|
||||
// 深链嵌套页(单智能体问答 / 多智能体会商 / 大屏绘制)只给宿主 iframe 套用,
|
||||
// 路径上就不带全局 Header / PageSidebar,不靠改写共用 embed.session。
|
||||
const hostNestedSurface = isHostNestedSurfacePath(location.pathname);
|
||||
const pageSidebarAllowed =
|
||||
(forceChromeForDashboardAgent || chromeByPreference) &&
|
||||
chromeByPreference &&
|
||||
!isChatsIframeWorkspacePath(location.pathname) &&
|
||||
!embedActive &&
|
||||
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
|
||||
!chromeHidden;
|
||||
!chromeHidden &&
|
||||
!hostNestedSurface;
|
||||
const compactToolboxAllowed =
|
||||
!chromeByPreference &&
|
||||
!forceChromeForDashboardAgent &&
|
||||
!embedActive &&
|
||||
!chromeHidden &&
|
||||
!hostNestedSurface &&
|
||||
!isChatsIframeWorkspacePath(location.pathname);
|
||||
|
||||
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
|
||||
|
||||
@@ -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 { useSearchParams } from "react-router-dom";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Select as TSelect } from "tdesign-react";
|
||||
@@ -444,7 +445,12 @@ export default function DeepResearchWorkbench() {
|
||||
}
|
||||
|
||||
function DeepResearchWorkbenchInner() {
|
||||
const [selectedId, setSelectedId] = useState<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 [generateImages, setGenerateImages] = useState(false);
|
||||
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
|
||||
|
||||
@@ -28,8 +28,9 @@ import type { JobStatus } from "@/roundtable-planning/lib/job-types";
|
||||
export interface DraftStep1Snapshot {
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
interactiveMessages: unknown[];
|
||||
timelineEntries: unknown[];
|
||||
/** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底。 */
|
||||
interactiveMessages?: unknown[];
|
||||
timelineEntries?: unknown[];
|
||||
}
|
||||
|
||||
export interface DraftStep2Snapshot {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
|
||||
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
@@ -254,8 +255,7 @@ export interface StreamMultiAgentRequest {
|
||||
*/
|
||||
synthesisMode?: boolean;
|
||||
/**
|
||||
* 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage
|
||||
* 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。
|
||||
* 仅为兼容旧协议保留。总控 prompt 属于总控私有上下文,前端恒传 true,后端也会无条件隔离。
|
||||
*/
|
||||
suppressPreBroadcast?: boolean;
|
||||
/**
|
||||
@@ -291,12 +291,14 @@ export interface StreamMultiAgentRequest {
|
||||
* show progressive status messages during the inevitable first-token wait.
|
||||
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||||
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
|
||||
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
|
||||
* 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。
|
||||
* `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。
|
||||
*/
|
||||
onPhaseChange?: (
|
||||
phase: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
toolCallId?: string,
|
||||
) => void;
|
||||
/**
|
||||
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
|
||||
@@ -468,7 +470,9 @@ function isFinalStatusFrame(value: unknown): value is {
|
||||
* structured content blocks. Returns null when the frame is not an AI text
|
||||
* chunk (e.g., tool calls, tool results, value snapshots).
|
||||
*/
|
||||
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||
function extractAiTextDelta(
|
||||
parsed: unknown,
|
||||
): { content: string; reasoning: string; id: string } | null {
|
||||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||
const chunk = parsed[0];
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
@@ -491,9 +495,18 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
const additionalKwargs = obj.additional_kwargs;
|
||||
const directReasoning = obj.reasoning_content ?? obj.reasoning;
|
||||
const nestedReasoning =
|
||||
additionalKwargs && typeof additionalKwargs === "object"
|
||||
? (additionalKwargs as Record<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 : "";
|
||||
return { text, id };
|
||||
return { content: text, reasoning, id };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -532,8 +545,8 @@ export async function streamMultiAgent(
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
|
||||
synthesis_mode: req.synthesisMode ?? false,
|
||||
// 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。
|
||||
suppress_pre_broadcast: req.suppressPreBroadcast ?? false,
|
||||
// 总控 prompt 永不进入席位 thread;后端同样强制执行,避免旧调用方破坏角色隔离。
|
||||
suppress_pre_broadcast: req.suppressPreBroadcast ?? true,
|
||||
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
|
||||
parallel_no_file: req.parallelNoFile ?? false,
|
||||
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
|
||||
@@ -620,27 +633,36 @@ export async function streamMultiAgent(
|
||||
let currentPhase: StreamPhase = "connecting";
|
||||
let currentPhaseName = "";
|
||||
let currentPhaseArgsSignature = "";
|
||||
let currentPhaseToolCallId = "";
|
||||
req.onPhaseChange?.("connecting");
|
||||
// The normal chat renders provider reasoning from a distinct message field.
|
||||
// Roundtable's bespoke SSE reader keeps that field inline so its existing
|
||||
// `<think>` renderer remains the single display path for Steps 2 and 3.
|
||||
const inlineAiDelta = createInlineReasoningDeltaFormatter();
|
||||
const setPhase = (
|
||||
next: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
toolCallId?: string,
|
||||
) => {
|
||||
const nextName = name ?? "";
|
||||
const nextArgsSignature = phaseArgsSignature(args);
|
||||
const nextToolCallId = toolCallId ?? "";
|
||||
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
|
||||
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
|
||||
if (
|
||||
next === currentPhase &&
|
||||
nextName === currentPhaseName &&
|
||||
nextArgsSignature === currentPhaseArgsSignature
|
||||
nextArgsSignature === currentPhaseArgsSignature &&
|
||||
nextToolCallId === currentPhaseToolCallId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentPhase = next;
|
||||
currentPhaseName = nextName;
|
||||
currentPhaseArgsSignature = nextArgsSignature;
|
||||
req.onPhaseChange?.(next, name, args);
|
||||
currentPhaseToolCallId = nextToolCallId;
|
||||
req.onPhaseChange?.(next, name, args, toolCallId);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -782,6 +804,7 @@ export async function streamMultiAgent(
|
||||
const toolCalls = chunkObj.tool_calls;
|
||||
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||
const content = chunkObj.content;
|
||||
const streamDelta = extractAiTextDelta(parsed);
|
||||
|
||||
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
||||
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
||||
@@ -812,27 +835,34 @@ export async function streamMultiAgent(
|
||||
if (chunkType === "tool") {
|
||||
setPhase("tool_result");
|
||||
} else if (hasToolCall) {
|
||||
// Pick the most informative name + args we have so far.
|
||||
// Pick the active (latest) call's name + args. A leader can dispatch
|
||||
// several seats with the same `agent_orchestration` tool in one run;
|
||||
// choosing the first accumulated call would leave the UI stuck on an
|
||||
// earlier assignment until the final response arrives.
|
||||
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
|
||||
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
|
||||
const firstCall =
|
||||
const activeCall =
|
||||
effectiveToolCalls && effectiveToolCalls.length > 0
|
||||
? (effectiveToolCalls[0] as Record<string, unknown>)
|
||||
? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
|
||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName =
|
||||
firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
activeCall && typeof activeCall.name === "string" ? activeCall.name : "";
|
||||
const args =
|
||||
firstCall &&
|
||||
typeof firstCall.args === "object" &&
|
||||
firstCall.args !== null
|
||||
? (firstCall.args as Record<string, unknown>)
|
||||
activeCall &&
|
||||
typeof activeCall.args === "object" &&
|
||||
activeCall.args !== null
|
||||
? (activeCall.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
setPhase("tool_calling", toolName, args);
|
||||
const toolCallId =
|
||||
activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined;
|
||||
setPhase("tool_calling", toolName, args, toolCallId);
|
||||
} else if (
|
||||
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||
(Boolean(streamDelta) ||
|
||||
(typeof content === "string" && content.length > 0) ||
|
||||
(Array.isArray(content) && content.length > 0))
|
||||
) {
|
||||
setPhase("streaming");
|
||||
}
|
||||
@@ -900,8 +930,9 @@ export async function streamMultiAgent(
|
||||
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
const text = inlineAiDelta(delta);
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
|
||||
if (text) req.onTextDelta?.(text, delta.id);
|
||||
}
|
||||
|
||||
req.onUpstreamFrame?.(parsed);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
|
||||
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
|
||||
*/
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ClockIcon,
|
||||
@@ -593,8 +593,9 @@ function ChainSeatsFlow({
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<Fragment key={stageIdx}>
|
||||
<div className="contents" key={stageIdx}>
|
||||
{stageIdx > 0 && (
|
||||
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
@@ -609,7 +610,7 @@ function ChainSeatsFlow({
|
||||
) : (
|
||||
seatTag(stageIds[0])
|
||||
)}
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用
|
||||
* `stripReportFence` 替换成一句提示。
|
||||
*/
|
||||
import { useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
@@ -64,7 +64,19 @@ export function DashboardChatPanel({
|
||||
}: DashboardChatPanelProps) {
|
||||
const inputLocked = phase === "creating";
|
||||
const scrollRef = useRef<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 speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
|
||||
@@ -156,7 +168,8 @@ export function DashboardChatPanel({
|
||||
|
||||
{/* 聊天记录 */}
|
||||
<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 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
|
||||
@@ -209,6 +222,7 @@ export function DashboardChatPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!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"
|
||||
/>
|
||||
{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
|
||||
type="submit"
|
||||
disabled={inputLocked || text.trim().length === 0}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
|
||||
* (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 { Button } from "@/components/ui/button";
|
||||
@@ -229,9 +229,10 @@ export function DispatchChainModal({
|
||||
state={coordinatorState}
|
||||
/>
|
||||
|
||||
{/* 席位节点 */}
|
||||
{/* 席位节点(div.contents 而非 <Fragment>:dev 期 tagger 会往具名 Fragment 注入
|
||||
data-lov-id 触发 React 警告,contents 不生成盒子、布局不变)。 */}
|
||||
{nodes.map((node) => (
|
||||
<Fragment key={node.agentId}>
|
||||
<div className="contents" key={node.agentId}>
|
||||
<Connector
|
||||
active={node.state === "active"}
|
||||
done={node.state === "done"}
|
||||
@@ -242,7 +243,7 @@ export function DispatchChainModal({
|
||||
state={node.state}
|
||||
order={node.order}
|
||||
/>
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 共识里程碑 */}
|
||||
|
||||
@@ -61,6 +61,8 @@ interface SeatDelivery {
|
||||
interface HighFidelityReportProps {
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
/** `report-only` is used by 8BF; normal roundtables retain every Step 3 view. */
|
||||
step3Mode?: "full" | "report-only";
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
/** The most recent leader-agent text — rendered as the final consensus. */
|
||||
@@ -121,9 +123,26 @@ function formatGeneratedAt(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 最终成果区打开的是已落盘文件,不走实时流;独立给工具栏一个稳定的文件类型徽标。 */
|
||||
function artifactTypeLabel(fileName: string): string {
|
||||
const normalized = fileName.trim().toLowerCase();
|
||||
if (/\.(md|markdown)$/.test(normalized)) return "MD";
|
||||
if (/\.html?$/.test(normalized)) return "HTML";
|
||||
if (/\.(mmd|mermaid)$/.test(normalized)) return "MMD";
|
||||
if (/\.json$/.test(normalized)) return "JSON";
|
||||
if (/\.csv$/.test(normalized)) return "CSV";
|
||||
if (/\.ya?ml$/.test(normalized)) return "YAML";
|
||||
if (/\.tsx?$/.test(normalized)) return "TS";
|
||||
if (/\.js$/.test(normalized)) return "JS";
|
||||
if (/\.py$/.test(normalized)) return "PY";
|
||||
if (/\.txt$/.test(normalized)) return "TXT";
|
||||
return "FILE";
|
||||
}
|
||||
|
||||
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
step3Mode = "full",
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -156,7 +175,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
|
||||
const hasFullStep3Outputs = step3Mode === "full";
|
||||
// 第 3 步主页为总结报告。普通会商可继续打开最终方案、经典可视化和大屏多页;
|
||||
// 8BF 明确停在报告工作台,不启动或露出这些后续交付。
|
||||
const [summaryMode, setSummaryMode] = useState(true);
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
|
||||
@@ -398,6 +419,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
const fileSandboxVirtualUrl = fileSandboxTarget
|
||||
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
|
||||
: "";
|
||||
const fileSandboxTypeLabel = fileSandboxTarget
|
||||
? artifactTypeLabel(fileSandboxTarget.file.name)
|
||||
: "FILE";
|
||||
const fileSandboxThreadStub = useMemo(() => {
|
||||
const messages =
|
||||
fileSandboxTarget && fileSandboxContent != null
|
||||
@@ -414,6 +438,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
// 最终产物仅借用 write-file 虚拟 URL 来复用预览器;内容已经通过下载接口完整读回,
|
||||
// 不是流式写入。因此要同时提供 tool result,避免 ArtifactFileDetail 误显示「正在生成」。
|
||||
{
|
||||
id: "hfr-file-tool-result",
|
||||
type: "tool",
|
||||
content: "",
|
||||
tool_call_id: "tc-hfr-0",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
@@ -434,7 +466,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
if (summaryMode) {
|
||||
if (summaryMode || !hasFullStep3Outputs) {
|
||||
return (
|
||||
<Step3SummaryMode
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
@@ -448,16 +480,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
summary={summaryFlow}
|
||||
enableFlow={hasFullStep3Outputs}
|
||||
onViewStatic={hasFullStep3Outputs ? () => setSummaryMode(false) : undefined}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (drawMode) {
|
||||
if (drawMode && hasFullStep3Outputs) {
|
||||
return (
|
||||
<Step3ReportMode
|
||||
threadIds={threadIds}
|
||||
@@ -1043,21 +1078,26 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
allowVirtualMarkdownEdit
|
||||
headerLeadingActionBare
|
||||
headerLeadingAction={(
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}) ?? {
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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">
|
||||
{fileSandboxTypeLabel}
|
||||
</span>
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}) ?? {
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getStepDisplay,
|
||||
@@ -177,13 +178,16 @@ function ToolStep({
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
||||
const [, toolName] = step.key.split(":");
|
||||
// 只让「自己生成/展示的文件」可点(write_file / str_replace / present_files),
|
||||
// 与主聊天页一致。read_file 不可点 —— 否则智能体调用技能时读取的 SOUL.md、
|
||||
// 执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。
|
||||
// 步骤条是工具活动的实时视图:所有 write_file 都应能打开沙箱,包括为后续执行
|
||||
// 而创建的 .py/.txt 等过程文件。消息气泡下的最终文件卡片才收紧为用户可交付类型。
|
||||
// read_file 仍不可点,避免技能/SOUL 等输入文件被误当作产物。
|
||||
// str_replace 沿用原有的文档预览范围(它没有完整写入内容可供虚拟沙箱读取)。
|
||||
const stepPath = typeof step.args?.path === "string" ? step.args.path : "";
|
||||
const isArtifactStep =
|
||||
toolName === "present_files" ||
|
||||
toolName === "write_file" ||
|
||||
toolName === "str_replace" ||
|
||||
toolName === "present_files";
|
||||
(toolName === "str_replace" &&
|
||||
collectRoundtableDeliverablePaths([stepPath]).length > 0);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
|
||||
@@ -130,6 +130,11 @@ export interface Step1PanelProps {
|
||||
footerAction?: React.ReactNode;
|
||||
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
|
||||
hideEnterRoundtable?: boolean;
|
||||
/**
|
||||
* 隐藏「继续追问」「跳过此项」两个快捷动作(多智能体会商页不需要;
|
||||
* 岗位会商 / 8BF 不传保持原样)。
|
||||
*/
|
||||
hideFollowupActions?: boolean;
|
||||
/**
|
||||
* 仅替换输入区的渲染插槽。消息列表、意图状态及附件状态仍由 Step1Panel
|
||||
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
|
||||
@@ -171,6 +176,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
enterButtonLabel = "进入多智能体研讨 >",
|
||||
footerAction,
|
||||
hideEnterRoundtable = false,
|
||||
hideFollowupActions = false,
|
||||
inputArea,
|
||||
inputDraftKey,
|
||||
initialInputText = "",
|
||||
@@ -425,23 +431,27 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
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" />
|
||||
继续追问
|
||||
</button>
|
||||
{!hideFollowupActions && (
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
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" />
|
||||
继续追问
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="skip-question-btn"
|
||||
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} />
|
||||
跳过此项
|
||||
</button>
|
||||
{!hideFollowupActions && (
|
||||
<button
|
||||
id="skip-question-btn"
|
||||
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} />
|
||||
跳过此项
|
||||
</button>
|
||||
)}
|
||||
|
||||
{footerAction ??
|
||||
(!hideEnterRoundtable && (
|
||||
|
||||
@@ -43,6 +43,7 @@ const SHOW_BACKGROUND_SUSPEND = true;
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
|
||||
|
||||
@@ -221,8 +222,42 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
} = props;
|
||||
|
||||
const step2ScrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||
// contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在
|
||||
// 状态更新之后才继续长高,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。
|
||||
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
|
||||
@@ -264,8 +299,8 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
|
||||
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
||||
useEffect(() => {
|
||||
scrollStep2ToBottom();
|
||||
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]);
|
||||
forceStep2ToBottom();
|
||||
}, [forceStep2ToBottom, isParallelRunning, selectedParallelSeats]);
|
||||
|
||||
const resolveAgentName = useCallback(
|
||||
(idOrName: string) =>
|
||||
@@ -601,14 +636,15 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
|
||||
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4",
|
||||
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden",
|
||||
useStep1InputAppearance
|
||||
? "eightbf-roundtable-step2-log"
|
||||
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
|
||||
)}
|
||||
>
|
||||
<div ref={step2ContentRef} className="flex min-h-full flex-col space-y-4 p-6">
|
||||
{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" />
|
||||
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
|
||||
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
|
||||
@@ -704,6 +740,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!step2AtBottom && (
|
||||
<ScrollToBottomButton
|
||||
@@ -848,6 +885,8 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
onAddFiles={addInjectFiles}
|
||||
onRemoveFile={removeInjectFile}
|
||||
onSend={handleInject}
|
||||
busy={isStep2Responding}
|
||||
onPause={onPauseRoundtable}
|
||||
hasConsensus={hasConsensus}
|
||||
onProceed={onProceedToStep3}
|
||||
/>
|
||||
@@ -888,6 +927,8 @@ function Step2LegacyComposer({
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
onSend,
|
||||
busy,
|
||||
onPause,
|
||||
hasConsensus,
|
||||
onProceed,
|
||||
}: {
|
||||
@@ -897,6 +938,9 @@ function Step2LegacyComposer({
|
||||
onAddFiles: (files: File[]) => void;
|
||||
onRemoveFile: (index: number) => void;
|
||||
onSend: () => void;
|
||||
/** 编排中或席位仍在流式输出 —— 此时显示输入框「暂停」键(与第一步输入框一致)。 */
|
||||
busy: boolean;
|
||||
onPause: () => void;
|
||||
hasConsensus: boolean;
|
||||
onProceed: () => void;
|
||||
}) {
|
||||
@@ -958,14 +1002,19 @@ function Step2LegacyComposer({
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
title="记录干预指令并立即恢复会商执行"
|
||||
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"
|
||||
>
|
||||
干预并发送
|
||||
</button>
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Pause className="size-3" />
|
||||
暂停
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
@@ -1037,7 +1086,7 @@ export function Step2Message({
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||
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");
|
||||
// 传了 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 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
|
||||
* 文件就出可点的卡片",而不是只有 present_files 才出。
|
||||
* 成品文件就出可点的卡片"。
|
||||
*
|
||||
* write_file/str_replace 的 path 需过同一套交付物门槛(只认 .md/.html/
|
||||
* .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套):
|
||||
* 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。
|
||||
*/
|
||||
function collectPresentedFiles(
|
||||
steps: Step2Dialogue["steps"],
|
||||
settledArtifactPaths?: 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 seen = new Set<string>();
|
||||
const add = (fp: unknown) => {
|
||||
const add = (fp: unknown, requirePreviewable = false) => {
|
||||
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
|
||||
if (requirePreviewable && collectRoundtableDeliverablePaths([fp]).length === 0) return;
|
||||
seen.add(fp);
|
||||
out.push(fp);
|
||||
};
|
||||
for (const step of steps) {
|
||||
for (const step of toolSteps) {
|
||||
if (step.key === "tool_calling:present_files") {
|
||||
const fps = step.args?.filepaths;
|
||||
if (Array.isArray(fps)) fps.forEach(add);
|
||||
if (Array.isArray(fps)) fps.forEach((fp) => add(fp));
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
step.key === "tool_calling:write_file" ||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,25 @@ export function Step2SandboxLayout({
|
||||
}, [artifacts, isStreaming, seatMessages]);
|
||||
|
||||
const previewThreadId = activeThreadId ?? "";
|
||||
const selectedArtifactTarget = useMemo(() => {
|
||||
const target = {
|
||||
threadId: previewThreadId,
|
||||
path: selectedArtifact ?? "",
|
||||
content: selectedArtifactContent,
|
||||
};
|
||||
return taskArtifactSubmission?.targetFor(target) ?? target;
|
||||
}, [
|
||||
previewThreadId,
|
||||
selectedArtifact,
|
||||
selectedArtifactContent,
|
||||
taskArtifactSubmission,
|
||||
]);
|
||||
// 8BF 等接入任务过程产物提交的页面,用提交按钮替换文件类型;其余普通/任务深链
|
||||
// 会商仍保留原有文件类型徽标。非 Markdown 产物本身不能提交,也应显示类型。
|
||||
const showTaskArtifactSubmit = Boolean(
|
||||
taskArtifactSubmission?.enabled
|
||||
&& taskArtifactSubmission.isMarkdown(selectedArtifactTarget),
|
||||
);
|
||||
|
||||
const artifactPanelBody = (
|
||||
<div
|
||||
@@ -162,18 +181,14 @@ export function Step2SandboxLayout({
|
||||
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
|
||||
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
|
||||
disableDesensitize
|
||||
headerLeadingActionBare
|
||||
headerLeadingAction={(
|
||||
headerLeadingAction={showTaskArtifactSubmit ? (
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: previewThreadId,
|
||||
path: selectedArtifact,
|
||||
content: selectedArtifactContent,
|
||||
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
|
||||
target={selectedArtifactTarget}
|
||||
/>
|
||||
)}
|
||||
) : undefined}
|
||||
headerLeadingActionBare={showTaskArtifactSubmit}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Step3PanelProps {
|
||||
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
/** 普通会商提供完整交付;8BF 仅保留 Markdown 总结报告。 */
|
||||
step3Mode?: "full" | "report-only";
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
@@ -65,6 +67,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
const {
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
step3Mode = "full",
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -115,6 +118,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
<HighFidelityReport
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
headerTitle={headerTitle}
|
||||
step3Mode={step3Mode}
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
|
||||
@@ -388,6 +388,7 @@ export function Step3ReportMode({
|
||||
error={error}
|
||||
themeMismatch={themeMismatch}
|
||||
onRetry={() => redraw()}
|
||||
onPause={pause}
|
||||
onRevisionSubmit={interject}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
@@ -513,6 +514,7 @@ function AgentProgressPanel({
|
||||
error,
|
||||
themeMismatch,
|
||||
onRetry,
|
||||
onPause,
|
||||
onRevisionSubmit,
|
||||
onArtifactStepClick,
|
||||
onArtifactPreview,
|
||||
@@ -527,6 +529,8 @@ function AgentProgressPanel({
|
||||
error: string | null;
|
||||
themeMismatch: boolean;
|
||||
onRetry: () => void;
|
||||
/** 输入框「暂停」键 —— 绘制中显示,中断在途请求并把当前气泡收尾为「已暂停」。 */
|
||||
onPause: () => void;
|
||||
onRevisionSubmit: (revisionInstruction: string) => void;
|
||||
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
|
||||
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
|
||||
@@ -541,7 +545,21 @@ function AgentProgressPanel({
|
||||
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
|
||||
const inputLocked = phase === "creating";
|
||||
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。
|
||||
const [localSettings] = useLocalSettings();
|
||||
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||
@@ -601,8 +619,9 @@ function AgentProgressPanel({
|
||||
<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"
|
||||
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 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
|
||||
@@ -674,6 +693,7 @@ function AgentProgressPanel({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
<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"
|
||||
/>
|
||||
{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
|
||||
type="submit"
|
||||
disabled={inputLocked || revisionText.trim().length === 0}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createPortal } from "react-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -42,6 +44,12 @@ import {
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
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 { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
@@ -74,6 +82,7 @@ interface Step3SummaryModeProps {
|
||||
intentReady: ReportIntent | null;
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
@@ -81,6 +90,10 @@ interface Step3SummaryModeProps {
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
|
||||
summary: UseStep3SummaryReturn;
|
||||
/** 普通会商保留流程图交付;8BF 为 false。 */
|
||||
enableFlow?: boolean;
|
||||
/** 普通会商进入完整的最终方案展示;8BF 不提供该入口。 */
|
||||
onViewStatic?: () => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
@@ -98,10 +111,13 @@ export function Step3SummaryMode({
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
summary,
|
||||
enableFlow = false,
|
||||
onViewStatic,
|
||||
taskArtifactSubmission,
|
||||
}: Step3SummaryModeProps) {
|
||||
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
|
||||
@@ -135,6 +151,36 @@ export function Step3SummaryMode({
|
||||
|
||||
const { open: artifactsOpen } = useArtifacts();
|
||||
|
||||
// 普通会商的结构化流程图:优先使用后端抽取+校验的结果,兼容旧草稿里 flow-json。
|
||||
const flowGraph = useMemo(() => {
|
||||
if (!enableFlow) return null;
|
||||
if (summary.flowPhase === "done" && summary.flowGraph) {
|
||||
return { graph: summary.flowGraph, streaming: false };
|
||||
}
|
||||
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
|
||||
return null;
|
||||
}
|
||||
for (let index = dialogues.length - 1; index >= 0; index -= 1) {
|
||||
const dialogue = dialogues[index];
|
||||
if (dialogue.avatarType === "human") continue;
|
||||
const graph = extractFlowGraph(dialogue.content ?? "");
|
||||
if (graph && (dialogue.content ?? "").includes(FLOW_FENCE_OPEN)) {
|
||||
return { graph, streaming: dialogue.streaming === true };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [dialogues, enableFlow, summary.flowGraph, summary.flowPhase]);
|
||||
|
||||
const [flowView, setFlowView] = useState(false);
|
||||
const flowAutoOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!enableFlow || !flowGraph || flowGraph.graph.nodes.length === 0) return;
|
||||
if (flowAutoOpenedRef.current) return;
|
||||
flowAutoOpenedRef.current = true;
|
||||
// 自动生成的图优先打开;若用户已在报告沙箱内,保留当前阅读位置并露出切换入口。
|
||||
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
|
||||
}, [artifactsOpen, enableFlow, flowGraph, summary.autoFlow]);
|
||||
|
||||
// 沙箱里已写出的 Markdown 总结报告。
|
||||
const mdArtifact = useMemo(() => {
|
||||
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
|
||||
@@ -156,16 +202,22 @@ export function Step3SummaryMode({
|
||||
return null;
|
||||
}, [seatStubMessages]);
|
||||
|
||||
const handleAsk = (message: string, files?: File[]) => ask(message, files);
|
||||
const handleAsk = (message: string, files?: File[]) => {
|
||||
if (enableFlow && detectFlowIntent(message)) {
|
||||
flowAutoOpenedRef.current = true;
|
||||
setFlowView(true);
|
||||
}
|
||||
ask(message, files);
|
||||
};
|
||||
|
||||
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
|
||||
// 保持与第二步一致的流式过程可见性。
|
||||
const displayDialogues = dialogues;
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
// 右侧沙箱或流程图露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
|
||||
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
|
||||
const rightPanelVisible = artifactsOpen;
|
||||
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
||||
const prevRightPanelVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
||||
@@ -195,16 +247,23 @@ export function Step3SummaryMode({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isFlowRunning =
|
||||
enableFlow &&
|
||||
(summary.flowPhase === "extracting" || summary.flowPhase === "validating");
|
||||
const isStreaming =
|
||||
phase === "creating" ||
|
||||
phase === "streaming" ||
|
||||
isFlowRunning ||
|
||||
dialogues.some((d) => d.streaming === true);
|
||||
const activeSeatMessages = activeArtifactThreadId
|
||||
? seatStubMessages[activeArtifactThreadId]
|
||||
: undefined;
|
||||
|
||||
const statusLabel =
|
||||
phase === "creating"
|
||||
const statusLabel = isFlowRunning
|
||||
? summary.flowPhase === "extracting"
|
||||
? "结构化流程图抽取中…"
|
||||
: "流程图校验 / 修复中…"
|
||||
: phase === "creating"
|
||||
? "正在初始化总结线程…"
|
||||
: phase === "streaming"
|
||||
? "方案总结报告智能体撰写中…"
|
||||
@@ -225,7 +284,7 @@ export function Step3SummaryMode({
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<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">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
{isStreaming && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />}
|
||||
@@ -237,12 +296,22 @@ export function Step3SummaryMode({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新生成
|
||||
</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>
|
||||
)}
|
||||
@@ -347,6 +416,24 @@ export function Step3SummaryMode({
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
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
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
@@ -356,6 +443,11 @@ export function Step3SummaryMode({
|
||||
error={error}
|
||||
busy={busy}
|
||||
mdAvailable={!!mdArtifact}
|
||||
enableFlow={enableFlow}
|
||||
flowPhase={summary.flowPhase}
|
||||
onRegenerateFlow={summary.regenerateFlow}
|
||||
pendingFlowClarification={summary.pendingFlowClarification}
|
||||
onResolveFlowClarification={summary.resolveFlowClarification}
|
||||
onRetry={redraw}
|
||||
onAsk={handleAsk}
|
||||
onPause={pause}
|
||||
@@ -363,11 +455,31 @@ export function Step3SummaryMode({
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -481,6 +593,11 @@ function AgentProgressPanel({
|
||||
error,
|
||||
busy,
|
||||
mdAvailable,
|
||||
enableFlow,
|
||||
flowPhase,
|
||||
onRegenerateFlow,
|
||||
pendingFlowClarification,
|
||||
onResolveFlowClarification,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onPause,
|
||||
@@ -488,6 +605,7 @@ function AgentProgressPanel({
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
@@ -499,6 +617,12 @@ function AgentProgressPanel({
|
||||
busy: boolean;
|
||||
/** 沙箱里是否已有 Markdown 总结报告。 */
|
||||
mdAvailable: boolean;
|
||||
/** 普通会商才会生成、展示和编辑流程图。 */
|
||||
enableFlow: boolean;
|
||||
flowPhase: UseStep3SummaryReturn["flowPhase"];
|
||||
onRegenerateFlow: () => void;
|
||||
pendingFlowClarification: string | null;
|
||||
onResolveFlowClarification: UseStep3SummaryReturn["resolveFlowClarification"];
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
@@ -506,21 +630,46 @@ function AgentProgressPanel({
|
||||
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
// 进度面板独立根据 props 判断流程图阶段,不能引用外层 Step3SummaryMode 的局部变量。
|
||||
const isFlowRunning =
|
||||
enableFlow && (flowPhase === "extracting" || flowPhase === "validating");
|
||||
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 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
|
||||
const prevDialogueCountRef = useRef(dialogues.length);
|
||||
useEffect(() => {
|
||||
if (dialogues.length > prevDialogueCountRef.current) {
|
||||
scrollToBottom();
|
||||
forceScrollToBottom();
|
||||
}
|
||||
prevDialogueCountRef.current = dialogues.length;
|
||||
}, [dialogues.length, scrollToBottom]);
|
||||
}, [dialogues.length, forceScrollToBottom]);
|
||||
// 结构化抽取可能复用前一张「调度中」气泡,导致消息条数不变;只依赖上面的
|
||||
// dialogues.length 时就不会触发定位。阶段切换后等 DOM 提交一帧再强制下滑,
|
||||
// 保证用户能立刻看到抽取/校验进度;后续增量仍由 useAutoScroll 按用户是否上翻决定。
|
||||
useEffect(() => {
|
||||
if (!enableFlow || (flowPhase !== "extracting" && flowPhase !== "validating")) return;
|
||||
let followUpFrameId: number | null = null;
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
forceScrollToBottom();
|
||||
// 校验卡及其中的流式内容可能在第一帧后才完成布局,再确认一次最终高度。
|
||||
followUpFrameId = window.requestAnimationFrame(forceScrollToBottom);
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
if (followUpFrameId !== null) window.cancelAnimationFrame(followUpFrameId);
|
||||
};
|
||||
}, [enableFlow, flowPhase, forceScrollToBottom]);
|
||||
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
|
||||
const [localSettings] = useLocalSettings();
|
||||
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||
@@ -598,10 +747,12 @@ function AgentProgressPanel({
|
||||
id="step3-summary-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={busy || availableModels.length === 0}
|
||||
disabled={busy || modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: busy
|
||||
? "撰写中不可切换;完成后或重新生成时生效"
|
||||
: "切换撰写使用的推理模型(下次生成生效)"
|
||||
@@ -609,7 +760,7 @@ function AgentProgressPanel({
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">未配置模型</option>
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
@@ -639,10 +790,11 @@ function AgentProgressPanel({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
|
||||
{phase === "creating" && dialogues.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -696,10 +906,13 @@ function AgentProgressPanel({
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{mdAvailable
|
||||
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
|
||||
? enableFlow
|
||||
? "已生成方案总结报告与流程图,右侧可预览 / 下载 .md 或切换流程图。"
|
||||
: "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
|
||||
: "已生成方案总结,可在下方继续修改报告或提问。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<ScrollToBottomButton
|
||||
@@ -783,7 +996,9 @@ function AgentProgressPanel({
|
||||
placeholder={
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,8 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
atBottom: boolean;
|
||||
/** Force-scroll to the bottom and resume auto-follow. */
|
||||
scrollToBottom: () => void;
|
||||
/** Immediately jump to the bottom and resume auto-follow (for phase transitions). */
|
||||
forceScrollToBottom: () => void;
|
||||
} {
|
||||
const threshold = options.threshold ?? 32;
|
||||
const contentRef = options.contentRef;
|
||||
@@ -51,16 +53,22 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
|
||||
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
|
||||
const lastObservedScrollTopRef = useRef(0);
|
||||
// 思考块 / Markdown 代码块完成重排时,浏览器可能在没有用户输入的情况下
|
||||
// clamp scrollTop。记录高度后可把这种布局变化与真实向上滚动区分开。
|
||||
const lastObservedScrollHeightRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let prevScrollTop = el.scrollTop;
|
||||
lastObservedScrollTopRef.current = prevScrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
const onScroll = () => {
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const currentScrollHeight = el.scrollHeight;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
const isAtBottom = distanceFromBottom <= threshold;
|
||||
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
|
||||
|
||||
const programmaticTarget = programmaticScrollTargetRef.current;
|
||||
if (
|
||||
@@ -71,6 +79,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
programmaticScrollTargetRef.current = null;
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
if (!atBottomRef.current && isAtBottom) {
|
||||
atBottomRef.current = true;
|
||||
@@ -84,7 +93,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
|
||||
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
|
||||
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
|
||||
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
|
||||
if (currentScrollTop < prevScrollTop && atBottomRef.current && !layoutChanged) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
} else if (isAtBottom && !atBottomRef.current) {
|
||||
@@ -97,6 +106,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// 初次测量
|
||||
@@ -114,21 +124,25 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const currentScrollHeight = el.scrollHeight;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
|
||||
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
|
||||
if (userScrolledUp && distanceFromBottom > threshold) {
|
||||
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
|
||||
if (userScrolledUp && !layoutChanged && distanceFromBottom > threshold) {
|
||||
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
|
||||
if (atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
return;
|
||||
}
|
||||
if (!atBottomRef.current) {
|
||||
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
lastObservedScrollHeightRef.current = currentScrollHeight;
|
||||
return;
|
||||
}
|
||||
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
|
||||
@@ -138,6 +152,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
@@ -163,6 +178,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
});
|
||||
});
|
||||
observer.observe(content);
|
||||
@@ -183,9 +199,27 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
el.scrollHeight - el.clientHeight,
|
||||
);
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
lastObservedScrollHeightRef.current = el.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
}, [ref]);
|
||||
|
||||
return { atBottom, scrollToBottom };
|
||||
// 阶段切换/用户刚发送消息时不能依赖 smooth 动画:下一帧可能还会插入校验卡、
|
||||
// Markdown 或工具步骤,动画目标会停在旧高度。这里直接写入 scrollTop,由内容
|
||||
// ResizeObserver 继续接管后续增高,保证关键阶段始终落在最新底部。
|
||||
const forceScrollToBottom = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
programmaticScrollTargetRef.current = Math.max(
|
||||
0,
|
||||
el.scrollHeight - el.clientHeight,
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
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 hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
|
||||
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
|
||||
const hasUserMessage = step1.interactiveMessages?.some((m) => m.sender === "user") ?? false;
|
||||
return hasUserMessage || (step2.step2RoundtableDialogues?.length ?? 0) > 0;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
normalizeIntentReadySummary,
|
||||
nowTime,
|
||||
splitIntentReadyBlock,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
@@ -82,8 +83,9 @@ export type TimelineEntry = {
|
||||
export type Step1Snapshot = {
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
timelineEntries: TimelineEntry[];
|
||||
/** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底,避免 undefined 流进渲染层。 */
|
||||
interactiveMessages?: InteractiveMessage[];
|
||||
timelineEntries?: TimelineEntry[];
|
||||
};
|
||||
|
||||
export interface UseStep1IntentOptions {
|
||||
@@ -699,11 +701,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
setIntentReady(snapshot.intentReady);
|
||||
// 旧草稿存的 summary 可能缺 constraints/assumptions(实时流两条入口都过
|
||||
// normalizeIntentReadySummary,唯独这里直接落 state)—— 先归一化再恢复,
|
||||
// 保证下游 IntentSummaryCard 等消费者拿到的一定是数组。
|
||||
setIntentReady(normalizeIntentReadySummary(snapshot.intentReady));
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setInteractiveMessages(snapshot.interactiveMessages);
|
||||
setTimelineEntries(snapshot.timelineEntries);
|
||||
setInteractiveMessages(snapshot.interactiveMessages ?? []);
|
||||
setTimelineEntries(snapshot.timelineEntries ?? []);
|
||||
step1InitRef.current = true;
|
||||
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
|
||||
step1InitGenRef.current += 1;
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
artifactPreviewKey,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import {
|
||||
initMultiAgent,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
type ModelSwitchInfo,
|
||||
type ClarificationPayload,
|
||||
@@ -29,6 +33,13 @@ import {
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { isValidSeatDelivery, stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
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";
|
||||
|
||||
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
|
||||
@@ -36,6 +47,21 @@ type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
||||
|
||||
type SeatDelivery = { name: string; content: string };
|
||||
|
||||
/**
|
||||
* `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活,
|
||||
* 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。
|
||||
*/
|
||||
function streamStepKey(
|
||||
phase: StreamPhase,
|
||||
toolName?: string,
|
||||
toolCallId?: string,
|
||||
): string {
|
||||
if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) {
|
||||
return `${phase}:${toolName}:${toolCallId}`;
|
||||
}
|
||||
return `${phase}:${toolName ?? ""}`;
|
||||
}
|
||||
|
||||
/** In-memory cursor for a business-chain human-validation pause. */
|
||||
type HumanValidationContinuation =
|
||||
| { mode: "chain"; nextSeatIndex: number }
|
||||
@@ -148,6 +174,8 @@ export type Step2Dialogue = {
|
||||
streaming?: boolean;
|
||||
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
|
||||
agentThreadId?: string;
|
||||
/** Stream settlement scans the real sandbox output directory for these cards. */
|
||||
artifactPaths?: string[];
|
||||
/**
|
||||
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
|
||||
* 调用的 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
|
||||
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
|
||||
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
|
||||
*/
|
||||
export interface StubToolCall {
|
||||
id: string;
|
||||
/** Original LangGraph id when a pre-id stream chunk needed a local fallback. */
|
||||
sourceToolCallId?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
export interface StubAiMessage {
|
||||
id: string;
|
||||
type: "ai";
|
||||
type: "ai" | "tool";
|
||||
content: string;
|
||||
tool_calls: StubToolCall[];
|
||||
/** tool 类型消息对应的原始 tool call;供沙箱判定 write_file 已完成。 */
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆桌流为了实时预览会合成一条带 write_file 的 ai 消息。主聊天真实流在工具执行结束后
|
||||
* 还会收到对应的 tool 消息;圆桌 special 流此前漏了这一步,导致沙箱永久认为文件仍在写。
|
||||
*
|
||||
* 这里只补齐已经存在的 write_file 调用,绝不根据整条会商/其它并行流的 running 状态推断,
|
||||
* 因而每个席位、报告、流程图和大屏预览的完成态彼此独立。
|
||||
*/
|
||||
export function completeStubWriteFile(
|
||||
messages: StubAiMessage[],
|
||||
toolCallId: string,
|
||||
): StubAiMessage[] {
|
||||
if (!toolCallId) {
|
||||
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 真实生效)。 */
|
||||
@@ -214,7 +303,8 @@ export type Step2Snapshot = {
|
||||
selectedAgents?: SelectedAgent[];
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
/** 早期草稿 / 旧版后台作业写入的 run 可能缺这字段 —— hydrate 按空数组兜底。 */
|
||||
step2RoundtableDialogues?: Step2Dialogue[];
|
||||
lastLeaderContent: string;
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
@@ -513,21 +603,20 @@ export function useStep2Orchestration(
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
deselect: deselectArtifact,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
openStreamedArtifact,
|
||||
} = useArtifacts();
|
||||
const artifactPathsRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
artifactPathsRef.current = artifacts ?? [];
|
||||
}, [artifacts]);
|
||||
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
|
||||
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
|
||||
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
|
||||
useEffect(() => {
|
||||
autoOpenFlagsRef.current = { autoOpen, autoSelect };
|
||||
}, [autoOpen, autoSelect]);
|
||||
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
|
||||
const hasAutoOpenedRef = useRef<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 ─────────────────────────────────────────────────────────
|
||||
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
|
||||
@@ -560,6 +649,9 @@ export function useStep2Orchestration(
|
||||
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
|
||||
Step2Dialogue[]
|
||||
>([]);
|
||||
// 由气泡 id 反查真实席位线程。完成一条气泡时只收口该线程的虚拟 write_file,
|
||||
// 不会误影响仍在并行运行的其它席位。
|
||||
const dialogueThreadIdRef = useRef<Record<number, string>>({});
|
||||
const [pendingClarification, setPendingClarification] =
|
||||
useState<ClarificationPayload | null>(null);
|
||||
const [clarificationDraft, setClarificationDraft] = useState<string>("");
|
||||
@@ -789,6 +881,7 @@ export function useStep2Orchestration(
|
||||
seatThreadId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
sourceToolCallId?: string,
|
||||
): { messageId: string; toolCallId: string } => {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||
@@ -799,17 +892,25 @@ export function useStep2Orchestration(
|
||||
content: "",
|
||||
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.name === toolName &&
|
||||
String((tc.args as { path?: unknown })?.path ?? "") === path,
|
||||
);
|
||||
const idx = bySourceId >= 0 ? bySourceId : byPath;
|
||||
const toolCallId =
|
||||
idx >= 0
|
||||
? baseMsg.tool_calls[idx].id
|
||||
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
|
||||
: sourceToolCallId || `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
|
||||
const nextToolCall: StubToolCall = {
|
||||
id: toolCallId,
|
||||
sourceToolCallId:
|
||||
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
|
||||
name: toolName,
|
||||
args,
|
||||
};
|
||||
@@ -835,13 +936,42 @@ export function useStep2Orchestration(
|
||||
[scheduleSeatStubMessagesFlush],
|
||||
);
|
||||
|
||||
/** 将某次真实 tool 返回同步到对应的虚拟 write_file 预览。 */
|
||||
const completeSeatStubWriteFile = useCallback(
|
||||
(seatThreadId: string, toolCallId: string) => {
|
||||
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||
const nextMessages = completeStubWriteFile(current, toolCallId);
|
||||
if (nextMessages === current) return;
|
||||
const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages };
|
||||
seatStubMessagesRef.current = nextMap;
|
||||
pendingSeatStubMessagesRef.current = nextMap;
|
||||
scheduleSeatStubMessagesFlush();
|
||||
},
|
||||
[scheduleSeatStubMessagesFlush],
|
||||
);
|
||||
|
||||
/** special 流正常收尾的兜底:补齐该席位尚未透传出来的 write_file tool 返回。 */
|
||||
const completeSeatStubWriteFiles = useCallback(
|
||||
(seatThreadId: string | undefined) => {
|
||||
if (!seatThreadId) return;
|
||||
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||
const nextMessages = completeAllStubWriteFiles(current);
|
||||
if (nextMessages === current) return;
|
||||
const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages };
|
||||
seatStubMessagesRef.current = nextMap;
|
||||
pendingSeatStubMessagesRef.current = nextMap;
|
||||
scheduleSeatStubMessagesFlush();
|
||||
},
|
||||
[scheduleSeatStubMessagesFlush],
|
||||
);
|
||||
|
||||
/**
|
||||
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
|
||||
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
|
||||
*
|
||||
* auto=true 流式期间:
|
||||
* - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule);
|
||||
* - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。
|
||||
* - 用户手动关闭过**当前文件**时不反复弹开;后续新的 write_file 调用仍允许自动预览。
|
||||
* auto=false(用户点击步骤 / 文件卡)始终强制打开。
|
||||
*/
|
||||
const openSeatVirtualArtifact = useCallback(
|
||||
@@ -859,8 +989,6 @@ export function useStep2Orchestration(
|
||||
const manual = opts?.manual ?? !auto;
|
||||
if (auto) {
|
||||
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
|
||||
const flags = autoOpenFlagsRef.current;
|
||||
if (!flags.autoOpen || !flags.autoSelect) return;
|
||||
// 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写
|
||||
// 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。
|
||||
if (
|
||||
@@ -880,6 +1008,11 @@ export function useStep2Orchestration(
|
||||
return;
|
||||
}
|
||||
hasAutoOpenedRef.current.add(virtualUrl);
|
||||
setActiveArtifactThreadId(seatThreadId);
|
||||
// 由 ArtifactsProvider 以 tool-call 维度管理「手动关闭后不再弹同一份文件」:
|
||||
// 当前文件被关闭过 → false;下一次 write_file 的 previewKey 不同 → 仍会自动打开。
|
||||
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
|
||||
return;
|
||||
}
|
||||
setActiveArtifactThreadId(seatThreadId);
|
||||
const open = () => {
|
||||
@@ -887,28 +1020,14 @@ export function useStep2Orchestration(
|
||||
selectArtifact(virtualUrl, !manual);
|
||||
setArtifactsOpen(true);
|
||||
};
|
||||
if (auto) {
|
||||
window.setTimeout(open, 100);
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
open();
|
||||
},
|
||||
[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(),
|
||||
[],
|
||||
[openStreamedArtifact, selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
/**
|
||||
* sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。
|
||||
* 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。
|
||||
* 从该 thread 的 stub messages 里倒序找第一个可预览的 write_file / str_replace。
|
||||
* 无产物返回 false(调用方据此关闭沙箱)。
|
||||
*/
|
||||
const openSeatLatestArtifact = useCallback(
|
||||
@@ -918,12 +1037,13 @@ export function useStep2Orchestration(
|
||||
for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) {
|
||||
const tc = stub.tool_calls[i];
|
||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||
const path =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
const args =
|
||||
tc.args && typeof tc.args === "object"
|
||||
? (tc.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
const path = resolveRoundtableStreamingWritePreview(tc.name, args);
|
||||
if (!path) continue;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId: stub.id, toolCallId: tc.id });
|
||||
// manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化
|
||||
// 后续实时自动打开,见 openSeatVirtualArtifact 注释)。
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false });
|
||||
@@ -1004,11 +1124,11 @@ export function useStep2Orchestration(
|
||||
// not found 的影响。
|
||||
const seatSource = findSeatArtifactSource(normalized);
|
||||
if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(
|
||||
normalized,
|
||||
seatSource.messageId,
|
||||
seatSource.toolCallId,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({
|
||||
path: normalized,
|
||||
messageId: seatSource.messageId,
|
||||
toolCallId: seatSource.toolCallId,
|
||||
});
|
||||
openSeatVirtualArtifact(
|
||||
seatSource.seatThreadId,
|
||||
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(
|
||||
(
|
||||
dialogue: Step2Dialogue,
|
||||
@@ -1062,14 +1272,15 @@ export function useStep2Orchestration(
|
||||
if (!seatThreadId) return;
|
||||
const [, toolName] = step.key.split(":");
|
||||
if (toolName === "write_file" || toolName === "str_replace") {
|
||||
const path = step.args?.path;
|
||||
if (typeof path !== "string" || !step.args) return;
|
||||
if (!step.args) return;
|
||||
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
|
||||
if (!path) return;
|
||||
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||
seatThreadId,
|
||||
toolName,
|
||||
step.args,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||
return;
|
||||
}
|
||||
@@ -1099,28 +1310,22 @@ export function useStep2Orchestration(
|
||||
toolName: string | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
seatThreadId: string | undefined,
|
||||
sourceToolCallId?: string,
|
||||
) => {
|
||||
if (phase !== "tool_calling") return;
|
||||
if (!seatThreadId) return;
|
||||
const isArtifactTool =
|
||||
toolName === "write_file" || toolName === "str_replace";
|
||||
if (!isArtifactTool) return;
|
||||
const path = args?.path;
|
||||
if (typeof path !== "string" || !path || !args) {
|
||||
if (typeof window !== "undefined") {
|
||||
console.debug(
|
||||
"[roundtable-sandbox] tool_calling without path yet",
|
||||
{ toolName, args },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (toolName !== "write_file" && toolName !== "str_replace") return;
|
||||
// `write_file` can stream its body before its path. It opens the sandbox
|
||||
// for every file type; only final message-list cards are filtered.
|
||||
const path = resolveRoundtableStreamingWritePreview(toolName, args);
|
||||
if (!path || !args) return;
|
||||
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||
seatThreadId,
|
||||
toolName as string,
|
||||
args,
|
||||
sourceToolCallId,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId });
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
|
||||
},
|
||||
[
|
||||
@@ -1136,7 +1341,9 @@ export function useStep2Orchestration(
|
||||
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
|
||||
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
|
||||
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
|
||||
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
|
||||
* - 同一 messages 帧可先写文件、再读技能。阶段层只会把最后一个 read_skill 当作
|
||||
* active tool,因而这里必须补一次 write_file 自动打开;以 tool-call 虚拟 URL 去重,
|
||||
* 不会在后续内容增量或用户手动关闭后反复弹出。
|
||||
*/
|
||||
const handleUpstreamFrame = useCallback(
|
||||
(frame: unknown, seatThreadId: string | undefined) => {
|
||||
@@ -1145,6 +1352,13 @@ export function useStep2Orchestration(
|
||||
const chunk = frame[0];
|
||||
if (!chunk || typeof chunk !== "object") return;
|
||||
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;
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
||||
for (const tc of toolCalls) {
|
||||
@@ -1155,14 +1369,79 @@ export function useStep2Orchestration(
|
||||
const tcArgs = tcObj.args;
|
||||
if (!tcArgs || typeof tcArgs !== "object") continue;
|
||||
const argsObj = tcArgs as Record<string, unknown>;
|
||||
const path = argsObj.path;
|
||||
if (typeof path !== "string" || !path) continue;
|
||||
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||
const sourceToolCallId = typeof tcObj.id === "string" ? tcObj.id : undefined;
|
||||
// Keep the content-first call in the stub as well. The virtual preview
|
||||
// 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 识别)──────────
|
||||
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
|
||||
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
|
||||
@@ -1391,15 +1670,28 @@ export function useStep2Orchestration(
|
||||
let nextDialogue: Step2Dialogue;
|
||||
|
||||
if (last && last.key === key) {
|
||||
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
|
||||
// 可见参数」时补一次;文件正文由 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 };
|
||||
// 总控派活的 task 是 tool_call JSON 的流式增量。不能沿用普通工具的
|
||||
// 「首次可见参数即停」策略,否则只显示 agent_name,整段任务会在收尾时
|
||||
// 才被最终消息一次性补上。
|
||||
if (key.startsWith("tool_calling:agent_orchestration:") && args) {
|
||||
const mergedArgs = { ...(last.args ?? {}), ...args };
|
||||
if (JSON.stringify(last.args ?? {}) === JSON.stringify(mergedArgs)) {
|
||||
return prev;
|
||||
}
|
||||
const nextSteps = [...steps];
|
||||
nextSteps[nextSteps.length - 1] = { ...last, args: mergedArgs };
|
||||
nextDialogue = { ...dialogue, steps: nextSteps };
|
||||
} else {
|
||||
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
|
||||
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
|
||||
// content chunk 更新对话 state。
|
||||
const hasVisibleArgs = (value?: Record<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 {
|
||||
nextDialogue = {
|
||||
...dialogue,
|
||||
@@ -1427,6 +1719,9 @@ export function useStep2Orchestration(
|
||||
agentThreadId?: string;
|
||||
}): number => {
|
||||
const id = Date.now() + Math.random();
|
||||
if (card.agentThreadId) {
|
||||
dialogueThreadIdRef.current[id] = card.agentThreadId;
|
||||
}
|
||||
setStep2RoundtableDialogues((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -1630,10 +1925,8 @@ export function useStep2Orchestration(
|
||||
const opened = openSeatLatestArtifact(tid);
|
||||
if (!opened) {
|
||||
// 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→
|
||||
// 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen);
|
||||
// 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false
|
||||
// (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的
|
||||
// `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。
|
||||
// 用 deselect 收起并清空旧预览,不把别的席位当前文件标记为「手动关闭」。这样该席位
|
||||
// 随后的新 write_file 仍能按自己的 previewKey 自动打开。
|
||||
setActiveArtifactThreadId(null);
|
||||
deselectArtifact();
|
||||
}
|
||||
@@ -1647,7 +1940,14 @@ export function useStep2Orchestration(
|
||||
|
||||
const finalizeDialogue = useCallback(
|
||||
(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);
|
||||
if (
|
||||
directStreamDeltaRef.current.size === 0 &&
|
||||
@@ -1660,10 +1960,18 @@ export function useStep2Orchestration(
|
||||
let changed = false;
|
||||
const next = prev.map((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 =
|
||||
finalContent && finalContent.trim().length > 0
|
||||
? finalContent
|
||||
: d.content || "(无文本输出)";
|
||||
streamed.trim() && streamedVisible === finalVisible
|
||||
? streamed
|
||||
: finalContent && finalContent.trim().length > 0
|
||||
? finalContent
|
||||
: streamed || "(无文本输出)";
|
||||
if (
|
||||
d.content === next &&
|
||||
d.confidence === (confidence ?? d.confidence) &&
|
||||
@@ -1682,7 +1990,7 @@ export function useStep2Orchestration(
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[attachSettledArtifactCards, completeSeatStubWriteFiles],
|
||||
);
|
||||
|
||||
const highlightSpeaker = useCallback(
|
||||
@@ -1881,11 +2189,11 @@ export function useStep2Orchestration(
|
||||
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
|
||||
onTextDelta: (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);
|
||||
setDialogueConfidence(a.bubbleId, label);
|
||||
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, a.seatThreadId);
|
||||
@@ -1971,27 +2279,25 @@ export function useStep2Orchestration(
|
||||
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
|
||||
// 首轮纯派活、无交付,传 false 保持上下文精简。
|
||||
synthesisMode: safetyCounter > 1,
|
||||
// 削减重复广播(2026-06-20):首轮(safetyCounter==1)保留预广播——它把任务背景播进各席位
|
||||
// thread,为席位打底;第 2 轮起的续轮/综合广播是「总控收到:<续轮 prompt>」纯噪声 + N 次
|
||||
// 读写巨大席位 thread 的纯浪费(还会让弱模型从历史模仿派活),故 suppress。席位的前序交付
|
||||
// 另经「席位交付广播」(broadcastSeatDeliveryToOthers)送达,不受影响。
|
||||
suppressPreBroadcast: safetyCounter > 1,
|
||||
// 总控 prompt 含派活/编排指令,只能进入总控 thread。任何轮次都禁止广播到席位历史,
|
||||
// 否则弱模型会模仿总控并声称要加载编排技能。席位上下文由具体 task 与交付广播提供。
|
||||
suppressPreBroadcast: true,
|
||||
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(leaderBubbleId, label);
|
||||
appendStepIfNew(
|
||||
leaderBubbleId,
|
||||
label,
|
||||
`${phase}:${toolName ?? ""}`,
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) =>
|
||||
handleUpstreamFrame(frame, leaderThreadId),
|
||||
@@ -2256,21 +2562,25 @@ export function useStep2Orchestration(
|
||||
dAgentThreadId: ids,
|
||||
agentName: coord,
|
||||
newMessage: leaderTask,
|
||||
// 削减重复广播(2026-06-20):首个席位(i===0)保留预广播为其打底任务背景;从第 2 个席位起
|
||||
// suppress——后续席位的前序交付已由「席位交付广播」送达其 thread,总控派活 prompt 再播一遍
|
||||
// 是 N 次读写巨大 thread 的纯浪费(还会让弱模型模仿派活)。
|
||||
suppressPreBroadcast: i > 0,
|
||||
// 链条首轮同样不能把「请总控调用 agent_orchestration」广播进席位 thread。
|
||||
// 当前席位拿到的是总控生成的具体 task,后续席位再通过交付广播承接上下文。
|
||||
suppressPreBroadcast: true,
|
||||
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(leaderBubbleId, label);
|
||||
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
appendStepIfNew(
|
||||
leaderBubbleId,
|
||||
label,
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
@@ -2349,11 +2659,11 @@ export function useStep2Orchestration(
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(subBubbleId, label);
|
||||
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
@@ -2452,11 +2762,16 @@ export function useStep2Orchestration(
|
||||
signal: closeoutAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(closeoutBubbleId, label);
|
||||
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
appendStepIfNew(
|
||||
closeoutBubbleId,
|
||||
label,
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
@@ -2631,11 +2946,16 @@ export function useStep2Orchestration(
|
||||
signal: leaderAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(leaderBubbleId, label);
|
||||
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
appendStepIfNew(
|
||||
leaderBubbleId,
|
||||
label,
|
||||
streamStepKey(phase, toolName, toolCallId),
|
||||
args,
|
||||
);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
@@ -2758,11 +3078,11 @@ export function useStep2Orchestration(
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
|
||||
onTextDelta: (text) =>
|
||||
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
@@ -2970,11 +3290,11 @@ export function useStep2Orchestration(
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, true),
|
||||
onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
onPhaseChange: (phase, toolName, args, toolCallId) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
@@ -3408,7 +3728,7 @@ export function useStep2Orchestration(
|
||||
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
|
||||
// streaming===true 与「加载中」文案一一对应(finalizeDialogue 收尾时必置 false),故据此即可精确识别。
|
||||
setStep2RoundtableDialogues(
|
||||
snapshot.step2RoundtableDialogues.map((d) => {
|
||||
(snapshot.step2RoundtableDialogues ?? []).map((d) => {
|
||||
if (!d.streaming) return d;
|
||||
const hasText = (d.content ?? "").trim().length > 0;
|
||||
return {
|
||||
@@ -3423,8 +3743,20 @@ export function useStep2Orchestration(
|
||||
setHasConsensus(snapshot.hasConsensus);
|
||||
setConsensusPercentage(snapshot.consensusPercentage);
|
||||
setBudgetLimit(snapshot.budgetLimit);
|
||||
const restoredStubs = snapshot.seatStubMessages ?? {};
|
||||
// 历史草稿是静态完成记录;旧版本未持久化 tool result 的 write_file 也必须收口,
|
||||
// 否则从「最终方案展示」回看时会错误显示仍在生成。
|
||||
const restoredStubs = Object.fromEntries(
|
||||
Object.entries(snapshot.seatStubMessages ?? {}).map(([threadId, messages]) => [
|
||||
threadId,
|
||||
completeAllStubWriteFiles(messages),
|
||||
]),
|
||||
);
|
||||
replaceSeatStubMessages(restoredStubs);
|
||||
dialogueThreadIdRef.current = Object.fromEntries(
|
||||
(snapshot.step2RoundtableDialogues ?? [])
|
||||
.filter((dialogue) => Boolean(dialogue.agentThreadId))
|
||||
.map((dialogue) => [dialogue.id, dialogue.agentThreadId!]),
|
||||
);
|
||||
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||
@@ -3491,6 +3823,7 @@ export function useStep2Orchestration(
|
||||
setCoordinatorName(null);
|
||||
setInitError(null);
|
||||
setStep2RoundtableDialogues([]);
|
||||
dialogueThreadIdRef.current = {};
|
||||
setLastLeaderContent("");
|
||||
setHasConsensus(false);
|
||||
setConsensusPercentage(0);
|
||||
@@ -3504,6 +3837,8 @@ export function useStep2Orchestration(
|
||||
setArtifacts([]);
|
||||
resetSeatStubMessages();
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
artifactDirectorySnapshotsRef.current = new Map();
|
||||
autoOpenedDiscoveredArtifactsRef.current = new Set();
|
||||
pendingInjectPromptRef.current = null;
|
||||
dagPlanExecutedRef.current = false;
|
||||
humanValidationContinuationRef.current = null;
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
type StreamPhase,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
import {
|
||||
completeAllStubWriteFiles,
|
||||
type Step2Dialogue,
|
||||
type StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
buildDashboardPrompt,
|
||||
@@ -234,6 +235,16 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
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 lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
|
||||
@@ -274,8 +285,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const current = htmlRef.current;
|
||||
if (!current) return;
|
||||
const { messageId, toolCallId } = upsertDashStub(tid, current);
|
||||
completeDashStubWriteFiles(tid);
|
||||
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
|
||||
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
|
||||
}, [activeArtifactThreadId, upsertDashStub, completeDashStubWriteFiles, openDashboardArtifact]);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
aborterRef.current?.abort();
|
||||
@@ -345,7 +357,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
const data = extractReportData(res.content);
|
||||
if (!data) {
|
||||
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
|
||||
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
|
||||
if (htmlRef.current) {
|
||||
upsertDashStub(threadId, htmlRef.current);
|
||||
completeDashStubWriteFiles(threadId);
|
||||
}
|
||||
finalizeDialogue(
|
||||
bubbleId,
|
||||
(res.content || "").trim() ||
|
||||
@@ -362,6 +377,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
|
||||
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
|
||||
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
|
||||
completeDashStubWriteFiles(threadId);
|
||||
openDashboardArtifact(threadId, messageId, toolCallId);
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
|
||||
@@ -378,6 +394,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setDialogueConfidence,
|
||||
finalizeDialogue,
|
||||
upsertDashStub,
|
||||
completeDashStubWriteFiles,
|
||||
openDashboardArtifact,
|
||||
],
|
||||
);
|
||||
@@ -483,6 +500,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setDashThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
|
||||
completeDashStubWriteFiles(synthThreadId);
|
||||
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
|
||||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||||
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
|
||||
@@ -495,7 +513,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
: null;
|
||||
if (savedStubs && Object.keys(savedStubs).length) {
|
||||
// 合并历史 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;
|
||||
setSeatStubMessages(merged);
|
||||
}
|
||||
@@ -522,7 +545,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setPhase("done");
|
||||
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
|
||||
},
|
||||
[abort, upsertDashStub],
|
||||
[abort, upsertDashStub, completeDashStubWriteFiles],
|
||||
);
|
||||
|
||||
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
|
||||
@@ -556,6 +579,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setData(parsed);
|
||||
dataRef.current = parsed; // 续轮修改的基线
|
||||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
|
||||
completeDashStubWriteFiles(synthThreadId);
|
||||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||||
} else {
|
||||
htmlRef.current = null;
|
||||
@@ -567,7 +591,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setError(null);
|
||||
setPhase(parsed ? "done" : "idle");
|
||||
},
|
||||
[abort, themeMode, upsertDashStub],
|
||||
[abort, themeMode, upsertDashStub, completeDashStubWriteFiles],
|
||||
);
|
||||
|
||||
// 恢复路径辅助: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(
|
||||
(path: string, messageId: string, toolCallId: string) =>
|
||||
new URL(
|
||||
@@ -825,6 +855,9 @@ export function useStep3Report(
|
||||
? await reconcileStubFromRealFile(threadId, aborter.signal)
|
||||
: "";
|
||||
if (aborter.signal.aborted) return;
|
||||
// 该轮已正常收尾:解除当前 HTML 虚拟预览的写入态。不能等其它并行的总结/大屏流,
|
||||
// 它们各自有独立线程和 stub,互不影响。
|
||||
completeStubWriteFiles(threadId);
|
||||
finalizeDialogue(bubbleId, closing, "绘制完成");
|
||||
setPhase("done");
|
||||
setReportTheme(theme);
|
||||
@@ -844,6 +877,7 @@ export function useStep3Report(
|
||||
selectedModel,
|
||||
openRealArtifactFallback,
|
||||
reconcileStubFromRealFile,
|
||||
completeStubWriteFiles,
|
||||
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 —— 新版草稿存了就整段回显,像第二步一样。
|
||||
|
||||
@@ -34,9 +34,11 @@ import type {
|
||||
ReportIntent,
|
||||
ReportSeatDelivery,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
import {
|
||||
completeAllStubWriteFiles,
|
||||
completeStubWriteFile,
|
||||
type Step2Dialogue,
|
||||
type StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
|
||||
import {
|
||||
@@ -127,7 +129,7 @@ export interface UseStep3SummaryReturn {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
activeArtifactThreadId: string | null;
|
||||
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */
|
||||
/** 是否正在生成中(报告撰写或流程图抽取/校验)—— 驱动暂停按钮与发送态。 */
|
||||
busy: boolean;
|
||||
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
|
||||
start: () => void;
|
||||
@@ -821,6 +823,34 @@ export function useStep3Summary(
|
||||
[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(
|
||||
(path: string, messageId: string, toolCallId: string) =>
|
||||
new URL(
|
||||
@@ -955,6 +985,11 @@ export function useStep3Summary(
|
||||
(frame: unknown, threadId: string) => {
|
||||
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||
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;
|
||||
if (!Array.isArray(toolCalls)) return;
|
||||
for (const tc of toolCalls) {
|
||||
@@ -970,7 +1005,7 @@ export function useStep3Summary(
|
||||
upsertStub(threadId, name, a as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
[upsertStub],
|
||||
[completeSummaryStubWriteFile, upsertStub],
|
||||
);
|
||||
|
||||
const openArtifactPreview = useCallback(
|
||||
@@ -1162,6 +1197,9 @@ export function useStep3Summary(
|
||||
flowActiveRef.current = false;
|
||||
}
|
||||
|
||||
// 报告线程本轮已收尾。即使后端没有单独透传 tool 帧,也只结束本线程的预览写入态;
|
||||
// 后续自动流程图/大屏等并行分支完全不参与这个判断。
|
||||
completeSummaryStubWriteFiles(threadId);
|
||||
finalizeDialogue(bubbleId, res.content, "总结完成");
|
||||
setPhase("done");
|
||||
|
||||
@@ -1187,6 +1225,7 @@ export function useStep3Summary(
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
reconcileStubFromRealFile,
|
||||
completeSummaryStubWriteFiles,
|
||||
finalizeDialogue,
|
||||
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 保住各气泡步骤卡的真线程预览。
|
||||
const effectiveStub =
|
||||
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;
|
||||
liveThreadRef.current = realThreadId;
|
||||
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
|
||||
@@ -2015,7 +2069,11 @@ export function useStep3Summary(
|
||||
[abort, buildWriteFileVirtualUrl, replaceStubMessages],
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const busy =
|
||||
phase === "creating" ||
|
||||
phase === "streaming" ||
|
||||
flowPhase === "extracting" ||
|
||||
flowPhase === "validating";
|
||||
|
||||
return {
|
||||
phase,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"
|
||||
|
||||
/**
|
||||
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
||||
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
|
||||
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧中当前工具调用的 args。
|
||||
*
|
||||
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
|
||||
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
|
||||
|
||||
@@ -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,分层编辑器)。
|
||||
*/
|
||||
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 { toast } from "sonner";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -1238,7 +1238,8 @@ export default function BusinessChainEditorPage() {
|
||||
{ n: 1 as const, label: "链条编排" },
|
||||
{ n: 2 as const, label: "基本信息" },
|
||||
]).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" />}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1261,7 +1262,7 @@ export default function BusinessChainEditorPage() {
|
||||
</span>
|
||||
{s.label}
|
||||
</button>
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
* 仍走旧实现,本页不复用它,避免互相牵连。
|
||||
*/
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -853,8 +852,9 @@ function ChainFlow({ chain }: { chain: BusinessChain }) {
|
||||
style={{ maxHeight: 220 }}
|
||||
>
|
||||
<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) => (
|
||||
<Fragment key={sIdx}>
|
||||
<div className="contents" key={sIdx}>
|
||||
<div
|
||||
className={cn(
|
||||
"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]" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1054,8 +1054,9 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) {
|
||||
const { stages, seatById } = useChainStages(chain);
|
||||
return (
|
||||
<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) => (
|
||||
<Fragment key={sIdx}>
|
||||
<div className="contents" key={sIdx}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center rounded border px-2 py-1",
|
||||
@@ -1073,7 +1074,7 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) {
|
||||
{sIdx < stages.length - 1 && (
|
||||
<span className="shrink-0 text-zinc-300 dark:text-[#4A6491]">→</span>
|
||||
)}
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -350,6 +350,11 @@ export default function RoundtablePlanningPage({
|
||||
hideHeaderSettings = false,
|
||||
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
|
||||
taskArtifactSubmissionConfig,
|
||||
/**
|
||||
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
|
||||
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
|
||||
*/
|
||||
step3Mode = "full",
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
forceDarkAppearance?: boolean;
|
||||
@@ -396,6 +401,7 @@ export default function RoundtablePlanningPage({
|
||||
newTaskLabel?: string;
|
||||
hideHeaderSettings?: boolean;
|
||||
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
|
||||
step3Mode?: "full" | "report-only";
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
@@ -403,6 +409,7 @@ export default function RoundtablePlanningPage({
|
||||
// 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式,
|
||||
// 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。
|
||||
const isDark = forceDarkAppearance || theme === "dark";
|
||||
const hasFullStep3Outputs = step3Mode === "full";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
@@ -1634,9 +1641,9 @@ export default function RoundtablePlanningPage({
|
||||
.filter((g) => g.length > 0),
|
||||
[orchestrationPlan],
|
||||
);
|
||||
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物,
|
||||
// 避免报告写完后额外触发一次结构化抽取请求。
|
||||
const autoSummaryFlow = false;
|
||||
// 普通会商:Markdown 报告完成后自动续跑结构化流程图;8BF:只完成报告即结束。
|
||||
// 这个开关由入口显式传入,而不是从业务链/任务状态推断,防止两类会商串行为。
|
||||
const autoSummaryFlow = hasFullStep3Outputs;
|
||||
|
||||
const summaryFlow = useStep3Summary({
|
||||
intentReady: step1.intentReady,
|
||||
@@ -1646,7 +1653,7 @@ export default function RoundtablePlanningPage({
|
||||
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
|
||||
chainLevels,
|
||||
autoFlow: autoSummaryFlow,
|
||||
flowEnabled: false,
|
||||
flowEnabled: hasFullStep3Outputs,
|
||||
savedSummary: step3Report?.summaryReport ?? null,
|
||||
onSummarySaved: handleSummarySaved,
|
||||
ingestAvailable,
|
||||
@@ -1753,28 +1760,30 @@ export default function RoundtablePlanningPage({
|
||||
} else {
|
||||
summaryFlow.start();
|
||||
}
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
|
||||
if (rep?.html && !isDashboardHtml(rep.html)) {
|
||||
reportFlow.hydrateSaved(rep);
|
||||
} else {
|
||||
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
|
||||
const dashSaved = {
|
||||
dialogues: rep?.dashboardDialogues,
|
||||
seatStubMessages: rep?.dashboardSeatStubMessages,
|
||||
};
|
||||
if (rep?.dashboardJson) {
|
||||
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
|
||||
} else if (rep?.html && isDashboardHtml(rep.html)) {
|
||||
dashboardFlow.hydrateSaved(rep.html, dashSaved);
|
||||
} else {
|
||||
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
|
||||
if (hasFullStep3Outputs) {
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
|
||||
if (rep?.html && !isDashboardHtml(rep.html)) {
|
||||
reportFlow.hydrateSaved(rep);
|
||||
} else {
|
||||
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
|
||||
const dashSaved = {
|
||||
dialogues: rep?.dashboardDialogues,
|
||||
seatStubMessages: rep?.dashboardSeatStubMessages,
|
||||
};
|
||||
if (rep?.dashboardJson) {
|
||||
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
|
||||
} else if (rep?.html && isDashboardHtml(rep.html)) {
|
||||
dashboardFlow.hydrateSaved(rep.html, dashSaved);
|
||||
} else {
|
||||
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]);
|
||||
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers, hasFullStep3Outputs]);
|
||||
|
||||
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
|
||||
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
|
||||
@@ -2166,6 +2175,8 @@ export default function RoundtablePlanningPage({
|
||||
onStopIntent: step1.stopIntentStream,
|
||||
onSkipQuestion: handleSkipQuestion,
|
||||
onFollowUp: handleAgentFollowUp,
|
||||
// 多智能体会商页去掉「继续追问」「跳过此项」快捷键 —— 干预走输入框直接发送即可。
|
||||
hideFollowupActions: true,
|
||||
onEnterRoundtable: () => {
|
||||
if (!step1.intentReady) {
|
||||
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||
@@ -2606,6 +2617,7 @@ export default function RoundtablePlanningPage({
|
||||
<Step3Panel
|
||||
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
|
||||
headerTitle={step3HeaderTitle}
|
||||
step3Mode={step3Mode}
|
||||
threadIds={step2.threadIds}
|
||||
coordinatorName={step2.coordinatorName}
|
||||
consensusText={step2.lastLeaderContent}
|
||||
@@ -4343,7 +4355,7 @@ function IntentSummaryCard({
|
||||
)}
|
||||
{showFullDetail && (
|
||||
<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">
|
||||
<span className="text-primary text-base leading-none">•</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.
|
||||
|
||||
### 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**:
|
||||
```python
|
||||
# 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.
|
||||
|
||||
### 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 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.public_knowledge_cors import PublicKnowledgeCORSMiddleware
|
||||
from app.gateway.routers import (
|
||||
ai_writing,
|
||||
admin_active_runs,
|
||||
admin_users,
|
||||
agents,
|
||||
ai_writing,
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
auth,
|
||||
@@ -39,11 +39,12 @@ from app.gateway.routers import (
|
||||
channels,
|
||||
checkpoint_migration,
|
||||
compact_menu_overrides,
|
||||
dashboard_sessions,
|
||||
deep_research,
|
||||
external_llmwiki,
|
||||
feedback,
|
||||
fixed_questions,
|
||||
dashboard_sessions,
|
||||
deep_research,
|
||||
flow_extract,
|
||||
html_page_favorites,
|
||||
intent,
|
||||
knowledge,
|
||||
@@ -63,19 +64,19 @@ from app.gateway.routers import (
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roles,
|
||||
position_roundtable,
|
||||
positions,
|
||||
position_roles,
|
||||
public_config,
|
||||
public_embed,
|
||||
public_knowledge_vector_search,
|
||||
public_leaderboard,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
flow_extract,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
report_structures,
|
||||
roundtable_artifact_submissions,
|
||||
roundtable_chains,
|
||||
roundtable_diagnostics,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
@@ -91,16 +92,22 @@ from app.gateway.routers import (
|
||||
system_settings,
|
||||
tags,
|
||||
task_buttons,
|
||||
taskcop_tasks,
|
||||
report_structures,
|
||||
task_reports,
|
||||
taskcop_tasks,
|
||||
thread_runs,
|
||||
tool_metrics,
|
||||
thread_shares,
|
||||
threads,
|
||||
tool_metrics,
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
workflow_data_sources,
|
||||
workflow_embed,
|
||||
workflow_resources,
|
||||
workflow_runs,
|
||||
workflow_studio,
|
||||
workflows,
|
||||
workflows_coze_compat,
|
||||
writing,
|
||||
)
|
||||
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
|
||||
while result_queue.empty():
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise asyncio.TimeoutError
|
||||
raise TimeoutError
|
||||
await asyncio.sleep(0.2)
|
||||
result = result_queue.get_nowait()
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
"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 "
|
||||
@@ -989,11 +996,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
|
||||
|
||||
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
|
||||
async with langgraph_runtime(app):
|
||||
logger.info("LangGraph runtime initialised")
|
||||
|
||||
# Run Alembic migrations before anything else touches the schema.
|
||||
await _run_db_migrations()
|
||||
async with langgraph_runtime(app):
|
||||
logger.info("LangGraph runtime initialised")
|
||||
|
||||
# One-time repair switch for a failed, unused Workflow Studio schema.
|
||||
# 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
|
||||
# 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
|
||||
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
|
||||
app.include_router(menu_overrides.router)
|
||||
|
||||
|
||||
@@ -4,25 +4,25 @@ Per RFC-001:
|
||||
State-changing operations require CSRF protection.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
@@ -35,7 +35,7 @@ def generate_csrf_token() -> str:
|
||||
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.
|
||||
|
||||
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)
|
||||
stripped = path.rstrip("/")
|
||||
# Exempt /api/v1/auth/me endpoint
|
||||
if stripped == "/api/v1/auth/me":
|
||||
return False
|
||||
if stripped == "/api/knowledge/vector-search":
|
||||
return False
|
||||
if stripped == "/api/writing/export/docx":
|
||||
return False
|
||||
if stripped == "/api/v1/auth/me":
|
||||
return False
|
||||
if stripped == "/api/knowledge/vector-search":
|
||||
return False
|
||||
if stripped == "/api/writing/export/docx":
|
||||
return False
|
||||
# Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES).
|
||||
# These intentionally have no cookie session, so the Double-Submit Cookie
|
||||
# pattern is both impossible (no token to compare) and unnecessary (no
|
||||
@@ -64,18 +64,37 @@ def should_check_csrf(request: Request) -> bool:
|
||||
return False
|
||||
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
|
||||
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
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):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
if is_coze_studio_compat_path(path):
|
||||
return False
|
||||
if is_weknora_embed_proxy_request(request):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Coze playground 自己的 axios 不会带 X-CSRF-Token;独立编排页经
|
||||
# localhost:8080 同源代理访问这些接口。Cookie 会话仍由 AuthMiddleware 校验。
|
||||
_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(
|
||||
@@ -90,7 +109,7 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
"/api/v1/auth/register",
|
||||
"/api/v1/auth/initialize",
|
||||
# 并行多智能体面板专用取 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.feedback_repo = FeedbackRepository(sf)
|
||||
else:
|
||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||
|
||||
app.state.run_store = MemoryRunStore()
|
||||
app.state.feedback_repo = None
|
||||
|
||||
from deerflow.persistence.fixed_questions import make_fixed_question_store
|
||||
|
||||
app.state.fixed_question_store = make_fixed_question_store(sf)
|
||||
else:
|
||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||
|
||||
app.state.run_store = MemoryRunStore()
|
||||
app.state.feedback_repo = None
|
||||
|
||||
from deerflow.persistence.fixed_questions import make_fixed_question_store
|
||||
|
||||
app.state.fixed_question_store = make_fixed_question_store(sf)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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.persistence.llmwiki import make_llmwiki_store
|
||||
|
||||
app.state.llmwiki_store = make_llmwiki_store(sf)
|
||||
register_llmwiki_store(app.state.llmwiki_store)
|
||||
|
||||
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
|
||||
# migrations and status inspection work while feature flags are off;
|
||||
# network/search services only exist when explicitly enabled.
|
||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
|
||||
|
||||
local_index_config = config.llmwiki.local_wiki_index
|
||||
app.state.llmwiki_index_store = make_llmwiki_index_store(
|
||||
sf,
|
||||
write_batch_size=local_index_config.database_batch_size,
|
||||
)
|
||||
app.state.llmwiki_embedding = None
|
||||
app.state.llmwiki_vector_cache = None
|
||||
app.state.llmwiki_vector_search = None
|
||||
app.state.llmwiki_sync_service = None
|
||||
app.state.llmwiki_retrieval_service = None
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
|
||||
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
|
||||
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
|
||||
if local_index_config.enabled:
|
||||
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
||||
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
|
||||
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||
|
||||
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(local_index_config.embedding)
|
||||
app.state.llmwiki_vector_cache = WikiVectorCache(
|
||||
app.state.llmwiki_index_store,
|
||||
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
|
||||
revision_check_seconds=local_index_config.cache_revision_check_seconds,
|
||||
)
|
||||
app.state.llmwiki_vector_search = WikiVectorSearchService(
|
||||
local_index_config,
|
||||
app.state.llmwiki_embedding,
|
||||
app.state.llmwiki_vector_cache,
|
||||
)
|
||||
if weknora_client is not None:
|
||||
app.state.llmwiki_sync_service = WikiSyncService(
|
||||
local_index_config,
|
||||
app.state.llmwiki_index_store,
|
||||
app.state.llmwiki_embedding,
|
||||
app.state.llmwiki_vector_cache,
|
||||
weknora_client,
|
||||
)
|
||||
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
|
||||
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
||||
|
||||
app.state.llmwiki_retrieval_service = WikiRetrievalService(
|
||||
app.state.llmwiki_index_store,
|
||||
vector_search=(
|
||||
app.state.llmwiki_vector_search
|
||||
if local_index_config.internal_search_enabled
|
||||
else None
|
||||
),
|
||||
client=weknora_client,
|
||||
)
|
||||
register_local_wiki_index_runtime(
|
||||
store=app.state.llmwiki_index_store,
|
||||
search=app.state.llmwiki_vector_search,
|
||||
sync=app.state.llmwiki_sync_service,
|
||||
retrieval=app.state.llmwiki_retrieval_service,
|
||||
)
|
||||
app.state.llmwiki_store = make_llmwiki_store(sf)
|
||||
register_llmwiki_store(app.state.llmwiki_store)
|
||||
|
||||
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
|
||||
# migrations and status inspection work while feature flags are off;
|
||||
# network/search services only exist when explicitly enabled.
|
||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
|
||||
|
||||
local_index_config = config.llmwiki.local_wiki_index
|
||||
app.state.llmwiki_index_store = make_llmwiki_index_store(
|
||||
sf,
|
||||
write_batch_size=local_index_config.database_batch_size,
|
||||
)
|
||||
app.state.llmwiki_embedding = None
|
||||
app.state.llmwiki_vector_cache = None
|
||||
app.state.llmwiki_vector_search = None
|
||||
app.state.llmwiki_sync_service = None
|
||||
app.state.llmwiki_retrieval_service = None
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
|
||||
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
|
||||
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
|
||||
if local_index_config.enabled:
|
||||
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
||||
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
|
||||
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||
|
||||
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(local_index_config.embedding)
|
||||
app.state.llmwiki_vector_cache = WikiVectorCache(
|
||||
app.state.llmwiki_index_store,
|
||||
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
|
||||
revision_check_seconds=local_index_config.cache_revision_check_seconds,
|
||||
)
|
||||
app.state.llmwiki_vector_search = WikiVectorSearchService(
|
||||
local_index_config,
|
||||
app.state.llmwiki_embedding,
|
||||
app.state.llmwiki_vector_cache,
|
||||
)
|
||||
if weknora_client is not None:
|
||||
app.state.llmwiki_sync_service = WikiSyncService(
|
||||
local_index_config,
|
||||
app.state.llmwiki_index_store,
|
||||
app.state.llmwiki_embedding,
|
||||
app.state.llmwiki_vector_cache,
|
||||
weknora_client,
|
||||
)
|
||||
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
|
||||
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
||||
|
||||
app.state.llmwiki_retrieval_service = WikiRetrievalService(
|
||||
app.state.llmwiki_index_store,
|
||||
vector_search=(
|
||||
app.state.llmwiki_vector_search
|
||||
if local_index_config.internal_search_enabled
|
||||
else None
|
||||
),
|
||||
client=weknora_client,
|
||||
)
|
||||
register_local_wiki_index_runtime(
|
||||
store=app.state.llmwiki_index_store,
|
||||
search=app.state.llmwiki_vector_search,
|
||||
sync=app.state.llmwiki_sync_service,
|
||||
retrieval=app.state.llmwiki_retrieval_service,
|
||||
)
|
||||
|
||||
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_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_events_config = getattr(config, "run_events", None)
|
||||
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)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
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)
|
||||
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||
if llmwiki_embedding is not None:
|
||||
await llmwiki_embedding.close()
|
||||
from deerflow.persistence.notifications import set_notification_store
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
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)
|
||||
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||
if llmwiki_embedding is not None:
|
||||
await llmwiki_embedding.close()
|
||||
from deerflow.persistence.notifications import set_notification_store
|
||||
|
||||
set_notification_store(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)
|
||||
if dr_dispatcher is not None:
|
||||
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)
|
||||
if queue is not None:
|
||||
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
|
||||
|
||||
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract the caller identity, preferring the explicit Bearer header.
|
||||
|
||||
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.
|
||||
"""
|
||||
from app.gateway.auth.token_source import get_explicit_or_session_token
|
||||
|
||||
request_token = get_explicit_or_session_token(request)
|
||||
if request_token:
|
||||
return request_token
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract the caller identity, preferring the explicit Bearer header.
|
||||
|
||||
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.
|
||||
"""
|
||||
from app.gateway.auth.token_source import get_explicit_or_session_token
|
||||
|
||||
request_token = get_explicit_or_session_token(request)
|
||||
if request_token:
|
||||
return request_token
|
||||
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
|
||||
|
||||
@@ -31,8 +31,7 @@ from app.gateway.roundtable_model_fallback import (
|
||||
reason_text,
|
||||
)
|
||||
from app.gateway.roundtable_run_policy import roundtable_run_policy
|
||||
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
|
||||
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD
|
||||
from app.gateway.roundtable_seat_skills import append_seat_role_boundary, append_seat_skill_directive
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
@@ -40,6 +39,7 @@ from app.gateway.routers._roundtable_seed import (
|
||||
SUMMARY_AGENT_ID,
|
||||
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.report_json import extract_report_json
|
||||
from deerflow.agents.roundtable_orchestrator.types import (
|
||||
@@ -806,6 +806,8 @@ class InProcessRoundtableGateway:
|
||||
seat_task = append_seat_skill_directive(
|
||||
agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False))
|
||||
)
|
||||
# 后台挂起路径与前台保持同一角色边界:席位只能执行具体任务,编排能力永远只属于总控。
|
||||
seat_task = append_seat_role_boundary(seat_task)
|
||||
# 每席位「推理深度覆盖」:业务链条里给该席位单独配了 seatMode → 用该档派生的
|
||||
# thinking/reasoning/subagent **覆盖作业级**(self._seat_*);未配则跟随作业级。
|
||||
# 作业级仍为 None 时落 policy 默认。与前端 useStep2Orchestration「覆盖优先、否则跟随
|
||||
|
||||
@@ -37,6 +37,22 @@ logger = logging.getLogger(__name__)
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
|
||||
_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:
|
||||
"""``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):
|
||||
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:
|
||||
return ""
|
||||
|
||||
|
||||
+9
-1
@@ -14,7 +14,14 @@
|
||||
|
||||
### 2. 生成报告文件
|
||||
|
||||
信息足够后,用 `write_file` 一次性写两份文件(路径固定,覆盖旧版):
|
||||
信息足够后,**必须用 `write_file` 分两次写文件**(路径固定,覆盖旧版)。目的是让用户在沙箱里**边写边看**,而不是等两份文件全部生成完才一次性弹出。
|
||||
|
||||
**顺序(违反即失败):**
|
||||
|
||||
1. **先**只调用一次 `write_file` 写 `/mnt/user-data/outputs/report.md`。这一次模型回复里禁止再夹带第二个 `write_file`、禁止夹带 `bash`。工具返回 OK 后立刻 `present_files`,参数只含这一份 md。
|
||||
2. 等第一步工具都返回后,再单独一次 `write_file` 写 `/mnt/user-data/outputs/task-reports.json`。写完后再 `present_files` 两份文件。
|
||||
3. **禁止**用 `bash` / `python -c` / 重定向 / `echo` / `cat <<` 创建或覆盖这两份交付文件。`bash` 只允许在入库步骤跑 `task_report_import.py`,或做只读校验(不得写入 `/mnt/user-data/outputs/`)。
|
||||
4. **禁止**在同一次模型回复里同时发出两个 `write_file`。
|
||||
|
||||
- `/mnt/user-data/outputs/report.md` —— Markdown 研判报告,章节按下方 14 个分类组织,每章给出要点与分析。
|
||||
- `/mnt/user-data/outputs/task-reports.json` —— 结构化 JSON 数组,**14 条记录一个不落**,每条形如 `{"contentJson": "<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` 转义。
|
||||
- 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。
|
||||
- **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
------------------
|
||||
通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
|
||||
**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
|
||||
总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream``
|
||||
驱动各 agent 真正推理。
|
||||
子 agent 完成摘要。总控输入、派活 prompt 和派活说明只留在总控 thread,
|
||||
绝不广播给席位;前端编排循环仍靠显式多次 ``/run/stream`` 驱动各 agent 真正推理。
|
||||
|
||||
Leader 终态帧(前端 ``isFinalStatusFrame``)
|
||||
-------------------------------------------
|
||||
@@ -48,7 +48,6 @@ Leader 终态帧(前端 ``isFinalStatusFrame``)
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
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_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.services import RUN_SCOPE_ORCHESTRATION_CHILD, internal_run_scope_headers
|
||||
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.routers._roundtable_seed import (
|
||||
ACTION_PLAN_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
@@ -78,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
|
||||
SUMMARY_AGENT_ID,
|
||||
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.extraction_spec import build_summary_directive
|
||||
from deerflow.config import get_app_config
|
||||
@@ -1554,16 +1553,15 @@ async def _leader_run(
|
||||
model_name: str,
|
||||
seat_meta: dict[str, dict[str, Any]] | None = None,
|
||||
synthesis_mode: bool = False,
|
||||
suppress_pre_broadcast: bool = False,
|
||||
suppress_pre_broadcast: bool = True,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||
|
||||
时序要点:
|
||||
1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身;
|
||||
1. 总控输入只进入总控 thread,绝不广播到席位 thread;
|
||||
2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
|
||||
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表;
|
||||
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
|
||||
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表并返回。
|
||||
|
||||
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
|
||||
|
||||
@@ -1575,6 +1573,10 @@ async def _leader_run(
|
||||
``synthesis_mode``:前端在「派活后让总控判断继续/综合」的轮次置真。为真时**额外**把各
|
||||
已交付席位的**完整交付正文**注入本轮输入(``_build_full_deliverables_block``),供总控写
|
||||
最终方案;平时(派活决策轮)只给摘要,让总控 thread 与每轮上下文保持精简、响应快。
|
||||
|
||||
``suppress_pre_broadcast`` 仅为兼容旧客户端保留,服务端始终按抑制处理。角色隔离不能由
|
||||
客户端布尔值决定:总控 prompt 可能含 ``agent_orchestration``,进入席位历史会诱导弱模型
|
||||
模仿总控加载编排能力。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
# 实读各席位 thread 的真实交付状态,构造「席位清单 + 客观交付状态(摘要)」块;综合轮再额外
|
||||
@@ -1623,21 +1625,9 @@ async def _leader_run(
|
||||
part for part in (seat_status_block, full_deliverables_block, new_message) if part
|
||||
)
|
||||
|
||||
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
|
||||
#
|
||||
# ``suppress_pre_broadcast``:DAG「每 stage 派活轮」(方案 A)置真——此时 ``new_message`` 是
|
||||
# 「请你作为总控…通过 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;席位需要的上下文
|
||||
# 由总控下发的具体 task、peer_deliveries 和席位交付广播提供。
|
||||
_ = suppress_pre_broadcast
|
||||
thread_id = agent_threads[agent_name]
|
||||
# 角色参数取自单一数据源 roundtable_run_policy(leader:禁 web_search、停
|
||||
# agent_orchestration 图、关 thinking + low reasoning——短决策、首字节快)。
|
||||
@@ -1677,7 +1667,7 @@ async def _leader_run(
|
||||
body = _mk_body(current_model)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s payload_built=%.3fs (broadcast running in background)",
|
||||
"[leader-timing] %s payload_built=%.3fs",
|
||||
agent_name,
|
||||
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)",
|
||||
agent_name,
|
||||
)
|
||||
if pre_broadcast_task is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await pre_broadcast_task
|
||||
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)
|
||||
leader_text = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
@@ -1839,22 +1810,9 @@ async def _leader_run(
|
||||
# 非法 id 已被丢弃;若 roster 校验后无合法派活(且重试已用尽),退化为空共识,
|
||||
# 而不是把假 id 漏给前端再报 400。
|
||||
dispatched: list[list[str]] = [[sub_name, task_text] for sub_name, task_text in valid_dispatch]
|
||||
# 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知
|
||||
# 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播
|
||||
# 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。
|
||||
# 关键路径优先:yield 派活帧,让前端立刻启动席位 special run。总控派活说明不广播给
|
||||
# 任何席位,避免席位从历史中模仿总控角色;席位间承上依靠完成交付广播 / peer_deliveries。
|
||||
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
|
||||
|
||||
|
||||
@@ -1937,6 +1895,8 @@ async def _special_run(
|
||||
seat_message = new_message
|
||||
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_role_boundary(seat_message)
|
||||
# 深链接业务规范(仅 summary 单例):把该业务的「业务链完整性核对」指令拼进本轮任务,强制总结报告
|
||||
# 核对产出相对业务链完不完整。business_code 为空(普通会商)/ 非写实业务 → directive 为 "",原样不变。
|
||||
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)。
|
||||
# 兼容 no_file 别名;缺省 False → 普通 seat,旧前端不传也不破坏。
|
||||
no_file: bool = bool(payload.get("parallel_no_file") or payload.get("no_file") or False)
|
||||
# 抑制 leader 预广播(仅 leader 用):DAG「每 stage 派活轮」置真,避免把总控的派活 prompt
|
||||
# 广播进席位 thread 让弱模型模仿派活(详见 _leader_run 的 suppress_pre_broadcast 注释)。
|
||||
# 缺省 False,recommend/chain 行为不变。
|
||||
suppress_pre_broadcast: bool = bool(payload.get("suppress_pre_broadcast") or False)
|
||||
# 旧客户端兼容字段。服务端已强制隔离总控 thread,因此无论请求值为何都不会执行 leader→seat
|
||||
# 广播;仍解析并透传只是保持调用签名稳定。
|
||||
suppress_pre_broadcast: bool = bool(payload.get("suppress_pre_broadcast", True))
|
||||
# 前序席位完整交付(仅 special 用):前端 DAG stage>0 席位带上此前各阶段的全文,网关注入
|
||||
# run context,供 read_peer_delivery 工具按需取全文(平时只看广播摘要,§12.9)。规整成
|
||||
# [{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"]
|
||||
@@ -32,7 +32,19 @@ concurrency:
|
||||
heartbeat_interval_seconds: 20
|
||||
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
|
||||
display_name: qwen3.6
|
||||
use: langchain_openai:ChatOpenAI
|
||||
@@ -647,3 +659,45 @@ task_deeplink:
|
||||
taskDirection: "正向引导 + 风险预警"
|
||||
taskName: "任务01:暑期文旅舆情综合研判"
|
||||
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 |
|
||||
| [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
|
||||
|
||||
| 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`
|
||||
> 对接前端: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. 目标与结论
|
||||
|
||||
@@ -189,6 +198,8 @@ ChatDev 适合参考的源码路径见第 15 节。
|
||||
|
||||
画布只保存 `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 短时票据
|
||||
|
||||
如 Coze 页面与 DeerFlow 不同域,新增短时、一次性的嵌入票据:
|
||||
@@ -581,6 +592,7 @@ DELETE /api/workflows/{workflow_id} # 软删除/归档
|
||||
PUT /api/workflows/{workflow_id}/draft # draft_revision 乐观锁
|
||||
POST /api/workflows/{workflow_id}/validate
|
||||
POST /api/workflows/{workflow_id}/publish
|
||||
POST /api/workflows/{workflow_id}/copy
|
||||
GET /api/workflows/{workflow_id}/versions
|
||||
GET /api/workflows/{workflow_id}/versions/{version_id}
|
||||
```
|
||||
@@ -602,18 +614,19 @@ GET /api/workflows/resources/subworkflows
|
||||
### 10.3 数据源
|
||||
|
||||
```text
|
||||
GET /api/workflow-data-sources
|
||||
POST /api/workflow-data-sources # 管理员或授权角色
|
||||
GET /api/workflow-data-sources/{id}
|
||||
PATCH /api/workflow-data-sources/{id}
|
||||
DELETE /api/workflow-data-sources/{id}
|
||||
POST /api/workflow-data-sources/{id}/test
|
||||
POST /api/workflow-data-sources/{id}/introspect
|
||||
POST /api/workflow-data-sources/{id}/validate-query
|
||||
GET /api/workflows/data-sources
|
||||
POST /api/workflows/data-sources
|
||||
GET /api/workflows/data-sources/{id}
|
||||
PUT /api/workflows/data-sources/{id}
|
||||
DELETE /api/workflows/data-sources/{id}
|
||||
POST /api/workflows/data-sources/{id}/introspect
|
||||
POST /api/workflows/data-sources/{id}/validate-query
|
||||
```
|
||||
|
||||
`introspect` 只返回白名单内 schema/table/column 元数据,不返回样例敏感数据。
|
||||
|
||||
HTTP 接口资源的创建体使用 kind=http、baseUrl、allowedMethods 和可选 headers;headers 是只写字段,服务端加密后不会出现在响应。资源目录 GET /api/workflows/resources/data-sources 返回前端所需的 dataSourceId、baseUrl、allowedMethods、description 等精简字段。
|
||||
|
||||
### 10.4 运行
|
||||
|
||||
```text
|
||||
@@ -743,7 +756,7 @@ POST /api/workflow-embed/tickets/exchange
|
||||
- SQL 只读:连接临时失败可有限重试;语法/权限错误不重试。
|
||||
- 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 取消
|
||||
|
||||
@@ -1083,6 +1096,8 @@ tests/test_workflow_migrations.py
|
||||
- 多 Gateway 实例、租约竞争、数据库短暂失败、SSE 大量重连压测。
|
||||
- 事件保留、清理和 artifact 生命周期。
|
||||
|
||||
阶段 6 已落地:`WorkflowRetentionCleaner` 消费 `workflows.retention.*`;`POST /api/workflows/runs/{run_id}/retry`;SSE `Last-Event-ID`;单机租约竞争与 SSE 重连风暴测试。制品文件仍走 sandbox/artifact,不另做工作流专属文件回收。
|
||||
|
||||
### 阶段 7:试点与扩展(持续)
|
||||
|
||||
- 选择 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。
|
||||
+1
-1
@@ -57,7 +57,7 @@ _UPSTREAM_PATTERNS = ("bad_response_status_code", "bad response status",
|
||||
"expecting ',' delimiter", "expecting value", "expecting property name",
|
||||
"unexpected character", "unterminated string", "jsondecodeerror")
|
||||
_TRANSIENT_EXC_NAMES = {"APITimeoutError", "APIConnectionError", "InternalServerError",
|
||||
"ReadError", "RemoteProtocolError", "TimeoutError"}
|
||||
"ReadError", "RemoteProtocolError", "ReadTimeout", "ConnectTimeout", "TimeoutError"}
|
||||
|
||||
|
||||
def classify_exception(exc: BaseException) -> str:
|
||||
|
||||
+68
-20
@@ -43,6 +43,41 @@ if TYPE_CHECKING:
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -347,26 +382,39 @@ class DeerFlowCompletionBackend:
|
||||
text_parts: list[str] = []
|
||||
usage: dict[str, Any] = {}
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
|
||||
if self._cancel is not None:
|
||||
self._cancel.raise_if_cancelled()
|
||||
reasoning = _reasoning_delta(chunk)
|
||||
if reasoning and on_reasoning is not None:
|
||||
await on_reasoning(reasoning)
|
||||
# A few OpenAI-compatible models put their reasoning in the
|
||||
# ordinary content channel instead of ``reasoning_content``.
|
||||
# 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
|
||||
# Isolate from the parent LangGraph run: inherited callbacks would
|
||||
# replay every report token into the chat ``messages`` stream, so the
|
||||
# left-hand bubble and the sandbox would write the same prose at once.
|
||||
# Custom ``report_delta`` events remain the only live preview channel.
|
||||
isolated_config = _parent_isolated_run_config(f"deep_research:{operation}")
|
||||
reset_parent_config = _clear_inherited_runnable_config(isolated_config)
|
||||
try:
|
||||
async for chunk in model.astream(
|
||||
to_langchain_messages(messages),
|
||||
config=isolated_config,
|
||||
**kwargs,
|
||||
):
|
||||
if self._cancel is not None:
|
||||
self._cancel.raise_if_cancelled()
|
||||
reasoning = _reasoning_delta(chunk)
|
||||
if reasoning and on_reasoning is not None:
|
||||
await on_reasoning(reasoning)
|
||||
# A few OpenAI-compatible models put their reasoning in the
|
||||
# ordinary content channel instead of ``reasoning_content``.
|
||||
# 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()
|
||||
if inline_reasoning_tail and on_reasoning is not None:
|
||||
|
||||
+18
-9
@@ -72,9 +72,7 @@ async def harvest_thread_materials(
|
||||
visible text (its collection小结) and doubles as a fallback material when
|
||||
the thread produced no harvestable tool results.
|
||||
"""
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(
|
||||
{"configurable": {"thread_id": runtime_thread_id, "checkpoint_ns": ""}}
|
||||
)
|
||||
checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": runtime_thread_id, "checkpoint_ns": ""}})
|
||||
if checkpoint_tuple is None:
|
||||
logger.warning("deep-research harvest: no checkpoint for thread %s", runtime_thread_id)
|
||||
return [], ""
|
||||
@@ -121,9 +119,14 @@ def harvest_messages_materials(
|
||||
|
||||
|
||||
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] = []
|
||||
for message in messages:
|
||||
for message_index, message in enumerate(messages):
|
||||
if _message_type(message) != "tool":
|
||||
continue
|
||||
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):
|
||||
material = result_to_material(item, query=envelope_query)
|
||||
material.metadata["retrieval_channel"] = "chat_web_search"
|
||||
material.metadata["message_index"] = message_index
|
||||
materials.append(material)
|
||||
continue
|
||||
materials.extend(
|
||||
_harvest_generic_payload(payload, tool_name=tool_name, query=query)
|
||||
)
|
||||
materials.extend(_harvest_generic_payload(payload, tool_name=tool_name, query=query, message_index=message_index))
|
||||
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)."""
|
||||
source_type = _TOOL_SOURCE_TYPES.get(tool_name, "other")
|
||||
materials: list[ResearchMaterial] = []
|
||||
@@ -177,6 +185,7 @@ def _harvest_generic_payload(payload: Any, *, tool_name: str, query: str) -> lis
|
||||
metadata={
|
||||
"retrieval_channel": "chat_tool",
|
||||
"tool_name": tool_name,
|
||||
**({"message_index": message_index} if message_index is not None else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
+78
-22
@@ -198,35 +198,63 @@ class BasicRunner:
|
||||
cancellation.raise_if_cancelled()
|
||||
|
||||
if not all_materials:
|
||||
knowledge_fill = _allows_knowledge_fill(request.config.report_instruction)
|
||||
await events.emit(
|
||||
"warning",
|
||||
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}」相关的材料,无法生成报告。"
|
||||
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)
|
||||
if not knowledge_fill:
|
||||
report_md = (
|
||||
f"# {request.query}\n\n"
|
||||
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)
|
||||
context_comp.set_materials(deduped)
|
||||
selected_ids = [m.id for m in deduped]
|
||||
await events.emit(
|
||||
"sources_curated",
|
||||
phase="curating",
|
||||
payload={"selectedIds": selected_ids, "removedIds": [], "reason": "deduped"},
|
||||
)
|
||||
|
||||
selected_ids = [m.id for m in deduped]
|
||||
await events.emit(
|
||||
"sources_curated",
|
||||
phase="curating",
|
||||
payload={"selectedIds": selected_ids, "removedIds": [], "reason": "deduped"},
|
||||
)
|
||||
# ── 4. compress context ────────────────────────────────────────
|
||||
await events.emit(
|
||||
"phase_changed",
|
||||
phase="compressing",
|
||||
payload={"from": "curating", "to": "compressing"},
|
||||
)
|
||||
if cancellation:
|
||||
cancellation.raise_if_cancelled()
|
||||
|
||||
# ── 4. compress context ────────────────────────────────────────
|
||||
await events.emit("phase_changed", phase="compressing", payload={"from": "curating", "to": "compressing"})
|
||||
if cancellation:
|
||||
cancellation.raise_if_cancelled()
|
||||
|
||||
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)
|
||||
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 ────────────────────────────────
|
||||
await events.emit("phase_changed", phase="writing", payload={"from": "compressing", "to": "writing"})
|
||||
@@ -403,6 +431,13 @@ class BasicRunner:
|
||||
)
|
||||
outline_block = _report_outline_instruction(outline)
|
||||
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":
|
||||
# Recency bias: keep the skeleton immediately before "请撰写报告".
|
||||
context_block = (
|
||||
@@ -427,6 +462,8 @@ class BasicRunner:
|
||||
if filled_preamble:
|
||||
await emitter.add(filled_preamble + "\n\n")
|
||||
system = _SYSTEM
|
||||
if system_extra:
|
||||
system += system_extra
|
||||
if request.config.structure_mode == "fixed" and (outline or "").strip():
|
||||
system += "撰写时必须原样沿用用户大纲中的章节标题与层级,不得改写成通用报告套式。"
|
||||
result = await llm.stream_complete(
|
||||
@@ -624,12 +661,31 @@ def ensure_markdown_standalone_lines(text: str) -> str:
|
||||
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:
|
||||
"""Render confirmed generation requirements as bounded, untrusted content."""
|
||||
if not report_instruction:
|
||||
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 (
|
||||
"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>"
|
||||
)
|
||||
|
||||
|
||||
+164
-68
@@ -1,5 +1,5 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
@@ -38,45 +38,112 @@ logger = logging.getLogger(__name__)
|
||||
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
|
||||
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
|
||||
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">'
|
||||
|
||||
_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(
|
||||
*,
|
||||
system_prompt: str,
|
||||
app_config: AppConfig,
|
||||
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
|
||||
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
|
||||
<orchestration_capability_boundary>
|
||||
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
|
||||
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
|
||||
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
|
||||
</orchestration_capability_boundary>
|
||||
""".strip()
|
||||
|
||||
|
||||
def _enforce_coordinator_only_tool_exclusions(
|
||||
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 "-"
|
||||
)
|
||||
excluded_tools: list[str] | None,
|
||||
) -> list[str] | None:
|
||||
"""Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
|
||||
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
|
||||
return excluded_tools
|
||||
effective = list(excluded_tools or [])
|
||||
if "agent_orchestration" not in effective:
|
||||
effective.append("agent_orchestration")
|
||||
return effective
|
||||
|
||||
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 _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
|
||||
"""Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
|
||||
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
|
||||
return system_prompt
|
||||
return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
|
||||
|
||||
|
||||
def _filter_available_skills_for_excluded_tools(
|
||||
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:
|
||||
@@ -426,6 +493,18 @@ def _build_middlewares(
|
||||
if agent_name == FORCED_RESEARCH_AGENT_ID:
|
||||
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
|
||||
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
|
||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||
writing_artifact_path = None
|
||||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||||
requested_excluded_tools = cfg.get("excluded_tools") or None
|
||||
# 只有写作模式产物 report.md 才走续写;其它 leftover .md 不得跳过管线。
|
||||
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:
|
||||
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
|
||||
|
||||
runtime_available_skills = merge_collector_skill_allowlist(
|
||||
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 = merge_collector_skill_allowlist(
|
||||
agent_id, runtime_available_skills, runtime_extra_skills(cfg)
|
||||
)
|
||||
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(
|
||||
{
|
||||
@@ -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
|
||||
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),
|
||||
tools=get_available_tools(
|
||||
model_name=model_name,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
tools=get_available_tools(
|
||||
model_name=model_name,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
+ [setup_agent],
|
||||
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
system_prompt=apply_prompt_template(
|
||||
@@ -680,21 +773,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
default_tools = get_available_tools(
|
||||
model_name=model_name,
|
||||
groups=agent_config.tool_groups if agent_config else None,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
if agent_id == FORCED_RESEARCH_AGENT_ID:
|
||||
default_tools = filter_forced_research_tools(default_tools)
|
||||
if is_notebook_mode:
|
||||
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
|
||||
|
||||
default_tools = [notebook_search_tool] + default_tools
|
||||
# 写作模式首轮(尚无 md 产物):挂深度研究报告工具,报告由管线生成而非
|
||||
# 模型手工 write_file。续写轮(已有 writing_artifact_path)不挂,保持
|
||||
# 原有「直接编辑该文件」行为。不受 tool_groups/excluded_tools 过滤——
|
||||
# 它是写作模式合约的核心交付手段。
|
||||
# 写作模式首轮(尚无 report.md):挂深度研究报告工具,报告由管线生成而非
|
||||
# 模型手工 write_file。续写轮(已有 report.md)不挂,保持原有「直接编辑
|
||||
# 该文件」行为。不受 tool_groups/excluded_tools 过滤——它是写作模式合约
|
||||
# 的核心交付手段。
|
||||
if writing_mode and not writing_artifact_path:
|
||||
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,
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
|
||||
)
|
||||
|
||||
_log_es_query_routing_registration(
|
||||
system_prompt=system_prompt,
|
||||
app_config=resolved_app_config,
|
||||
agent_id=agent_id,
|
||||
available_skills=prompt_available_skills,
|
||||
)
|
||||
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
|
||||
|
||||
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),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
|
||||
+57
-69
@@ -7,11 +7,11 @@ import threading
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
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.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.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>
|
||||
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
|
||||
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
|
||||
- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。
|
||||
- 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。
|
||||
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
|
||||
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
|
||||
</ordinary_qa_markdown_format>
|
||||
<ordinary_qa_markdown_format>
|
||||
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
|
||||
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
|
||||
- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。
|
||||
- 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。
|
||||
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
|
||||
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
|
||||
</ordinary_qa_markdown_format>
|
||||
"""
|
||||
|
||||
|
||||
def _ordinary_qa_markdown_format_enabled() -> bool:
|
||||
"""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.
|
||||
"""
|
||||
|
||||
"""Return the admin-controlled formal Markdown prompt switch."""
|
||||
try:
|
||||
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
|
||||
except Exception:
|
||||
@@ -640,15 +634,15 @@ You: "Deploying to staging..." [proceed]
|
||||
{acp_section}
|
||||
</working_directory>
|
||||
|
||||
<response_style>
|
||||
- Clear and Concise: Avoid over-formatting unless requested
|
||||
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
||||
- Action-Oriented: Focus on delivering results, not explaining processes
|
||||
</response_style>
|
||||
|
||||
{ordinary_qa_format_section}
|
||||
|
||||
<citations>
|
||||
<response_style>
|
||||
- Clear and Concise: Avoid over-formatting unless requested
|
||||
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
||||
- Action-Oriented: Focus on delivering results, not explaining processes
|
||||
</response_style>
|
||||
|
||||
{ordinary_qa_format_section}
|
||||
|
||||
<citations>
|
||||
**CRITICAL: Always include citations when using web search results**
|
||||
|
||||
- **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 ""
|
||||
)
|
||||
if writing_mode and not writing_artifact_path:
|
||||
# 与通用模板一致:对话行为照旧,交付时刻的「写 md」动作交给
|
||||
# deep_research_report 管线;资料不足时先经 ask_clarification 问用户。
|
||||
# 对话行为照旧;只有开始写 md 时才走 deep_research_report。资料不足也直接开写。
|
||||
writing_mode_section = """<writing_mode>
|
||||
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:
|
||||
`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)
|
||||
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,
|
||||
re-call the tool with the same `topic`/`focus` plus `retrieve=true` (search more) or `retrieve=false` (existing materials only).
|
||||
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question.
|
||||
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for it.
|
||||
- After the tool returns, reply briefly (2-3 sentences) summarizing the core conclusions; do not duplicate the report inline.
|
||||
- If harvested materials are thin, still call `deep_research_report` once and let it write immediately.
|
||||
Do NOT ask the user whether to search for more, and do NOT call `ask_clarification` for insufficient materials.
|
||||
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
|
||||
- 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.
|
||||
- Only if the tool fails, tell the user and offer to retry.
|
||||
</writing_mode>
|
||||
"""
|
||||
@@ -1423,7 +1415,7 @@ def build_report_structure_setup_prompt(
|
||||
"""
|
||||
|
||||
|
||||
def apply_prompt_template(
|
||||
def apply_prompt_template(
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
*,
|
||||
@@ -1435,11 +1427,11 @@ def apply_prompt_template(
|
||||
is_bootstrap: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
memory_injection_enabled: bool = True,
|
||||
rag_mode_enabled: bool = False,
|
||||
is_notebook_mode: bool = False,
|
||||
notebook_source_ids: list[int] | None = None,
|
||||
is_scheduled_run: bool = False,
|
||||
) -> str:
|
||||
rag_mode_enabled: bool = False,
|
||||
is_notebook_mode: bool = False,
|
||||
notebook_source_ids: list[int] | None = None,
|
||||
is_scheduled_run: bool = False,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
# Get memory context
|
||||
@@ -1489,29 +1481,25 @@ def apply_prompt_template(
|
||||
else ""
|
||||
)
|
||||
if writing_mode and not writing_artifact_path:
|
||||
# 首轮:对话行为一切照旧,仅在交付时刻把「写 md」动作交给
|
||||
# deep_research_report 管线(收割对话资料 + 通用分析结构)。资料不足时
|
||||
# 工具会返回指令,让模型经 ask_clarification 征询用户是否补充检索。
|
||||
# 对话行为一切照旧;只有开始写 md 时才把交付交给 deep_research_report。
|
||||
# 资料不足也直接开写,不再经 ask_clarification 征询用户是否补充检索。
|
||||
writing_mode_section = """
|
||||
<writing_mode>
|
||||
Writing mode is enabled for this run (first report turn — no existing artifact).
|
||||
|
||||
**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`
|
||||
(or any file-writing tool) to create the report, and do not call `present_files` for it.
|
||||
- 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.
|
||||
- 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:
|
||||
- `topic`: the user's subject/thesis, copied faithfully.
|
||||
- `focus`: the user's extra requirements (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
|
||||
- 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 — the refined one settled in the clarifications, not the raw first message.
|
||||
- `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, then writes the full
|
||||
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
|
||||
whether to search for more materials, and after the user answers, re-call `deep_research_report` with the same `topic`/`focus` plus
|
||||
`retrieve=true` (user agreed) or `retrieve=false` (user declined — write with what exists).
|
||||
NEVER pass `retrieve=true` before the user has answered — the tool rejects it and bounces you back to the question.
|
||||
- Do NOT write the report body yourself with `write_file`, and do NOT call `present_files` for the report — the tool already handles both.
|
||||
- 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.
|
||||
- If harvested materials are thin, still call `deep_research_report` once and let it start the writing pipeline immediately.
|
||||
Do NOT ask the user whether to search for more materials, and do NOT call `ask_clarification` for that reason.
|
||||
- Search with your normal conversation tools. `deep_research_report` only writes the report file from materials already in this conversation; it does not search.
|
||||
- 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.
|
||||
- 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.
|
||||
</writing_mode>
|
||||
"""
|
||||
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).
|
||||
network_policy_section = _build_network_policy_section()
|
||||
rag_mode_section = (
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
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>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
else ""
|
||||
)
|
||||
ordinary_qa_format_section = (
|
||||
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION
|
||||
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 is_notebook_mode
|
||||
and not is_scheduled_run
|
||||
else ""
|
||||
)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
else ""
|
||||
)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||
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,
|
||||
subagent_section=subagent_section,
|
||||
subagent_reminder=subagent_reminder,
|
||||
subagent_thinking=subagent_thinking,
|
||||
acp_section=acp_and_mounts_section,
|
||||
ordinary_qa_format_section=ordinary_qa_format_section,
|
||||
)
|
||||
subagent_thinking=subagent_thinking,
|
||||
acp_section=acp_and_mounts_section,
|
||||
ordinary_qa_format_section=ordinary_qa_format_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + _current_time_context()
|
||||
|
||||
|
||||
+9
@@ -226,6 +226,15 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
"InternalServerError",
|
||||
"ReadError", # httpx.ReadError: connection dropped mid-stream
|
||||
"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"
|
||||
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
Reference in new issue
Block a user