feat: add unified strategy project workflow
This commit is contained in:
1 parent
67d12929fc
commit
a26fd32eba
28 files changed
+3382
-191
No files matched your search
@@ -51,6 +51,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
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 { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
@@ -69,6 +73,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} 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 {
|
import {
|
||||||
bootstrapResumePath,
|
bootstrapResumePath,
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
<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))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-4"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useThreadsPage } from "@/core/threads/hooks";
|
import { useThreadsPage } from "@/core/threads/hooks";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
{threads.map((thread) => {
|
{threads.map((thread) => {
|
||||||
const href = pathOfThread(thread);
|
const href = pathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquareIcon
|
{projectType ? (
|
||||||
className={cn(
|
<MyProjectThreadIcon type={projectType} />
|
||||||
"size-3.5 shrink-0",
|
) : (
|
||||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
<MessageSquareIcon
|
||||||
)}
|
className={cn(
|
||||||
/>
|
"size-3.5 shrink-0",
|
||||||
<span className="truncate">
|
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, "新对话"))}
|
{desensitize(titleOfThread(thread, "新对话"))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
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 { useAgentsPaginated, type Agent } from "@/core/agents";
|
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -75,6 +79,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} 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 {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
|
|||||||
);
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<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))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-4"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<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,
|
className,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
defaultLabel = "默认模型",
|
defaultLabel = "默认模型",
|
||||||
|
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||||
}: {
|
}: {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (modelName: string | null) => void;
|
onChange: (modelName: string | null) => void;
|
||||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
|||||||
className?: string;
|
className?: string;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
title={title}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={defaultLabel} />
|
<SelectValue placeholder={defaultLabel} />
|
||||||
</SelectTrigger>
|
</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,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} 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 { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -63,7 +67,8 @@ import {
|
|||||||
useToggleThreadFavorite,
|
useToggleThreadFavorite,
|
||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} 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 {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -245,7 +250,8 @@ export function RecentChatList({
|
|||||||
{shownThreads.map((thread) => {
|
{shownThreads.map((thread) => {
|
||||||
const href = listPathOfThread(thread);
|
const href = listPathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
@@ -279,8 +285,10 @@ export function RecentChatList({
|
|||||||
}
|
}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : projectType ? (
|
||||||
compact && (
|
<MyProjectThreadIcon type={projectType} />
|
||||||
|
) : (
|
||||||
|
compact && (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={
|
className={
|
||||||
"size-3.5 shrink-0 " +
|
"size-3.5 shrink-0 " +
|
||||||
@@ -294,9 +302,15 @@ export function RecentChatList({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<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))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-3"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -65,6 +65,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/chats",
|
path: "/page/workspace/chats",
|
||||||
match: (pathname) => pathname === "/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",
|
id: "workspace-library",
|
||||||
label: "资料库",
|
label: "资料库",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FileBox,
|
FileBox,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
FileText,
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
Gauge,
|
Gauge,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
@@ -69,10 +70,11 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "strategy-selection",
|
id: "strategy-projects",
|
||||||
label: "策略优选",
|
label: "我的项目",
|
||||||
icon: Sparkles,
|
icon: FolderKanban,
|
||||||
path: "/page/strategy/qa/strategy-selection",
|
path: "/page/strategy/qa/projects",
|
||||||
|
aliasPaths: ["/page/strategy/qa/strategy-selection"],
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// id: "position-roundtable",
|
// 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}`;
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ import {
|
|||||||
useThreadSettings,
|
useThreadSettings,
|
||||||
} from "@/core/settings";
|
} from "@/core/settings";
|
||||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
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 { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
@@ -285,16 +289,28 @@ export default function ChatPage() {
|
|||||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
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({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
context: streamContext,
|
context: streamContext,
|
||||||
threadMetadata: knowledgeSelectionReady
|
threadMetadata: newThreadMetadata,
|
||||||
? {
|
|
||||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
|
||||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
isMock,
|
isMock,
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
knowledgeSelectionThread.current = createdThreadId;
|
knowledgeSelectionThread.current = createdThreadId;
|
||||||
@@ -405,7 +421,9 @@ export default function ChatPage() {
|
|||||||
try {
|
try {
|
||||||
let targetThreadId = threadId;
|
let targetThreadId = threadId;
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
const created = await getAPIClient(isMock).threads.create({});
|
const created = await getAPIClient(isMock).threads.create({
|
||||||
|
metadata: newThreadMetadata ?? {},
|
||||||
|
});
|
||||||
targetThreadId = created.thread_id;
|
targetThreadId = created.thread_id;
|
||||||
setThreadId(targetThreadId);
|
setThreadId(targetThreadId);
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
@@ -465,13 +483,8 @@ export default function ChatPage() {
|
|||||||
try {
|
try {
|
||||||
const created = await getAPIClient(isMock).threads.create({
|
const created = await getAPIClient(isMock).threads.create({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...newThreadMetadata,
|
||||||
agent_id: selectedAgentId,
|
agent_id: selectedAgentId,
|
||||||
...(knowledgeSelectionReady
|
|
||||||
? {
|
|
||||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
|
||||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -490,7 +503,7 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
saveLastAgentId(selectedAgentId);
|
saveLastAgentId(selectedAgentId);
|
||||||
router.push(
|
router.push(
|
||||||
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
|
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
agentHandoffPendingRef.current = false;
|
agentHandoffPendingRef.current = false;
|
||||||
@@ -549,6 +562,7 @@ export default function ChatPage() {
|
|||||||
selectedAssistantKnowledgeBaseIds,
|
selectedAssistantKnowledgeBaseIds,
|
||||||
selectedKnowledgeBaseIds,
|
selectedKnowledgeBaseIds,
|
||||||
knowledgeSelectionReady,
|
knowledgeSelectionReady,
|
||||||
|
newThreadMetadata,
|
||||||
agentHandoffPending,
|
agentHandoffPending,
|
||||||
isMock,
|
isMock,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
FolderKanban,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
@@ -20,7 +21,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
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 { useAgent, useAgents } from "@/core/agents";
|
||||||
import { getAccountDisplayName } from "@/core/auth";
|
import { getAccountDisplayName } from "@/core/auth";
|
||||||
import {
|
import {
|
||||||
@@ -29,28 +35,41 @@ import {
|
|||||||
useScheduledTaskRuns,
|
useScheduledTaskRuns,
|
||||||
useScheduledTasks,
|
useScheduledTasks,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettings,
|
useAppearanceSettings,
|
||||||
useCitationDisplaySettings,
|
useCitationDisplaySettings,
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import { useThreadStream, useThreads } from "@/core/threads/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 { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
import {
|
import {
|
||||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||||
} from "@/core/official-deerflow/routes";
|
} from "@/core/official-deerflow/routes";
|
||||||
|
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||||
|
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||||
|
|
||||||
import "./official-deerflow-entry.css";
|
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 {
|
function pageFromPath(pathname: string): OfficialPage {
|
||||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||||
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
||||||
return "agent-chat";
|
return "agent-chat";
|
||||||
}
|
}
|
||||||
|
if (path.startsWith("/workspace/projects")) return "projects";
|
||||||
if (path.startsWith("/workspace/agents")) return "agents";
|
if (path.startsWith("/workspace/agents")) return "agents";
|
||||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||||
if (path.startsWith("/workspace/settings")) return "settings";
|
if (path.startsWith("/workspace/settings")) return "settings";
|
||||||
@@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) {
|
|||||||
return match ? decodeURIComponent(match[1]!) : null;
|
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) {
|
function readableError(error: unknown, fallback: string) {
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
}
|
}
|
||||||
@@ -159,20 +183,36 @@ function ChatWorkspace({
|
|||||||
routeThreadId: string;
|
routeThreadId: string;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const isNewThread = routeThreadId === "new";
|
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
|
// A new conversation uses the same client-generated id approach as the
|
||||||
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
||||||
const [draftThreadId] = useState<string>(() => uuid() as string);
|
const [draftThreadId] = useState<string>(() => uuid() as string);
|
||||||
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
||||||
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
||||||
const [localSettings] = useLocalSettings();
|
|
||||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : routeThreadId,
|
threadId: isNewThread ? undefined : routeThreadId,
|
||||||
assistantId: agentId ?? undefined,
|
assistantId: agentId ?? undefined,
|
||||||
|
threadMetadata: projectConversationType
|
||||||
|
? myProjectThreadMetadata(projectConversationType)
|
||||||
|
: undefined,
|
||||||
context: {
|
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 } : {}),
|
...(agentId ? { agent_id: agentId } : {}),
|
||||||
},
|
},
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
@@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
() => parseScheduledTaskId(location.pathname),
|
() => parseScheduledTaskId(location.pathname),
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
const projectId = useMemo(
|
||||||
|
() => parseProjectId(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||||
useThreads({
|
useThreads({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||||
});
|
});
|
||||||
const go = (path: string) => navigate(path);
|
const go = (path: string) => navigate(path);
|
||||||
|
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
||||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, 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: 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"] },
|
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
) : threadsError ? (
|
) : threadsError ? (
|
||||||
<p className="official-sidebar-error">对话列表加载失败</p>
|
<p className="official-sidebar-error">对话列表加载失败</p>
|
||||||
) : threads.length ? (
|
) : threads.length ? (
|
||||||
threads.map((chat) => (
|
threads.map((chat) => {
|
||||||
<button
|
const projectType = myProjectConversationTypeOfThread(chat);
|
||||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
return (
|
||||||
key={chat.thread_id}
|
<button
|
||||||
onClick={() => {
|
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||||
const agentId = typeof chat.context?.agent_id === "string"
|
key={chat.thread_id}
|
||||||
? chat.context.agent_id
|
onClick={() => {
|
||||||
: typeof chat.metadata?.agent_id === "string"
|
const agentId = typeof chat.context?.agent_id === "string"
|
||||||
? chat.metadata.agent_id
|
? chat.context.agent_id
|
||||||
: null;
|
: typeof chat.metadata?.agent_id === "string"
|
||||||
go(
|
? chat.metadata.agent_id
|
||||||
agentId
|
: null;
|
||||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
go(
|
||||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
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} />
|
type="button"
|
||||||
<span>{titleOfThread(chat, "未命名对话")}</span>
|
>
|
||||||
<small>{formatTimestamp(chat.updated_at)}</small>
|
{projectType ? (
|
||||||
</button>
|
<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>
|
<p className="official-sidebar-empty">暂无历史对话</p>
|
||||||
)}
|
)}
|
||||||
@@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
routeThreadId={chatRoute.threadId}
|
routeThreadId={chatRoute.threadId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{page === "projects" && (
|
||||||
|
projectId ? (
|
||||||
|
<StrategySelectionPage
|
||||||
|
key={projectId}
|
||||||
|
projectBasePath={projectBasePath}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StrategyProjectsPage basePath={projectBasePath} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
{page === "agents" && (
|
{page === "agents" && (
|
||||||
<AgentsPage
|
<AgentsPage
|
||||||
openAgent={(agentId) =>
|
openAgent={(agentId) =>
|
||||||
@@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||||
{page === "settings" && <SettingsPage />}
|
{page === "settings" && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster position="top-center" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 { Toaster } from "sonner";
|
||||||
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
@@ -78,6 +78,7 @@ import UsersPage from "./UsersPage";
|
|||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||||
|
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||||
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
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() {
|
export default function StrategyRoutes() {
|
||||||
const storedAuth = getStoredAuth();
|
const storedAuth = getStoredAuth();
|
||||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||||
@@ -111,7 +117,20 @@ export default function StrategyRoutes() {
|
|||||||
<Route index element={<Navigate to="qa/general" replace />} />
|
<Route index element={<Navigate to="qa/general" replace />} />
|
||||||
|
|
||||||
<Route path="qa/general" element={<GeneralQA />} />
|
<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
|
<Route
|
||||||
path="qa/expert-qa"
|
path="qa/expert-qa"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
|
.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 { 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 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 small { color: #acaca9; font-size: 11px; }
|
||||||
.official-recent-chat.is-active { background: #efefed; color: #252525; }
|
.official-recent-chat.is-active { background: #efefed; color: #252525; }
|
||||||
.official-sidebar-loading,
|
.official-sidebar-loading,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { Bot, Check, LoaderCircle, Save, Sparkles } from "lucide-react";
|
import { Bot, Check, LoaderCircle, Save, Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { getAgent, updateAgent } from "@/core/agents/api";
|
import { getAgent, updateAgent } from "@/core/agents/api";
|
||||||
import type { Agent } from "@/core/agents/types";
|
import type { Agent } from "@/core/agents/types";
|
||||||
|
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { STRATEGY_AGENT_ROLES } from "./constants";
|
import { STRATEGY_AGENT_ROLES } from "./constants";
|
||||||
@@ -24,15 +28,33 @@ interface AgentConfigDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSkills(value: string): string[] {
|
interface AgentConfigDraft {
|
||||||
return [...new Set(value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean))];
|
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) {
|
export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps) {
|
||||||
const [selectedId, setSelectedId] = useState<string>(STRATEGY_AGENT_ROLES[0].id);
|
const [selectedId, setSelectedId] = useState<string>(STRATEGY_AGENT_ROLES[0].id);
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [soul, setSoul] = useState("");
|
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 [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -41,6 +63,31 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
() => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!,
|
() => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!,
|
||||||
[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(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedId) return;
|
if (!open || !selectedId) return;
|
||||||
@@ -48,12 +95,20 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setAgent(null);
|
setAgent(null);
|
||||||
|
setSavedDraft(null);
|
||||||
void getAgent(selectedId)
|
void getAgent(selectedId)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setAgent(data);
|
setAgent(data);
|
||||||
setSoul(data.soul ?? "");
|
const nextDraft = {
|
||||||
setSkillsText((data.skills ?? []).join("\n"));
|
soul: data.soul ?? "",
|
||||||
|
model: data.model ?? null,
|
||||||
|
skills: data.skills ?? [],
|
||||||
|
};
|
||||||
|
setSoul(nextDraft.soul);
|
||||||
|
setModel(nextDraft.model);
|
||||||
|
setSelectedSkills(nextDraft.skills);
|
||||||
|
setSavedDraft(nextDraft);
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -77,11 +132,18 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
try {
|
try {
|
||||||
const updated = await updateAgent(agent.id, {
|
const updated = await updateAgent(agent.id, {
|
||||||
soul: soul.trim(),
|
soul: soul.trim(),
|
||||||
skills: parseSkills(skillsText),
|
model,
|
||||||
|
skills: selectedSkills,
|
||||||
});
|
});
|
||||||
setAgent(updated);
|
setAgent(updated);
|
||||||
setSoul(updated.soul ?? "");
|
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} 配置已保存`);
|
toast.success(`${updated.name} 配置已保存`);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
toast.error(reason instanceof Error ? reason.message : "保存失败");
|
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 (
|
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]">
|
<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]">
|
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
@@ -99,7 +174,7 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
策略智能体配置
|
策略智能体配置
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
每个流程角色对应一个默认智能体。管理员可修改 SOUL 和技能;保存后下一次运行即生效。
|
这是全局统一配置,不属于某一个项目。管理员保存后,所有策略项目下一次运行都会使用这里的模型、SOUL 和技能。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -116,7 +191,7 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
key={role.id}
|
key={role.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => setSelectedId(role.id)}
|
onClick={() => selectRole(role.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
"w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
selected
|
selected
|
||||||
@@ -160,15 +235,22 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
</div>
|
</div>
|
||||||
) : agent ? (
|
) : agent ? (
|
||||||
<div className="space-y-5">
|
<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>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground">当前名称</span>
|
<span className="text-xs text-muted-foreground">当前名称</span>
|
||||||
<div className="mt-1 font-medium">{agent.name}</div>
|
<div className="mt-1 font-medium">{agent.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<label className="block">
|
||||||
<span className="text-xs text-muted-foreground">运行模型</span>
|
<span className="text-xs text-muted-foreground">运行模型</span>
|
||||||
<div className="mt-1 font-medium">{agent.model || "使用系统默认模型"}</div>
|
<ModelNameSelect
|
||||||
</div>
|
value={model}
|
||||||
|
onChange={setModel}
|
||||||
|
disabled={saving}
|
||||||
|
defaultLabel="使用系统默认模型"
|
||||||
|
title="选择该全局智能体运行时使用的大模型;未指定时使用系统默认模型"
|
||||||
|
triggerClassName="mt-1 w-full bg-background"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -179,40 +261,52 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
value={soul}
|
value={soul}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onChange={(event) => setSoul(event.target.value)}
|
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="定义智能体的角色、分析方法、边界和输出要求…"
|
placeholder="定义智能体的角色、分析方法、边界和输出要求…"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<div className="space-y-2">
|
||||||
<span className="mb-2 block text-sm font-medium">技能</span>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Textarea
|
<span className="text-sm font-medium">技能库选择</span>
|
||||||
value={skillsText}
|
<Badge variant="secondary">已选 {selectedSkills.length} 个</Badge>
|
||||||
disabled={saving}
|
</div>
|
||||||
onChange={(event) => setSkillsText(event.target.value)}
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
placeholder="多个技能可用逗号分隔"
|
从系统技能库勾选该智能体可使用的技能;这里保存的是全局白名单,不接受手工输入技能名。
|
||||||
rows={3}
|
|
||||||
className="resize-y text-sm"
|
|
||||||
/>
|
|
||||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
||||||
可用逗号、中文逗号或换行分隔;留空表示不绑定专用技能。
|
|
||||||
</p>
|
</p>
|
||||||
</label>
|
<SkillTransferSelector
|
||||||
|
skills={selectableSkills}
|
||||||
|
selected={selectedSkills}
|
||||||
|
onChange={setSelectedSkills}
|
||||||
|
isLoading={isSkillsLoading}
|
||||||
|
onViewDetail={setDetailSkillName}
|
||||||
|
/>
|
||||||
|
{skillsError ? (
|
||||||
|
<p className="text-xs text-destructive">技能库加载失败,请稍后重试。</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="border-t px-6 py-4 dark:border-[#173d68]">
|
<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>
|
||||||
<Button disabled={!agent || loading || saving} onClick={() => void save()}>
|
<Button disabled={!agent || loading || saving || isSkillsLoading} onClick={() => void save()}>
|
||||||
{saving ? <LoaderCircle className="animate-spin" /> : <Save />}
|
{saving ? <LoaderCircle className="animate-spin" /> : <Save />}
|
||||||
保存当前智能体
|
保存当前智能体
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<SkillDetailDialog
|
||||||
|
skillName={detailSkillName}
|
||||||
|
open={detailSkillName !== null}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) setDetailSkillName(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Dialog>
|
</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.
|
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`.
|
**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).
|
**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
|
- Supports `supports_vision` flag for image understanding models
|
||||||
- Config values starting with `$` resolved as environment variables
|
- 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`)
|
- 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 (会商模型自动容错 / 换下一个模型)
|
### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
### 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`
|
prevents DeerFlow's connection check from sending vendor-specific `thinking`
|
||||||
fields that a generic OpenAI-compatible gateway may reject.
|
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
|
### Running
|
||||||
|
|
||||||
**Full Application** (from project root):
|
**Full Application** (from project root):
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.auth.password import verify_password_async
|
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.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_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
|
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.paths import get_paths
|
||||||
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
|
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.agents import AgentStore
|
||||||
|
from deerflow.persistence.skills import SkillStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.skills.cascade import sync_agent_skills_to_available
|
from deerflow.skills.cascade import sync_agent_skills_to_available
|
||||||
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 SkillCategory
|
||||||
|
from deerflow.skills.usage import all_entries as get_skill_usage_entries
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
|
_STRATEGY_SELECTION_AGENT_IDS = frozenset(required_strategy_agent_ids())
|
||||||
|
|
||||||
|
|
||||||
class AgentResponse(BaseModel):
|
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
|
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(
|
def _write_agent_config(
|
||||||
agent_id: str,
|
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 "")
|
next_description = update_data.get("description", current.get("description") or "")
|
||||||
|
|
||||||
existing_extras = _load_config_extras(agent_id)
|
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_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_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"]
|
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(
|
_, edit_mode = await _get_editable_or_raise(
|
||||||
store, agent_id, user_id, is_admin=is_admin
|
store, agent_id, user_id, is_admin=is_admin
|
||||||
)
|
)
|
||||||
|
_ensure_strategy_agent_deletable(agent_id)
|
||||||
|
|
||||||
if edit_mode == "admin":
|
if edit_mode == "admin":
|
||||||
deleted = await store.delete_any(agent_id)
|
deleted = await store.delete_any(agent_id)
|
||||||
|
|||||||
@@ -31,13 +31,15 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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 (
|
from app.gateway.routers.multi_agent import (
|
||||||
_ROUNDTABLE_THREAD_METADATA,
|
_ROUNDTABLE_THREAD_METADATA,
|
||||||
_auth_headers,
|
_auth_headers,
|
||||||
@@ -50,6 +52,7 @@ from app.gateway.routers.multi_agent import (
|
|||||||
_stream_upstream,
|
_stream_upstream,
|
||||||
_validate_agent_id,
|
_validate_agent_id,
|
||||||
)
|
)
|
||||||
|
from deerflow.agents.roundtable_orchestrator.delivery import visible_seat_text
|
||||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
|
||||||
MAX_PARALLEL_AGENTS = 8
|
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)—— 并行席位互相写文件会打架;
|
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
|
||||||
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
|
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
|
||||||
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
|
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
|
||||||
@@ -231,6 +245,9 @@ async def _run_one_agent(
|
|||||||
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||||
last_ai = msg
|
last_ai = msg
|
||||||
break
|
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", ""))
|
content = _flatten_content(last_ai.get("content", ""))
|
||||||
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
|
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
|
||||||
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
|
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
|
||||||
@@ -250,6 +267,8 @@ async def _run_one_agent(
|
|||||||
"tool_calls": final_tool_calls,
|
"tool_calls": final_tool_calls,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
if not _has_substantive_content(content):
|
||||||
|
raise RuntimeError("智能体未生成有效正文,请重试或更换全局模型")
|
||||||
yield {
|
yield {
|
||||||
"status": f"子智能体 {agent_id} 完成",
|
"status": f"子智能体 {agent_id} 完成",
|
||||||
"content": content,
|
"content": content,
|
||||||
@@ -408,8 +427,24 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
await _emit_tool(tc)
|
await _emit_tool(tc)
|
||||||
if not done_sent:
|
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(
|
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 失败不拖垮其它
|
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
|
||||||
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
|
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"policy-knowledge": {
|
"policy-knowledge": {
|
||||||
"enabled": false
|
"enabled": true
|
||||||
},
|
},
|
||||||
"html-page-builder": {
|
"html-page-builder": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
+9
-5
@@ -96,9 +96,11 @@ _BUSY_PATTERNS = (
|
|||||||
"稍后重试",
|
"稍后重试",
|
||||||
"请稍后重试",
|
"请稍后重试",
|
||||||
)
|
)
|
||||||
_QUOTA_PATTERNS = (
|
_QUOTA_PATTERNS = (
|
||||||
"insufficient_quota",
|
"insufficient_quota",
|
||||||
"quota",
|
"insufficient balance",
|
||||||
|
"token plan limit",
|
||||||
|
"quota",
|
||||||
"billing",
|
"billing",
|
||||||
"credit",
|
"credit",
|
||||||
"payment",
|
"payment",
|
||||||
@@ -122,8 +124,10 @@ _AUTH_PATTERNS = (
|
|||||||
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
|
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
|
||||||
# response). These are almost always transient inference-side issues and safe
|
# response). These are almost always transient inference-side issues and safe
|
||||||
# to retry.
|
# to retry.
|
||||||
_UPSTREAM_GARBAGE_PATTERNS = (
|
_UPSTREAM_GARBAGE_PATTERNS = (
|
||||||
"bad_response_status_code",
|
"openai-compatible upstream application error",
|
||||||
|
"null value for 'choices'",
|
||||||
|
"bad_response_status_code",
|
||||||
"bad response status",
|
"bad response status",
|
||||||
"expecting ',' delimiter",
|
"expecting ',' delimiter",
|
||||||
"expecting value",
|
"expecting value",
|
||||||
|
|||||||
+81
-16
@@ -25,7 +25,8 @@ message that originally carried it:
|
|||||||
|
|
||||||
from __future__ import annotations
|
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.language_models import LanguageModelInput
|
||||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
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
|
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):
|
class PatchedChatOpenAI(ChatOpenAI):
|
||||||
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
|
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
|
||||||
@@ -100,16 +160,20 @@ class PatchedChatOpenAI(ChatOpenAI):
|
|||||||
normalize_chat_payload_messages(payload)
|
normalize_chat_payload_messages(payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _convert_chunk_to_generation_chunk(
|
def _convert_chunk_to_generation_chunk(
|
||||||
self,
|
self,
|
||||||
chunk: dict,
|
chunk: dict,
|
||||||
default_chunk_class: type,
|
default_chunk_class: type,
|
||||||
base_generation_info: dict | None,
|
base_generation_info: dict | None,
|
||||||
) -> ChatGenerationChunk | None:
|
) -> ChatGenerationChunk | None:
|
||||||
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
|
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
|
||||||
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
# Some compatible gateways return a vendor error event followed by a
|
||||||
chunk,
|
# normal-looking usage chunk, all under HTTP 200. Detect the first
|
||||||
default_chunk_class,
|
# 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,
|
base_generation_info,
|
||||||
)
|
)
|
||||||
if generation_chunk is None:
|
if generation_chunk is None:
|
||||||
@@ -130,11 +194,12 @@ class PatchedChatOpenAI(ChatOpenAI):
|
|||||||
self,
|
self,
|
||||||
response: dict | Any,
|
response: dict | Any,
|
||||||
generation_info: dict | None = None,
|
generation_info: dict | None = None,
|
||||||
) -> ChatResult:
|
) -> ChatResult:
|
||||||
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
|
"""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()
|
||||||
response_dict = response if isinstance(response, dict) else response.model_dump()
|
_raise_for_application_error(response_dict)
|
||||||
choices = response_dict.get("choices", [])
|
result = super()._create_chat_result(response, generation_info)
|
||||||
|
choices = response_dict.get("choices", [])
|
||||||
|
|
||||||
generations: list[ChatGeneration] = []
|
generations: list[ChatGeneration] = []
|
||||||
for index, generation in enumerate(result.generations):
|
for index, generation in enumerate(result.generations):
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import asyncio
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
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.agents.middlewares.llm_error_handling_middleware import LLM_ERROR_MARKER, LLMErrorHandlingMiddleware
|
||||||
|
from deerflow.models.patched_openai import OpenAICompatibleUpstreamError, PatchedChatOpenAI
|
||||||
|
|
||||||
|
|
||||||
def _middleware() -> LLMErrorHandlingMiddleware:
|
def _middleware() -> LLMErrorHandlingMiddleware:
|
||||||
@@ -26,6 +28,10 @@ def _middleware() -> LLMErrorHandlingMiddleware:
|
|||||||
return middleware
|
return middleware
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_model() -> PatchedChatOpenAI:
|
||||||
|
return PatchedChatOpenAI(model="test-model", api_key="test-key")
|
||||||
|
|
||||||
|
|
||||||
def test_httpx_read_timeout_is_transient() -> None:
|
def test_httpx_read_timeout_is_transient() -> None:
|
||||||
assert _middleware()._classify_error(httpx.ReadTimeout("read timed out")) == (True, "transient")
|
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")
|
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:
|
def test_read_timeout_is_retried_and_recovers() -> None:
|
||||||
"""第一次 ReadTimeout、第二次成功:必须重试并返回真实结果。"""
|
"""第一次 ReadTimeout、第二次成功:必须重试并返回真实结果。"""
|
||||||
middleware = _middleware()
|
middleware = _middleware()
|
||||||
|
|||||||
@@ -13,9 +13,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import app.gateway.roundtable_model_fallback as mf
|
import app.gateway.roundtable_model_fallback as mf
|
||||||
|
import app.gateway.routers.parallel_agents as parallel_agents
|
||||||
from app.gateway.roundtable_model_fallback import (
|
from app.gateway.roundtable_model_fallback import (
|
||||||
fallback_model_chain,
|
fallback_model_chain,
|
||||||
is_llm_error_message,
|
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
|
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 打标记 ────────────────────────────────────────────
|
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.gateway.routers import _strategy_selection_seed as seed
|
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.config.paths import Paths
|
||||||
|
from deerflow.persistence.skills.memory import MemorySkillStore
|
||||||
|
from deerflow.skills.types import Skill, SkillCategory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -73,6 +77,18 @@ def test_seed_assets_use_role_appropriate_existing_skills_and_tools() -> None:
|
|||||||
assert solution["tool_groups"] == ["file:write"]
|
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:
|
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")
|
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")
|
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 seed.ensure_strategy_selection_agents() == []
|
||||||
assert customized_soul.read_text(encoding="utf-8") == "管理员现场修改"
|
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")
|
||||||
Reference in new issue
Block a user