feat: add unified strategy project workflow

This commit is contained in:
Administrator committed 2026-09-09 15:40:29 +08:00
1 parent 67d12929fc
commit a26fd32eba
28 files changed
+3382 -191

No files matched your search

@@ -51,6 +51,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api";
@@ -69,6 +73,7 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
bootstrapResumePath,
isBootstrapThread,
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : (
filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquare
className={cn(
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
<span className="truncate">
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => {
const href = pathOfThread(thread);
const isActive = href === pathname;
const projectType = myProjectConversationTypeOfThread(thread);
return (
<Link
key={thread.thread_id}
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
<span className="truncate">
{projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</Link>
);
})}
@@ -60,6 +60,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
@@ -75,6 +79,7 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
)}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquare
className={cn(
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
@@ -25,6 +25,7 @@ export function ModelNameSelect({
className,
triggerClassName,
defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: {
value: string | null;
onChange: (modelName: string | null) => void;
@@ -32,6 +33,7 @@ export function ModelNameSelect({
className?: string;
triggerClassName?: string;
defaultLabel?: string;
title?: string;
}) {
const { models } = useModels();
return (
@@ -42,7 +44,7 @@ export function ModelNameSelect({
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
title={title}
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
@@ -0,0 +1,51 @@
import { FolderKanban } from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<FolderKanban
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
className={cn("size-3.5 shrink-0 text-violet-500", className)}
/>
);
}
export function MyProjectThreadBadge({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<span
className={cn(
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
>
{MY_PROJECT_CONVERSATION_LABELS[type]}
</span>
);
}
@@ -49,7 +49,11 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -63,7 +67,8 @@ import {
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -245,7 +250,8 @@ export function RecentChatList({
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -279,8 +285,10 @@ export function RecentChatList({
}
aria-label="智能体创建会话"
/>
) : (
compact && (
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
@@ -294,9 +302,15 @@ export function RecentChatList({
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -65,6 +65,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
match: (pathname) =>
pathname.startsWith("/page/strategy/qa/projects") ||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
},
{
id: "workspace-library",
label: "资料库",
@@ -21,6 +21,7 @@ import {
FileBox,
FileSearch,
FileText,
FolderKanban,
Gauge,
Grid3X3,
HelpCircle,
@@ -69,10 +70,11 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/qa/multi-agent-roundtable",
},
{
id: "strategy-selection",
label: "策略优选",
icon: Sparkles,
path: "/page/strategy/qa/strategy-selection",
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
aliasPaths: ["/page/strategy/qa/strategy-selection"],
},
// {
// id: "position-roundtable",
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
myProjectConversationTypeFromSearchParams,
myProjectConversationTypeOfThread,
myProjectThreadMetadata,
withMyProjectConversationMarker,
} from "./project-metadata";
test("project conversation type is read from persisted metadata", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("strategy"),
}),
"strategy",
);
assert.equal(
myProjectConversationTypeOfThread({
metadata: { project_source: "elsewhere", project_type: "qa" },
}),
null,
);
});
test("metadata wins over the compatibility context fallback", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("solution"),
context: myProjectThreadMetadata("qa"),
}),
"solution",
);
});
test("new-chat route marker round-trips all supported project types", () => {
for (const type of ["qa", "strategy", "solution"] as const) {
const path = withMyProjectConversationMarker(
"/page/strategy/chats/new?keep=1",
type,
);
const params = new URLSearchParams(path.split("?", 2)[1]);
assert.equal(params.get("keep"), "1");
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
}
});
@@ -0,0 +1,94 @@
/**
* Stable marker for conversations entered from 「我的项目」.
*
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
* folder-style conversation projects, while this marker describes the product
* mode that created (or explicitly continued) a conversation.
*/
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
export type MyProjectConversationType = "qa" | "strategy" | "solution";
export const MY_PROJECT_CONVERSATION_LABELS: Record<
MyProjectConversationType,
string
> = {
qa: "普通问答",
strategy: "策略优选",
solution: "方案生成",
};
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
"qa",
"strategy",
"solution",
]);
function typeFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationType | null {
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
export function myProjectThreadMetadata(
type: MyProjectConversationType,
): Record<string, unknown> {
return {
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
[MY_PROJECT_THREAD_TYPE_KEY]: type,
};
}
/** Read persisted metadata first; context is only a compatibility fallback. */
export function myProjectConversationTypeOfThread(thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
}): MyProjectConversationType | null {
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
}
export function myProjectConversationTypeFromSearchParams(params: {
get(name: string): string | null;
}): MyProjectConversationType | null {
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
/** Add an explicit, one-shot marker to a new-conversation route. */
export function withMyProjectConversationMarker(
path: string,
type: MyProjectConversationType,
): string {
const hashIndex = path.indexOf("#");
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
const queryIndex = pathWithoutHash.indexOf("?");
const pathname =
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
const params = new URLSearchParams(
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
);
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
const search = params.toString();
return `${pathname}${search ? `?${search}` : ""}${hash}`;
}
+28 -14
View File
@@ -64,6 +64,10 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import {
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
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";
@@ -285,16 +289,28 @@ export default function ChatPage() {
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification();
const projectConversationType =
myProjectConversationTypeFromSearchParams(searchParams);
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
const metadata: Record<string, unknown> = projectConversationType
? myProjectThreadMetadata(projectConversationType)
: {};
if (knowledgeSelectionReady) {
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}, [
knowledgeSelectionReady,
projectConversationType,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
]);
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
threadMetadata: newThreadMetadata,
isMock,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
@@ -405,7 +421,9 @@ export default function ChatPage() {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
const created = await getAPIClient(isMock).threads.create({
metadata: newThreadMetadata ?? {},
});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
@@ -465,13 +483,8 @@ export default function ChatPage() {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: {
...newThreadMetadata,
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
},
});
sessionStorage.setItem(
@@ -490,7 +503,7 @@ export default function ChatPage() {
);
saveLastAgentId(selectedAgentId);
router.push(
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
);
} catch (err) {
agentHandoffPendingRef.current = false;
@@ -549,6 +562,7 @@ export default function ChatPage() {
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
newThreadMetadata,
agentHandoffPending,
isMock,
router,
@@ -5,6 +5,7 @@ import {
ChevronDown,
CircleHelp,
Clock3,
FolderKanban,
LoaderCircle,
MessageSquareText,
PanelLeftClose,
@@ -20,7 +21,12 @@ import {
} from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Toaster } from "sonner";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import {
@@ -29,28 +35,41 @@ import {
useScheduledTaskRuns,
useScheduledTasks,
} from "@/core/scheduled-tasks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { useThreadStream, useThreads } from "@/core/threads/hooks";
import {
myProjectConversationTypeOfThread,
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import {
OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import "./official-deerflow-entry.css";
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
type OfficialPage =
| "chat"
| "projects"
| "agents"
| "agent-chat"
| "scheduled"
| "settings";
function pageFromPath(pathname: string): OfficialPage {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat";
}
if (path.startsWith("/workspace/projects")) return "projects";
if (path.startsWith("/workspace/agents")) return "agents";
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/settings")) return "settings";
@@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null;
}
function parseProjectId(pathname: string) {
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
return match ? decodeURIComponent(match[1]!) : null;
}
function readableError(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
@@ -159,20 +183,36 @@ function ChatWorkspace({
routeThreadId: string;
}) {
const navigate = useNavigate();
const location = useLocation();
const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new";
const projectConversationType = isNewThread
? myProjectConversationTypeFromSearchParams(
new URLSearchParams(location.search),
)
: null;
// A new conversation uses the same client-generated id approach as the
// existing ChatPage. The backend owns the persisted id once streaming starts.
const [draftThreadId] = useState<string>(() => uuid() as string);
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
const { agent } = useAgent(agentId, { enabled: !!agentId });
const [localSettings] = useLocalSettings();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : routeThreadId,
assistantId: agentId ?? undefined,
threadMetadata: projectConversationType
? myProjectThreadMetadata(projectConversationType)
: undefined,
context: {
...localSettings.context,
// This compact official shell has no model/mode controls of its own.
// Do not inherit a hidden selection saved by the customized workspace
// (for example an obsolete or quota-exhausted model); an omitted model
// lets the backend resolve the current system default or the agent's
// globally configured model.
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
...(agentId ? { agent_id: agentId } : {}),
},
onStart: (createdThreadId) => {
@@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname),
[location.pathname],
);
const projectId = useMemo(
() => parseProjectId(location.pathname),
[location.pathname],
);
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
useThreads({
limit: 20,
@@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() {
select: ["thread_id", "updated_at", "values", "metadata"],
});
const go = (path: string) => navigate(path);
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
const nav = [
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
] as const;
@@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() {
) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? (
threads.map((chat) => (
<button
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id}
onClick={() => {
const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string"
? chat.metadata.agent_id
: null;
go(
agentId
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
);
}}
type="button"
>
<MessageSquareText aria-hidden="true" size={15} />
<span>{titleOfThread(chat, "未命名对话")}</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
))
threads.map((chat) => {
const projectType = myProjectConversationTypeOfThread(chat);
return (
<button
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id}
onClick={() => {
const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string"
? chat.metadata.agent_id
: null;
go(
agentId
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
);
}}
type="button"
>
{projectType ? (
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
) : (
<MessageSquareText aria-hidden="true" size={15} />
)}
<span className="official-recent-chat-copy">
<span className="official-recent-chat-title">
{titleOfThread(chat, "未命名对话")}
</span>
{projectType && (
<MyProjectThreadBadge type={projectType} />
)}
</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
);
})
) : (
<p className="official-sidebar-empty">暂无历史对话</p>
)}
@@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() {
routeThreadId={chatRoute.threadId}
/>
)}
{page === "projects" && (
projectId ? (
<StrategySelectionPage
key={projectId}
projectBasePath={projectBasePath}
projectId={projectId}
/>
) : (
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "agents" && (
<AgentsPage
openAgent={(agentId) =>
@@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() {
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
{page === "settings" && <SettingsPage />}
</div>
<Toaster position="top-center" />
</main>
);
}
+21 -2
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
@@ -78,6 +78,7 @@ import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import { ChatRuntime } from "./WorkspaceRoutes";
@@ -99,6 +100,11 @@ function StrategyChatThreadLayout() {
);
}
function StrategyProjectWorkbenchRoute() {
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
return <StrategySelectionPage key={projectId} projectId={projectId} />;
}
export default function StrategyRoutes() {
const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -111,7 +117,20 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="qa/general" replace />} />
<Route path="qa/general" element={<GeneralQA />} />
<Route path="qa/strategy-selection" element={<StrategySelectionPage />} />
<Route
path="qa/projects"
element={
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
}
/>
<Route
path="qa/projects/:project_id"
element={<StrategyProjectWorkbenchRoute />}
/>
<Route
path="qa/strategy-selection"
element={<Navigate to="/page/strategy/qa/projects" replace />}
/>
<Route
path="qa/expert-qa"
element={
@@ -64,6 +64,8 @@
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; }
.official-recent-chat-title { flex: 1; min-width: 0; }
.official-recent-chat small { color: #acaca9; font-size: 11px; }
.official-recent-chat.is-active { background: #efefed; color: #252525; }
.official-sidebar-loading,
@@ -2,6 +2,9 @@ import { useEffect, useMemo, useState } from "react";
import { Bot, Check, LoaderCircle, Save, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -15,6 +18,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { getAgent, updateAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { cn } from "@/lib/utils";
import { STRATEGY_AGENT_ROLES } from "./constants";
@@ -24,15 +28,33 @@ interface AgentConfigDialogProps {
onOpenChange: (open: boolean) => void;
}
function parseSkills(value: string): string[] {
return [...new Set(value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean))];
interface AgentConfigDraft {
soul: string;
model: string | null;
skills: string[];
}
function normalizeSkills(skills: string[]) {
return [...new Set(skills)].sort((left, right) => left.localeCompare(right));
}
function sameSkills(left: string[], right: string[]) {
const normalizedLeft = normalizeSkills(left);
const normalizedRight = normalizeSkills(right);
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((skill, index) => skill === normalizedRight[index])
);
}
export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps) {
const [selectedId, setSelectedId] = useState<string>(STRATEGY_AGENT_ROLES[0].id);
const [agent, setAgent] = useState<Agent | null>(null);
const [soul, setSoul] = useState("");
const [skillsText, setSkillsText] = useState("");
const [model, setModel] = useState<string | null>(null);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [savedDraft, setSavedDraft] = useState<AgentConfigDraft | null>(null);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
@@ -41,6 +63,31 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
() => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!,
[selectedId],
);
const {
skills,
isLoading: isSkillsLoading,
error: skillsError,
} = useAllSkillsAdmin(open);
const selectableSkills = useMemo(
() =>
skills.filter((skill) => {
const globallyAvailable =
Boolean(skill.builtin) || Boolean(skill.published);
return (
skill.enabled &&
(skill.state ?? "active") === "active" &&
globallyAvailable
);
}),
[skills],
);
const dirty = Boolean(
agent &&
savedDraft &&
(soul !== savedDraft.soul ||
model !== savedDraft.model ||
!sameSkills(selectedSkills, savedDraft.skills)),
);
useEffect(() => {
if (!open || !selectedId) return;
@@ -48,12 +95,20 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
setLoading(true);
setError("");
setAgent(null);
setSavedDraft(null);
void getAgent(selectedId)
.then((data) => {
if (!active) return;
setAgent(data);
setSoul(data.soul ?? "");
setSkillsText((data.skills ?? []).join("\n"));
const nextDraft = {
soul: data.soul ?? "",
model: data.model ?? null,
skills: data.skills ?? [],
};
setSoul(nextDraft.soul);
setModel(nextDraft.model);
setSelectedSkills(nextDraft.skills);
setSavedDraft(nextDraft);
})
.catch((reason: unknown) => {
if (!active) return;
@@ -77,11 +132,18 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
try {
const updated = await updateAgent(agent.id, {
soul: soul.trim(),
skills: parseSkills(skillsText),
model,
skills: selectedSkills,
});
setAgent(updated);
setSoul(updated.soul ?? "");
setSkillsText((updated.skills ?? []).join("\n"));
setModel(updated.model ?? null);
setSelectedSkills(updated.skills ?? []);
setSavedDraft({
soul: updated.soul ?? "",
model: updated.model ?? null,
skills: updated.skills ?? [],
});
toast.success(`${updated.name} 配置已保存`);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
@@ -90,8 +152,21 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
}
};
const confirmDiscardChanges = () =>
!dirty || window.confirm("当前智能体配置尚未保存,确定放弃这些修改吗?");
const selectRole = (roleId: string) => {
if (roleId === selectedId || saving || !confirmDiscardChanges()) return;
setSelectedId(roleId);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && (saving || !confirmDiscardChanges())) return;
onOpenChange(nextOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="h-[min(780px,calc(100vh-48px))] max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1080px]">
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
<DialogTitle className="flex items-center gap-2">
@@ -99,7 +174,7 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
策略智能体配置
</DialogTitle>
<DialogDescription>
每个流程角色对应一个默认智能体。管理员可修改 SOUL 和技能;保存后下一次运行即生效。
这是全局统一配置,不属于某一个项目。管理员保存后,所有策略项目下一次运行都会使用这里的模型、SOUL 和技能。
</DialogDescription>
</DialogHeader>
@@ -116,7 +191,7 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
key={role.id}
type="button"
disabled={saving}
onClick={() => setSelectedId(role.id)}
onClick={() => selectRole(role.id)}
className={cn(
"w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
selected
@@ -160,15 +235,22 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
</div>
) : agent ? (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3 rounded-lg border bg-muted/20 p-3 text-sm dark:border-[#173d68] dark:bg-[#031b38]">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] gap-3 rounded-lg border bg-muted/20 p-3 text-sm dark:border-[#173d68] dark:bg-[#031b38]">
<div>
<span className="text-xs text-muted-foreground">当前名称</span>
<div className="mt-1 font-medium">{agent.name}</div>
</div>
<div>
<label className="block">
<span className="text-xs text-muted-foreground">运行模型</span>
<div className="mt-1 font-medium">{agent.model || "使用系统默认模型"}</div>
</div>
<ModelNameSelect
value={model}
onChange={setModel}
disabled={saving}
defaultLabel="使用系统默认模型"
title="选择该全局智能体运行时使用的大模型;未指定时使用系统默认模型"
triggerClassName="mt-1 w-full bg-background"
/>
</label>
</div>
<label className="block">
@@ -179,40 +261,52 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
value={soul}
disabled={saving}
onChange={(event) => setSoul(event.target.value)}
className="min-h-[300px] resize-y font-mono text-xs leading-6"
className="min-h-[220px] resize-y font-mono text-xs leading-6"
placeholder="定义智能体的角色、分析方法、边界和输出要求…"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium">技能</span>
<Textarea
value={skillsText}
disabled={saving}
onChange={(event) => setSkillsText(event.target.value)}
placeholder="多个技能可用逗号分隔"
rows={3}
className="resize-y text-sm"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
可用逗号、中文逗号或换行分隔;留空表示不绑定专用技能。
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">技能库选择</span>
<Badge variant="secondary">已选 {selectedSkills.length} 个</Badge>
</div>
<p className="text-xs leading-5 text-muted-foreground">
从系统技能库勾选该智能体可使用的技能;这里保存的是全局白名单,不接受手工输入技能名。
</p>
</label>
<SkillTransferSelector
skills={selectableSkills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
{skillsError ? (
<p className="text-xs text-destructive">技能库加载失败,请稍后重试。</p>
) : null}
</div>
</div>
) : null}
</div>
</div>
<DialogFooter className="border-t px-6 py-4 dark:border-[#173d68]">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" disabled={saving} onClick={() => handleOpenChange(false)}>
关闭
</Button>
<Button disabled={!agent || loading || saving} onClick={() => void save()}>
<Button disabled={!agent || loading || saving || isSkillsLoading} onClick={() => void save()}>
{saving ? <LoaderCircle className="animate-spin" /> : <Save />}
保存当前智能体
</Button>
</DialogFooter>
</DialogContent>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDetailSkillName(null);
}}
/>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,336 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import {
EMPTY_TASK_DRAFT,
INITIAL_INTENT_TURNS,
STRATEGY_ANGLES,
TAIWAN_STRAIT_DEMO_EVALUATIONS,
TAIWAN_STRAIT_DEMO_OUTPUTS,
TAIWAN_STRAIT_DEMO_SYNTHESIS,
TAIWAN_STRAIT_DEMO_TASK,
TAIWAN_STRAIT_DEMO_TURNS,
} from "./constants";
import type {
AgentOutputState,
IntentTurn,
StrategyEvaluation,
StrategyStage,
StrategyTaskDraft,
} from "./types";
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
const SNAPSHOT_KIND = "strategy-selection-project";
const DEMO_JUDGE_CONTENT = `### 评判结论
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
**组合提醒:** 对外沟通节奏、市场稳定措施和风险等级调整必须使用同一事实基线;任何高风险政策动作均需设置冷静期、法律复核与退出条件。`;
export interface StrategyProjectSnapshot {
version: 1;
stage: StrategyStage;
taskDraft: StrategyTaskDraft;
turns: IntentTurn[];
outputs: Record<string, AgentOutputState>;
judgeStatus: AgentOutputState["status"];
judgeContent: string;
evaluations: StrategyEvaluation[];
selectedStrategyIds: string[];
synthesis: string;
synthesisStatus: AgentOutputState["status"];
isDemo: boolean;
workflowStale: boolean;
}
export interface StrategyProjectSummary {
id: string;
title: string;
brief: string;
createdAt: string | null;
updatedAt: string | null;
stage: StrategyStage;
questionCount: number;
angleCount: number;
completedStrategyCount: number;
hasEvaluation: boolean;
hasSolution: boolean;
isDemo: boolean;
workflowStale: boolean;
}
export interface StrategyProject extends StrategyProjectSummary {
snapshot: StrategyProjectSnapshot;
}
interface ParallelTaskResponse {
id: string;
title?: string;
brief?: string | null;
agents?: Array<{ agent_id: string; name?: string; avatarType?: string | null }>;
messages?: Array<Record<string, unknown>> | null;
created_at?: string | null;
updated_at?: string | null;
}
interface ParallelTaskListResponse {
tasks?: ParallelTaskResponse[];
}
interface SnapshotEnvelope extends Record<string, unknown> {
kind: typeof SNAPSHOT_KIND;
version: 1;
snapshot: StrategyProjectSnapshot;
}
const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
...task,
selectedAngleIds: [...task.selectedAngleIds],
});
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
turns.map((turn) => ({ ...turn, streaming: false }));
const cloneOutputs = (
outputs: Record<string, AgentOutputState>,
): Record<string, AgentOutputState> =>
Object.fromEntries(
Object.entries(outputs).map(([id, output]) => [
id,
{ ...output, steps: [...(output.steps ?? [])] },
]),
);
const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
evaluations.map((item) => ({
...item,
strengths: [...item.strengths],
risks: [...item.risks],
}));
export function createEmptyStrategySnapshot(): StrategyProjectSnapshot {
return {
version: 1,
stage: "intent",
taskDraft: cloneTask(EMPTY_TASK_DRAFT),
turns: cloneTurns(INITIAL_INTENT_TURNS),
outputs: {},
judgeStatus: "idle",
judgeContent: "",
evaluations: [],
selectedStrategyIds: [],
synthesis: "",
synthesisStatus: "idle",
isDemo: false,
workflowStale: false,
};
}
export function createDemoStrategySnapshot(): StrategyProjectSnapshot {
return {
version: 1,
stage: "workspace",
taskDraft: cloneTask(TAIWAN_STRAIT_DEMO_TASK),
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
outputs: cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS),
judgeStatus: "done",
judgeContent: DEMO_JUDGE_CONTENT,
evaluations: cloneEvaluations(TAIWAN_STRAIT_DEMO_EVALUATIONS),
selectedStrategyIds: TAIWAN_STRAIT_DEMO_EVALUATIONS.filter(
(item) => item.score >= 88,
).map((item) => item.agentId),
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
synthesisStatus: "done",
isDemo: true,
workflowStale: false,
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | null {
const envelope = task.messages?.find(
(message) => message.kind === SNAPSHOT_KIND && message.version === 1,
);
if (!isObject(envelope) || !isObject(envelope.snapshot)) return null;
const snapshot = envelope.snapshot as unknown as StrategyProjectSnapshot;
if (
snapshot.version !== 1 ||
(snapshot.stage !== "intent" && snapshot.stage !== "workspace") ||
!isObject(snapshot.taskDraft) ||
!Array.isArray(snapshot.taskDraft.selectedAngleIds) ||
!Array.isArray(snapshot.turns) ||
!isObject(snapshot.outputs) ||
!Array.isArray(snapshot.evaluations) ||
!Array.isArray(snapshot.selectedStrategyIds)
) {
return null;
}
return {
...snapshot,
workflowStale: snapshot.workflowStale === true,
taskDraft: cloneTask(snapshot.taskDraft),
turns: cloneTurns(snapshot.turns),
outputs: cloneOutputs(snapshot.outputs),
evaluations: cloneEvaluations(snapshot.evaluations),
selectedStrategyIds: [...snapshot.selectedStrategyIds],
};
}
function toProject(task: ParallelTaskResponse): StrategyProject | null {
const snapshot = readSnapshot(task);
if (!snapshot) return null;
const completedStrategyCount = Object.values(snapshot.outputs).filter(
(output) => output.status === "done" && output.content.trim(),
).length;
return {
id: task.id,
title: snapshot.taskDraft.name.trim() || task.title?.trim() || "未命名策略项目",
brief: snapshot.taskDraft.detail.trim() || task.brief?.trim() || "尚未补充项目说明",
createdAt: task.created_at ?? null,
updatedAt: task.updated_at ?? null,
stage: snapshot.stage,
questionCount: snapshot.turns.filter((turn) => turn.role === "user").length,
angleCount: snapshot.taskDraft.selectedAngleIds.length,
completedStrategyCount,
hasEvaluation:
!snapshot.workflowStale &&
snapshot.judgeStatus === "done" &&
Boolean(snapshot.judgeContent.trim()),
hasSolution:
!snapshot.workflowStale &&
snapshot.synthesisStatus === "done" &&
Boolean(snapshot.synthesis.trim()),
isDemo: snapshot.isDemo,
workflowStale: snapshot.workflowStale,
snapshot,
};
}
async function readError(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
const endpoint = (suffix = "") =>
`${getBackendBaseURL()}/api/parallel-agent-tasks${suffix}`;
async function getParallelTask(id: string): Promise<ParallelTaskResponse> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`));
if (!response.ok) throw new Error(await readError(response));
return (await response.json()) as ParallelTaskResponse;
}
export async function listStrategyProjects(): Promise<StrategyProjectSummary[]> {
const response = await apiFetch(endpoint());
if (!response.ok) throw new Error(await readError(response));
const body = (await response.json()) as ParallelTaskListResponse;
const candidates = (body.tasks ?? []).filter((task) =>
task.id.startsWith(STRATEGY_PROJECT_ID_PREFIX),
);
const records = await Promise.allSettled(
candidates.map((task) => getParallelTask(task.id)),
);
return records
.flatMap((result) => {
if (result.status !== "fulfilled") return [];
const project = toProject(result.value);
return project ? [project] : [];
})
.sort((a, b) =>
String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")),
);
}
export async function getStrategyProject(id: string): Promise<StrategyProject> {
if (!id.startsWith(STRATEGY_PROJECT_ID_PREFIX)) {
throw new Error("这不是一个策略优选项目");
}
const task = await getParallelTask(id);
const project = toProject(task);
if (!project) throw new Error("项目数据不完整,无法恢复策略工作台");
return project;
}
function projectAgents(snapshot: StrategyProjectSnapshot) {
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
const angleAgents = STRATEGY_ANGLES.filter((angle) => selected.has(angle.id)).map(
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
);
return [
{ agent_id: "strategy-selection-intent", name: "意图理解智能体" },
...angleAgents,
{ agent_id: "strategy-selection-judge", name: "策略评判智能体" },
{ agent_id: "strategy-selection-solution", name: "方案整理智能体" },
];
}
function snapshotEnvelope(snapshot: StrategyProjectSnapshot): SnapshotEnvelope {
return {
kind: SNAPSHOT_KIND,
version: 1,
snapshot,
};
}
export async function createStrategyProject(
snapshot: StrategyProjectSnapshot = createEmptyStrategySnapshot(),
): Promise<StrategyProject> {
const id = `${STRATEGY_PROJECT_ID_PREFIX}${createClientId()}`;
const response = await apiFetch(endpoint(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id,
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
brief: snapshot.taskDraft.detail.trim() || null,
agents: projectAgents(snapshot),
messages: [snapshotEnvelope(snapshot)],
}),
});
if (!response.ok) throw new Error(await readError(response));
const project = toProject((await response.json()) as ParallelTaskResponse);
if (!project) throw new Error("项目已创建,但返回数据无法识别");
return project;
}
export async function saveStrategyProject(
id: string,
snapshot: StrategyProjectSnapshot,
options: {
keepalive?: boolean;
signal?: AbortSignal;
} = {},
): Promise<StrategyProject> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
keepalive: options.keepalive,
signal: options.signal,
body: JSON.stringify({
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
brief: snapshot.taskDraft.detail.trim() || null,
agents: projectAgents(snapshot),
messages: [snapshotEnvelope(snapshot)],
}),
});
if (!response.ok) throw new Error(await readError(response));
const project = toProject((await response.json()) as ParallelTaskResponse);
if (!project) throw new Error("项目已保存,但返回数据无法识别");
return project;
}
export async function deleteStrategyProject(id: string): Promise<void> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "DELETE",
});
if (!response.ok && response.status !== 404) {
throw new Error(await readError(response));
}
}
@@ -542,6 +542,8 @@ Self-contained product knowledge base. **All knowledge capability lives in this
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
Strategy-selection fan-out (`routers/parallel_agents.py`) must inspect its final AI message with the shared `is_llm_error_message` detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits `agent_error`; never wrap it in `agent_done`. Regression coverage: `tests/test_roundtable_model_fallback.py`.
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
**Homepage selector (agent picker on the new-chat page)** ? single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
@@ -1000,6 +1002,15 @@ scheduler runs once per deployment. Per run:
- Supports `supports_vision` flag for image understanding models
- Config values starting with `$` resolved as environment variables
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
- Generic `langchain_openai:ChatOpenAI` entries are transparently wrapped by
`PatchedChatOpenAI`. Besides preserving reasoning/tool-call metadata, the
patch recognizes compatible gateways that encode failures inside HTTP 200
payloads (`base_resp.status_code != 0` with null/empty `choices`) in both
streaming and non-streaming paths. It raises the stable
`OpenAICompatibleUpstreamError`; `LLMErrorHandlingMiddleware` retries
transient concurrency/inference failures, while genuine quota/balance
failures are classified as non-retriable and surfaced immediately. Embedded
vendor failures therefore cannot become empty successful assistant turns.
### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
+8 -1
View File
@@ -471,7 +471,7 @@ Custom agents are indexed in the `agents` database table. The `id` column is the
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
The strategy-selection workbench owns nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. The angle agents use the existing `policy-knowledge` skill and read/search tool groups, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation. Regression coverage: `tests/test_strategy_selection_seed.py`.
The strategy-selection workbench owns nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. The angle agents use the existing `policy-knowledge` skill and read/search tool groups, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation. The parallel strategy stream treats a final `is_llm_error_message` fallback, empty/thinking-only response, or punctuation-only provider degeneration as `agent_error`, never as a successful `agent_done`. Regression coverage: `tests/test_strategy_selection_seed.py` and `tests/test_roundtable_model_fallback.py`.
### AI Writing Intranet Retrieval
@@ -545,6 +545,13 @@ the configured model has an explicit, proxy-supported thinking control. This
prevents DeerFlow's connection check from sending vendor-specific `thinking`
fields that a generic OpenAI-compatible gateway may reject.
OpenAI-compatible gateways that return vendor errors inside an HTTP 200
response (for example, a non-zero `base_resp.status_code` with empty/null
`choices`) are treated as upstream failures in both streaming and
non-streaming calls. DeerFlow retries transient upstream responses instead of
accepting an empty assistant turn, while genuine quota/balance failures are
classified as non-retriable and surfaced immediately instead of being retried.
### Running
**Full Application** (from project root):
@@ -15,7 +15,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
from app.gateway.routers._strategy_selection_seed import required_agent_ids as required_strategy_agent_ids
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
@@ -23,13 +24,17 @@ from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.cascade import sync_agent_skills_to_available
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import SkillCategory
from deerflow.skills.usage import all_entries as get_skill_usage_entries
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["agents"])
_STRATEGY_SELECTION_AGENT_IDS = frozenset(required_strategy_agent_ids())
class AgentResponse(BaseModel):
@@ -276,6 +281,109 @@ def _validate_storage_id(agent_id: str) -> str:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def _validate_strategy_agent_skills(
requested_skills: list[str] | None,
*,
app_config: AppConfig,
skill_store: SkillStore,
) -> list[str] | None:
"""Validate a global strategy role's skill allowlist.
Strategy-selection agents are built-in roles shared by every project and
every user. Their allowlists may therefore reference only canonical skill
names that exist on disk, are enabled/active, and are globally visible:
deployment-owned public skills, explicit built-in rows, or custom skills
currently published by their owner. In particular, an administrator must
not be able to attach a different user's unpublished skill merely because
``?admin=true`` exposes it in the management catalog.
"""
if requested_skills is None:
return None
normalized: list[str] = []
for raw_name in requested_skills:
name = raw_name.strip()
if not name:
raise HTTPException(
status_code=422,
detail="Strategy agent skill names cannot be blank",
)
if name not in normalized:
normalized.append(name)
try:
skills = get_or_new_skill_storage(app_config=app_config).load_skills(
enabled_only=False
)
ownership = {
str(record.get("name") or ""): record
for record in await skill_store.list_all()
if record.get("name")
}
usage_entries = get_skill_usage_entries()
except HTTPException:
raise
except Exception as exc: # noqa: BLE001 - stable API response
logger.exception("Failed to validate strategy-agent skill allowlist")
raise HTTPException(status_code=503, detail="Skill catalog is unavailable") from exc
by_name = {skill.name: skill for skill in skills}
unknown = [name for name in normalized if name not in by_name]
if unknown:
raise HTTPException(
status_code=422,
detail=f"Unknown strategy agent skills: {', '.join(unknown)}",
)
unavailable = [
name
for name in normalized
if not by_name[name].enabled
or str((usage_entries.get(name) or {}).get("state") or "active") != "active"
]
if unavailable:
raise HTTPException(
status_code=422,
detail=f"Disabled or archived strategy agent skills: {', '.join(unavailable)}",
)
not_global: list[str] = []
for name in normalized:
skill = by_name[name]
if skill.category != SkillCategory.CUSTOM:
continue
record = ownership.get(name)
if record is not None and (
record.get("owner_user_id") is None or bool(record.get("published"))
):
continue
not_global.append(name)
if not_global:
raise HTTPException(
status_code=422,
detail=(
"Strategy agent skills must be globally available; "
f"unpublished or unregistered custom skills: {', '.join(not_global)}"
),
)
return normalized
def _ensure_strategy_agent_deletable(agent_id: str) -> None:
"""Keep fixed strategy role ids alive; their SOUL/skills remain editable."""
if agent_id in _STRATEGY_SELECTION_AGENT_IDS:
raise HTTPException(
status_code=409,
detail=(
f"Strategy role agent '{agent_id}' cannot be deleted; "
"edit its SOUL and skills instead"
),
)
def _write_agent_config(
agent_id: str,
*,
@@ -953,7 +1061,14 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_description = update_data.get("description", current.get("description") or "")
existing_extras = _load_config_extras(agent_id)
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
if "skills" in fields_set and agent_id in _STRATEGY_SELECTION_AGENT_IDS:
next_skills = await _validate_strategy_agent_skills(
request.skills,
app_config=get_config(http_request),
skill_store=get_skill_store(http_request),
)
else:
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
next_model = request.model if "model" in fields_set else existing_extras["model"]
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
@@ -1079,6 +1194,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
_, edit_mode = await _get_editable_or_raise(
store, agent_id, user_id, is_admin=is_admin
)
_ensure_strategy_agent_deletable(agent_id)
if edit_mode == "admin":
deleted = await store.delete_any(agent_id)
@@ -31,13 +31,15 @@ from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator
from collections.abc import AsyncIterator
from typing import Any
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.roundtable_model_fallback import is_llm_error_message, reason_text
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
@@ -50,6 +52,7 @@ from app.gateway.routers.multi_agent import (
_stream_upstream,
_validate_agent_id,
)
from deerflow.agents.roundtable_orchestrator.delivery import visible_seat_text
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__)
@@ -58,6 +61,17 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8
# Strategy roles are expected to return a substantive task/strategy/evaluation,
# not merely punctuation or a broken provider placeholder. Counting Unicode
# alphanumerics keeps the check language-neutral while rejecting outputs such as
# a long run of "!!!", which otherwise look non-empty to the UI.
MIN_MEANINGFUL_CONTENT_CHARS = 20
def _has_substantive_content(content: str) -> bool:
visible = visible_seat_text(content)
return sum(character.isalnum() for character in visible) >= MIN_MEANINGFUL_CONTENT_CHARS
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
@@ -231,6 +245,9 @@ async def _run_one_agent(
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = msg
break
failure_reason = is_llm_error_message(last_ai)
if failure_reason:
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
content = _flatten_content(last_ai.get("content", ""))
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
@@ -250,6 +267,8 @@ async def _run_one_agent(
"tool_calls": final_tool_calls,
}
else:
if not _has_substantive_content(content):
raise RuntimeError("智能体未生成有效正文,请重试或更换全局模型")
yield {
"status": f"子智能体 {agent_id} 完成",
"content": content,
@@ -408,8 +427,24 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
await _emit_tool(tc)
if not done_sent:
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
buffered_content = "".join(buffer)
failure_reason = is_llm_error_message(
{"type": "ai", "content": buffered_content}
)
if failure_reason:
raise RuntimeError(
f"模型调用失败:{reason_text(failure_reason)}"
)
if not _has_substantive_content(buffered_content):
raise RuntimeError(
"智能体未生成有效正文,请重试或更换全局模型"
)
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": "".join(buffer)}
{
"type": "agent_done",
"agent_id": agent_id,
"content": buffered_content,
}
)
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
@@ -26,7 +26,7 @@
"enabled": true
},
"policy-knowledge": {
"enabled": false
"enabled": true
},
"html-page-builder": {
"enabled": true
@@ -96,9 +96,11 @@ _BUSY_PATTERNS = (
"稍后重试",
"请稍后重试",
)
_QUOTA_PATTERNS = (
"insufficient_quota",
"quota",
_QUOTA_PATTERNS = (
"insufficient_quota",
"insufficient balance",
"token plan limit",
"quota",
"billing",
"credit",
"payment",
@@ -122,8 +124,10 @@ _AUTH_PATTERNS = (
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
# response). These are almost always transient inference-side issues and safe
# to retry.
_UPSTREAM_GARBAGE_PATTERNS = (
"bad_response_status_code",
_UPSTREAM_GARBAGE_PATTERNS = (
"openai-compatible upstream application error",
"null value for 'choices'",
"bad_response_status_code",
"bad response status",
"expecting ',' delimiter",
"expecting value",
@@ -25,7 +25,8 @@ message that originally carried it:
from __future__ import annotations
from typing import Any
from collections.abc import Mapping
from typing import Any
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk
@@ -34,6 +35,65 @@ from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages
class OpenAICompatibleUpstreamError(RuntimeError):
"""An OpenAI-compatible gateway reported an error inside a HTTP 200 body.
Several compatible gateways do not use an HTTP error status for quota,
concurrency, or inference failures. Instead they return ``choices=null``
(or an empty streamed chunk) together with a vendor ``base_resp`` object.
LangChain raises a generic ``TypeError`` for the non-streaming shape and
treats the streaming shape as an empty successful response. Raising one
stable exception here lets DeerFlow's model middleware retry both paths.
"""
def __init__(self, *, code: Any, message: str | None = None) -> None:
self.code = code
detail = _bounded_error_message(message)
suffix = f": {detail}" if detail else ""
super().__init__(f"OpenAI-compatible upstream application error (code={code}){suffix}")
def _bounded_error_message(value: Any, *, limit: int = 500) -> str:
"""Return a log-safe, bounded one-line provider message."""
if not isinstance(value, str):
return ""
normalized = " ".join(value.split())
return normalized[:limit]
def _application_error(payload: Mapping[str, Any]) -> tuple[Any, str | None] | None:
"""Extract a non-success ``base_resp`` error from a compatible payload."""
raw = payload.get("base_resp") or payload.get("baseResp")
if not isinstance(raw, Mapping):
return None
code = raw.get("status_code")
if code is None:
code = raw.get("statusCode")
if code is None:
code = raw.get("code")
# MiniMax-compatible responses use 0 for success. Be conservative for
# gateways that put informational metadata in ``base_resp``.
normalized_code = str(code).strip().lower() if code is not None else ""
if normalized_code in {"", "0", "200", "ok", "success"}:
return None
message = raw.get("status_msg")
if message is None:
message = raw.get("statusMsg")
if message is None:
message = raw.get("message")
return code, message if isinstance(message, str) else None
def _raise_for_application_error(payload: Mapping[str, Any]) -> None:
error = _application_error(payload)
if error is not None:
code, message = error
raise OpenAICompatibleUpstreamError(code=code, message=message)
class PatchedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
@@ -100,16 +160,20 @@ class PatchedChatOpenAI(ChatOpenAI):
normalize_chat_payload_messages(payload)
return payload
def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
generation_chunk = super()._convert_chunk_to_generation_chunk(
chunk,
default_chunk_class,
def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
# Some compatible gateways return a vendor error event followed by a
# normal-looking usage chunk, all under HTTP 200. Detect the first
# event before LangChain converts it into an empty AIMessageChunk.
_raise_for_application_error(chunk)
generation_chunk = super()._convert_chunk_to_generation_chunk(
chunk,
default_chunk_class,
base_generation_info,
)
if generation_chunk is None:
@@ -130,11 +194,12 @@ class PatchedChatOpenAI(ChatOpenAI):
self,
response: dict | Any,
generation_info: dict | None = None,
) -> ChatResult:
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
result = super()._create_chat_result(response, generation_info)
response_dict = response if isinstance(response, dict) else response.model_dump()
choices = response_dict.get("choices", [])
) -> ChatResult:
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
response_dict = response if isinstance(response, dict) else response.model_dump()
_raise_for_application_error(response_dict)
result = super()._create_chat_result(response, generation_info)
choices = response_dict.get("choices", [])
generations: list[ChatGeneration] = []
for index, generation in enumerate(result.generations):
@@ -11,9 +11,11 @@ import asyncio
from types import SimpleNamespace
import httpx
from langchain_core.messages import AIMessage
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk
from deerflow.agents.middlewares.llm_error_handling_middleware import LLM_ERROR_MARKER, LLMErrorHandlingMiddleware
from deerflow.models.patched_openai import OpenAICompatibleUpstreamError, PatchedChatOpenAI
def _middleware() -> LLMErrorHandlingMiddleware:
@@ -26,6 +28,10 @@ def _middleware() -> LLMErrorHandlingMiddleware:
return middleware
def _openai_model() -> PatchedChatOpenAI:
return PatchedChatOpenAI(model="test-model", api_key="test-key")
def test_httpx_read_timeout_is_transient() -> None:
assert _middleware()._classify_error(httpx.ReadTimeout("read timed out")) == (True, "transient")
@@ -46,6 +52,98 @@ def test_value_error_stays_generic() -> None:
assert _middleware()._classify_error(ValueError("boom")) == (False, "generic")
def test_openai_compatible_embedded_error_is_retriable_upstream() -> None:
error = RuntimeError("OpenAI-compatible upstream application error (code=2056)")
assert _middleware()._classify_error(error) == (True, "upstream")
def test_openai_compatible_null_choices_error_is_retriable_upstream() -> None:
error = TypeError("Received response with null value for 'choices'.")
assert _middleware()._classify_error(error) == (True, "upstream")
def test_insufficient_balance_stays_non_retriable_quota() -> None:
"""The 200-body compatibility fix must not hide a real billing failure."""
class PaymentError(RuntimeError):
status_code = 402
error = PaymentError("Insufficient Balance")
assert _middleware()._classify_error(error) == (False, "quota")
def test_minimax_token_plan_limit_stays_non_retriable_quota() -> None:
error = OpenAICompatibleUpstreamError(
code=2056,
message="Token Plan limit reached",
)
assert _middleware()._classify_error(error) == (False, "quota")
def test_non_stream_embedded_application_error_is_raised() -> None:
response = {
"id": "failed-response",
"object": "chat.completion",
"created": 0,
"model": "test-model",
"choices": None,
"base_resp": {
"status_code": 2056,
"status_msg": "Token Plan limit reached",
},
}
with pytest.raises(OpenAICompatibleUpstreamError, match=r"code=2056") as exc_info:
_openai_model()._create_chat_result(response)
assert exc_info.value.code == 2056
assert "Token Plan limit reached" in str(exc_info.value)
def test_stream_embedded_application_error_is_raised() -> None:
chunk = {
"id": "failed-response",
"object": "chat.completion.chunk",
"created": 0,
"model": "test-model",
"choices": [],
"base_resp": {
"status_code": 2056,
"status_msg": "Token Plan limit reached",
},
}
with pytest.raises(OpenAICompatibleUpstreamError, match=r"code=2056"):
_openai_model()._convert_chunk_to_generation_chunk(chunk, AIMessageChunk, None)
def test_normal_empty_usage_chunk_is_not_treated_as_error() -> None:
chunk = {
"id": "usage-response",
"object": "chat.completion.chunk",
"created": 0,
"model": "test-model",
"choices": [],
"usage": {
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
},
}
result = _openai_model()._convert_chunk_to_generation_chunk(chunk, AIMessageChunk, None)
assert result is not None
assert result.message.usage_metadata == {
"input_tokens": 3,
"output_tokens": 2,
"total_tokens": 5,
"input_token_details": {},
"output_token_details": {},
}
def test_read_timeout_is_retried_and_recovers() -> None:
"""第一次 ReadTimeout、第二次成功:必须重试并返回真实结果。"""
middleware = _middleware()
@@ -13,9 +13,14 @@
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
import app.gateway.roundtable_model_fallback as mf
import app.gateway.routers.parallel_agents as parallel_agents
from app.gateway.roundtable_model_fallback import (
fallback_model_chain,
is_llm_error_message,
@@ -56,6 +61,181 @@ def test_is_llm_error_message_normal_is_none() -> None:
assert is_llm_error_message({"type": "ai", "content": ""}) is None
def test_parallel_agent_run_raises_instead_of_completing_llm_fallback(monkeypatch) -> None:
fallback_message = {
"type": "ai",
"content": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
}
async def fake_stream_upstream(*_args, **_kwargs):
yield None, [f"data: {json.dumps({'messages': [fallback_message]}, ensure_ascii=False)}"]
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
async def collect() -> list[object]:
return [
item
async for item in parallel_agents._run_one_agent(
None, # type: ignore[arg-type]
"http://gateway.test",
{"Authorization": "Bearer test"},
"strategy-selection-intent",
"thread-id",
"test task",
"",
)
]
try:
asyncio.run(collect())
except RuntimeError as exc:
assert "模型调用失败" in str(exc)
else:
raise AssertionError("LLM fallback message must not be emitted as agent_done")
def test_parallel_agent_run_rejects_punctuation_only_delivery(monkeypatch) -> None:
invalid_message = {"type": "ai", "content": "!" * 500}
async def fake_stream_upstream(*_args, **_kwargs):
yield None, [f"data: {json.dumps({'messages': [invalid_message]}, ensure_ascii=False)}"]
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
async def collect() -> list[object]:
return [
item
async for item in parallel_agents._run_one_agent(
None, # type: ignore[arg-type]
"http://gateway.test",
{"Authorization": "Bearer test"},
"strategy-selection-angle-security",
"thread-id",
"test task",
"",
)
]
try:
asyncio.run(collect())
except RuntimeError as exc:
assert "未生成有效正文" in str(exc)
else:
raise AssertionError("Punctuation-only output must not be emitted as agent_done")
def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> None:
clarification_message = {
"type": "ai",
"content": "",
"tool_calls": [
{
"id": "clarify-1",
"name": "ask_clarification",
"args": {
"question": "请确认策略时间范围。",
"clarification_type": "single_choice",
"options": ["短期", "长期"],
"allow_custom": True,
},
}
],
}
async def fake_stream_upstream(*_args, **_kwargs):
yield None, [
f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"
]
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
async def collect() -> list[object]:
return [
item
async for item in parallel_agents._run_one_agent(
None, # type: ignore[arg-type]
"http://gateway.test",
{"Authorization": "Bearer test"},
"strategy-selection-intent",
"thread-id",
"test task",
"",
)
]
events = asyncio.run(collect())
assert len(events) == 1
event = events[0]
assert isinstance(event, dict)
assert event["type"] == "clarification"
assert event["content"] == ""
assert event["clarification"]["question"] == "请确认策略时间范围。"
def test_parallel_agent_substantive_content_accepts_chinese_and_english() -> None:
assert parallel_agents._has_substantive_content("这是一份包含目标、前置条件、阶段路径和验收标准的完整策略。")
assert parallel_agents._has_substantive_content(
"A complete strategy with objectives, prerequisites, milestones, and measurable outcomes."
)
assert not parallel_agents._has_substantive_content("<think>internal reasoning only</think>!!!")
@pytest.mark.parametrize(
("buffered_content", "expected_error"),
[
("大模型本次调用未能成功完成,请稍后再试或更换模型。", "模型调用失败"),
("", "未生成有效正文"),
("<think>internal reasoning only</think>", "未生成有效正文"),
("!" * 500, "未生成有效正文"),
],
)
def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
monkeypatch,
buffered_content: str,
expected_error: str,
) -> None:
async def fake_run_one_agent(*_args, **_kwargs):
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': buffered_content}], ensure_ascii=False)}"
@asynccontextmanager
async def fake_client(*_args, **_kwargs):
yield object()
async def fake_create_thread(*_args, **_kwargs):
return "thread-id"
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
async def collect() -> list[dict[str, object]]:
response = await parallel_agents.run_parallel_agents_stream(
SimpleNamespace(),
{
"task_name": "test task",
"brief": "test brief",
"agent_ids": ["strategy-selection-intent"],
},
)
events: list[dict[str, object]] = []
async for chunk in response.body_iterator:
text = chunk.decode() if isinstance(chunk, bytes) else chunk
for line in text.splitlines():
if line.startswith("data:"):
events.append(json.loads(line[5:].strip()))
return events
events = asyncio.run(collect())
assert not any(event.get("type") == "agent_done" for event in events)
errors = [event for event in events if event.get("type") == "agent_error"]
assert len(errors) == 1
assert expected_error in str(errors[0].get("message"))
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
@@ -6,9 +6,13 @@ from pathlib import Path
import pytest
import yaml
from fastapi import HTTPException
from app.gateway.routers import _strategy_selection_seed as seed
from app.gateway.routers import agents as agents_router
from deerflow.config.paths import Paths
from deerflow.persistence.skills.memory import MemorySkillStore
from deerflow.skills.types import Skill, SkillCategory
@pytest.fixture()
@@ -73,6 +77,18 @@ def test_seed_assets_use_role_appropriate_existing_skills_and_tools() -> None:
assert solution["tool_groups"] == ["file:write"]
def test_default_strategy_skills_are_enabled_in_the_system_catalog() -> None:
extensions_path = Path(__file__).resolve().parents[1] / "extensions_config.json"
extensions = yaml.safe_load(extensions_path.read_text(encoding="utf-8")) or {}
configured = extensions.get("skills") or {}
# A missing entry means enabled by default. An explicit false would make
# the seeded allowlist impossible to save under the server-side validator
# and silently remove the skill from strategy runs.
for skill_name in ("intent-clarification-policy", "policy-knowledge"):
assert (configured.get(skill_name) or {}).get("enabled", True) is True
def test_seed_assets_define_machine_readable_handoff_contracts() -> None:
intent_soul = _asset(seed.STRATEGY_INTENT_AGENT_ID, "SOUL.md").read_text(encoding="utf-8")
judge_soul = _asset(seed.STRATEGY_JUDGE_AGENT_ID, "SOUL.md").read_text(encoding="utf-8")
@@ -113,3 +129,120 @@ def test_seed_creates_once_and_never_overwrites_admin_changes(tmp_paths: Paths)
assert seed.ensure_strategy_selection_agents() == []
assert customized_soul.read_text(encoding="utf-8") == "管理员现场修改"
class _SkillStorage:
def __init__(self, skills: list[Skill]) -> None:
self._skills = skills
def load_skills(self, *, enabled_only: bool) -> list[Skill]:
if enabled_only:
return [skill for skill in self._skills if skill.enabled]
return list(self._skills)
def _skill(name: str, *, category: SkillCategory, enabled: bool = True) -> Skill:
directory = Path("skills") / category.value / name
return Skill(
name=name,
description=name,
license=None,
skill_dir=directory,
skill_file=directory / "SKILL.md",
relative_path=Path(name),
category=category,
enabled=enabled,
)
@pytest.mark.asyncio
async def test_strategy_skill_validation_accepts_only_global_catalog_names(
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_store = MemorySkillStore()
await skill_store.create(
{
"name": "published-custom",
"owner_user_id": "publisher",
"published": True,
}
)
storage = _SkillStorage(
[
_skill("public-skill", category=SkillCategory.PUBLIC),
_skill("published-custom", category=SkillCategory.CUSTOM),
]
)
monkeypatch.setattr(agents_router, "get_or_new_skill_storage", lambda **_: storage)
monkeypatch.setattr(
agents_router,
"get_skill_usage_entries",
lambda: {},
)
result = await agents_router._validate_strategy_agent_skills(
[" public-skill ", "published-custom", "public-skill"],
app_config=object(), # type: ignore[arg-type]
skill_store=skill_store,
)
assert result == ["public-skill", "published-custom"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("requested", "detail_fragment"),
[
("missing", "Unknown strategy agent skills"),
("disabled", "Disabled or archived strategy agent skills"),
("stale", "Disabled or archived strategy agent skills"),
("private-custom", "must be globally available"),
("orphan-custom", "must be globally available"),
],
)
async def test_strategy_skill_validation_rejects_unselectable_names(
monkeypatch: pytest.MonkeyPatch,
requested: str,
detail_fragment: str,
) -> None:
skill_store = MemorySkillStore()
await skill_store.create(
{
"name": "private-custom",
"owner_user_id": "owner",
"published": False,
}
)
storage = _SkillStorage(
[
_skill("disabled", category=SkillCategory.PUBLIC, enabled=False),
_skill("stale", category=SkillCategory.PUBLIC),
_skill("private-custom", category=SkillCategory.CUSTOM),
_skill("orphan-custom", category=SkillCategory.CUSTOM),
]
)
monkeypatch.setattr(agents_router, "get_or_new_skill_storage", lambda **_: storage)
monkeypatch.setattr(
agents_router,
"get_skill_usage_entries",
lambda: {"stale": {"state": "stale"}},
)
with pytest.raises(HTTPException) as exc_info:
await agents_router._validate_strategy_agent_skills(
[requested],
app_config=object(), # type: ignore[arg-type]
skill_store=skill_store,
)
assert exc_info.value.status_code == 422
assert detail_fragment in str(exc_info.value.detail)
def test_fixed_strategy_role_agents_cannot_be_deleted() -> None:
for agent_id in seed.required_agent_ids():
with pytest.raises(HTTPException) as exc_info:
agents_router._ensure_strategy_agent_deletable(agent_id)
assert exc_info.value.status_code == 409
agents_router._ensure_strategy_agent_deletable("ordinary-custom-agent")