sync update from LAN repo (source master-yx @ cf505220)

This commit is contained in:
gozeros committed 2026-10-21 15:11:11 +08:00
1 parent e243ec3a46
commit 19d3cb96dc
114 files changed
+14787 -451

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
@@ -51,6 +51,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api";
@@ -69,6 +73,7 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
bootstrapResumePath,
isBootstrapThread,
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : (
filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquare
className={cn(
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
<span className="truncate">
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => {
const href = pathOfThread(thread);
const isActive = href === pathname;
const projectType = myProjectConversationTypeOfThread(thread);
return (
<Link
key={thread.thread_id}
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
<span className="truncate">
{projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</Link>
);
})}
@@ -60,6 +60,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
@@ -75,6 +79,7 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
)}
aria-label="智能体创建会话"
/>
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquare
className={cn(
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
@@ -25,6 +25,7 @@ export function ModelNameSelect({
className,
triggerClassName,
defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: {
value: string | null;
onChange: (modelName: string | null) => void;
@@ -32,6 +33,7 @@ export function ModelNameSelect({
className?: string;
triggerClassName?: string;
defaultLabel?: string;
title?: string;
}) {
const { models } = useModels();
return (
@@ -42,7 +44,7 @@ export function ModelNameSelect({
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
title={title}
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
@@ -0,0 +1,51 @@
import { FolderKanban } from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<FolderKanban
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
className={cn("size-3.5 shrink-0 text-violet-500", className)}
/>
);
}
export function MyProjectThreadBadge({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<span
className={cn(
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
>
{MY_PROJECT_CONVERSATION_LABELS[type]}
</span>
);
}
@@ -49,7 +49,11 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -63,7 +67,8 @@ import {
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -245,7 +250,8 @@ export function RecentChatList({
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -279,8 +285,10 @@ export function RecentChatList({
}
aria-label="智能体创建会话"
/>
) : (
compact && (
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
@@ -294,9 +302,15 @@ export function RecentChatList({
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -69,7 +69,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
{
id: "account",
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
match: (pathname) =>
pathname.startsWith("/page/strategy/qa/projects") ||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
},
{
id: "workspace-library",
label: "资料库",
@@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
];
@@ -21,6 +21,7 @@ import {
FileBox,
FileSearch,
FileText,
FolderKanban,
Gauge,
Grid3X3,
HelpCircle,
@@ -48,12 +49,23 @@ export type MenuItem = {
path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: MARKDOWN_DOCX_MENU_PATH,
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
@@ -68,6 +80,13 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
aliasPaths: ["/page/strategy/qa/strategy-selection"],
},
// {
// id: "position-roundtable",
// label: "岗位会商",
@@ -258,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
MARKDOWN_DOCX_MENU_ITEM,
{
id: "mgmt-tasks",
label: "定时任务",
@@ -330,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
},
{
id: "resource-knowledge-base",
@@ -365,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
return false;
}
if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
}
@@ -13,6 +13,44 @@ export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
export interface StrategyProjectSettings {
taiwan_strait_demo_enabled: boolean;
}
export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
);
if (!response.ok) {
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
}
return (await response.json()) as StrategyProjectSettings;
}
export async function updateStrategyProjectSettings(
body: StrategyProjectSettingsUpdate,
): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存策略项目设置失败" }));
throw new Error(detail.detail ?? "保存策略项目设置失败");
}
return (await response.json()) as StrategyProjectSettings;
}
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
@@ -13,6 +13,7 @@ import {
loadPromptPrefixSettings,
loadPublishSquares,
loadSkillCompressionSettings,
loadStrategyProjectSettings,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCitationDisplaySettings,
@@ -23,6 +24,7 @@ import {
updatePromptPrefixSettings,
updatePublishSquares,
updateSkillCompressionSettings,
updateStrategyProjectSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
@@ -32,6 +34,7 @@ import {
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type SkillCompressionUpdate,
type StrategyProjectSettingsUpdate,
type LayoutMode,
type ViewMode,
} from "./api";
@@ -45,6 +48,28 @@ const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
export function useStrategyProjectSettings() {
return useQuery({
queryKey: STRATEGY_PROJECTS_KEY,
queryFn: () => loadStrategyProjectSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateStrategyProjectSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: StrategyProjectSettingsUpdate) =>
updateStrategyProjectSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
},
});
}
export function usePromptPrefixSettings() {
return useQuery({
@@ -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}`;
}
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
id: string;
}
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -180,7 +188,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
body: processReportBody(input),
});
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null));
@@ -198,7 +206,7 @@ export async function updateEightBfTaskProcessReport(
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
body: processReportBody(input),
},
);
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -88,14 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
const artifactPositionByAgent = useMemo(() => {
const map = new Map<string, number>();
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
map.set(seat.agent_id, seat.artifact_position);
}
}
return map;
}, [configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
},
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId],
[taskId, artifactPositionByAgent],
);
useEffect(() => {
+28 -14
View File
@@ -64,6 +64,10 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import {
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -286,16 +290,28 @@ export default function ChatPage() {
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification();
const projectConversationType =
myProjectConversationTypeFromSearchParams(searchParams);
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
const metadata: Record<string, unknown> = projectConversationType
? myProjectThreadMetadata(projectConversationType)
: {};
if (knowledgeSelectionReady) {
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}, [
knowledgeSelectionReady,
projectConversationType,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
]);
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
threadMetadata: newThreadMetadata,
isMock,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
@@ -462,7 +478,9 @@ export default function ChatPage() {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
const created = await getAPIClient(isMock).threads.create({
metadata: newThreadMetadata ?? {},
});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
@@ -522,13 +540,8 @@ export default function ChatPage() {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: {
...newThreadMetadata,
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
},
});
sessionStorage.setItem(
@@ -547,7 +560,7 @@ export default function ChatPage() {
);
saveLastAgentId(selectedAgentId);
router.push(
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
);
} catch (err) {
agentHandoffPendingRef.current = false;
@@ -606,6 +619,7 @@ export default function ChatPage() {
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
newThreadMetadata,
agentHandoffPending,
isMock,
router,
@@ -5,6 +5,7 @@ import {
ChevronDown,
CircleHelp,
Clock3,
FolderKanban,
LoaderCircle,
MessageSquareText,
PanelLeftClose,
@@ -20,7 +21,12 @@ import {
} from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Toaster } from "sonner";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import {
@@ -29,28 +35,41 @@ import {
useScheduledTaskRuns,
useScheduledTasks,
} from "@/core/scheduled-tasks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { useThreadStream, useThreads } from "@/core/threads/hooks";
import {
myProjectConversationTypeOfThread,
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import {
OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import "./official-deerflow-entry.css";
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
type OfficialPage =
| "chat"
| "projects"
| "agents"
| "agent-chat"
| "scheduled"
| "settings";
function pageFromPath(pathname: string): OfficialPage {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat";
}
if (path.startsWith("/workspace/projects")) return "projects";
if (path.startsWith("/workspace/agents")) return "agents";
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/settings")) return "settings";
@@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null;
}
function parseProjectId(pathname: string) {
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
return match ? decodeURIComponent(match[1]!) : null;
}
function readableError(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
@@ -159,20 +183,36 @@ function ChatWorkspace({
routeThreadId: string;
}) {
const navigate = useNavigate();
const location = useLocation();
const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new";
const projectConversationType = isNewThread
? myProjectConversationTypeFromSearchParams(
new URLSearchParams(location.search),
)
: null;
// A new conversation uses the same client-generated id approach as the
// existing ChatPage. The backend owns the persisted id once streaming starts.
const [draftThreadId] = useState<string>(() => uuid() as string);
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
const { agent } = useAgent(agentId, { enabled: !!agentId });
const [localSettings] = useLocalSettings();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : routeThreadId,
assistantId: agentId ?? undefined,
threadMetadata: projectConversationType
? myProjectThreadMetadata(projectConversationType)
: undefined,
context: {
...localSettings.context,
// This compact official shell has no model/mode controls of its own.
// Do not inherit a hidden selection saved by the customized workspace
// (for example an obsolete or quota-exhausted model); an omitted model
// lets the backend resolve the current system default or the agent's
// globally configured model.
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
...(agentId ? { agent_id: agentId } : {}),
},
onStart: (createdThreadId) => {
@@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname),
[location.pathname],
);
const projectId = useMemo(
() => parseProjectId(location.pathname),
[location.pathname],
);
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
useThreads({
limit: 20,
@@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() {
select: ["thread_id", "updated_at", "values", "metadata"],
});
const go = (path: string) => navigate(path);
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
const nav = [
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
] as const;
@@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() {
) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? (
threads.map((chat) => (
<button
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id}
onClick={() => {
const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string"
? chat.metadata.agent_id
: null;
go(
agentId
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
);
}}
type="button"
>
<MessageSquareText aria-hidden="true" size={15} />
<span>{titleOfThread(chat, "未命名对话")}</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
))
threads.map((chat) => {
const projectType = myProjectConversationTypeOfThread(chat);
return (
<button
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id}
onClick={() => {
const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string"
? chat.metadata.agent_id
: null;
go(
agentId
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
);
}}
type="button"
>
{projectType ? (
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
) : (
<MessageSquareText aria-hidden="true" size={15} />
)}
<span className="official-recent-chat-copy">
<span className="official-recent-chat-title">
{titleOfThread(chat, "未命名对话")}
</span>
{projectType && (
<MyProjectThreadBadge type={projectType} />
)}
</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
);
})
) : (
<p className="official-sidebar-empty">暂无历史对话</p>
)}
@@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() {
routeThreadId={chatRoute.threadId}
/>
)}
{page === "projects" && (
projectId ? (
<StrategySelectionPage
key={projectId}
projectBasePath={projectBasePath}
projectId={projectId}
/>
) : (
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "agents" && (
<AgentsPage
openAgent={(agentId) =>
@@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() {
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
{page === "settings" && <SettingsPage />}
</div>
<Toaster position="top-center" />
</main>
);
}
+22 -1
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
@@ -78,6 +78,8 @@ import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
@@ -98,6 +100,11 @@ function StrategyChatThreadLayout() {
);
}
function StrategyProjectWorkbenchRoute() {
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
return <StrategySelectionPage key={projectId} projectId={projectId} />;
}
export default function StrategyRoutes() {
const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -110,6 +117,20 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="qa/general" replace />} />
<Route path="qa/general" element={<GeneralQA />} />
<Route
path="qa/projects"
element={
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
}
/>
<Route
path="qa/projects/:project_id"
element={<StrategyProjectWorkbenchRoute />}
/>
<Route
path="qa/strategy-selection"
element={<Navigate to="/page/strategy/qa/projects" replace />}
/>
<Route
path="qa/expert-qa"
element={
@@ -64,6 +64,8 @@
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; }
.official-recent-chat-title { flex: 1; min-width: 0; }
.official-recent-chat small { color: #acaca9; font-size: 11px; }
.official-recent-chat.is-active { background: #efefed; color: #252525; }
.official-sidebar-loading,
@@ -55,12 +55,14 @@ function payloadFrom(
): TaskProcessReportPayload {
const name = target.name?.trim() || fileName(target.path);
const agentId = target.agentId?.trim() || target.threadId;
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
const position = config.positionFor?.(target) ?? config.position;
return {
taskId: config.taskId,
agentId,
agentName: target.agentName?.trim() || agentId,
content,
position: config.position,
position,
reportName: name,
submitStatus,
};
@@ -55,6 +55,13 @@ export interface ChainSeat {
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/
position_id?: string | null;
/**
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
* 注意与 `position_id`(岗位协同的岗位归属)无关。
*/
artifact_position?: number | null;
/**
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
@@ -13,7 +13,14 @@ export interface TaskProcessReportPayload {
export interface TaskArtifactSubmissionConfig {
/** External task-system id, not the local roundtable draft storage key. */
taskId: string;
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
position: number;
/**
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
* null / undefined = 该席位未配置,回退 `position` 默认值。
*/
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
}
@@ -697,6 +697,8 @@ export default function BusinessChainEditorPage() {
seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null,
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
artifact_position: s.artifact_position ?? null,
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
job_responsibilities: s.job_responsibilities?.trim() || null,
};
@@ -41,10 +41,6 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
type HeaderProps = {
collapsed?: boolean;
};
@@ -122,8 +118,11 @@ const Header = ({ collapsed }: HeaderProps) => {
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
// 深色顶栏围绕中间标题分成左右两组。直接按菜单管理后的一级顺序均分,确保被升为
// 一级或重新排序的节点仍会显示;默认 5 项时仍保持左 3 / 右 2 的原布局。
const topMenuSplitIndex = Math.ceil(visibleMenuItems.length / 2);
const leftMenuItems = visibleMenuItems.slice(0, topMenuSplitIndex);
const rightMenuItems = visibleMenuItems.slice(topMenuSplitIndex);
const handleNavItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
@@ -73,6 +73,21 @@ export interface LlmWikiKnowledgeBase {
is_conversation_deposit?: boolean;
}
export interface LlmWikiEmbeddingModel {
id: string;
name: string;
provider?: string | null;
model_name?: string | null;
status?: string | null;
is_default: boolean;
available: boolean;
}
export interface LlmWikiEmbeddingModelsResponse {
models: LlmWikiEmbeddingModel[];
default_model_id?: string | null;
}
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
wiki: {
status: "ready" | "empty" | "unavailable";
@@ -268,6 +283,9 @@ export interface LlmWikiWikiIndexStatus {
export interface LocalWikiEncoderStatus {
provider: string;
configured_provider?: string | null;
mode?: "configured" | "local";
using_local?: boolean;
model: string;
dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed";
@@ -424,6 +442,17 @@ export async function getLlmWikiKnowledgeBase(
);
}
export async function listLlmWikiEmbeddingModels(): Promise<LlmWikiEmbeddingModelsResponse> {
const payload = await readJson<LlmWikiEmbeddingModelsResponse>(
await apiFetch(`${API()}/embedding-models`),
"读取知识服务向量模型",
);
return {
models: payload.models ?? [],
default_model_id: payload.default_model_id ?? null,
};
}
export async function createLlmWikiWeKnoraEmbedSession(
id: string,
tab: "documents" | "wiki" | "graph",
@@ -457,6 +486,7 @@ export async function createLlmWikiKnowledgeBase(input: {
description: string;
type: "document" | "faq";
wiki_enabled?: boolean;
embedding_model_id?: string;
}): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${API()}/knowledge-bases`, {
@@ -654,6 +684,13 @@ export async function configureLocalWikiEncoder(
);
}
export async function restoreConfiguredWikiEncoder(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }),
"恢复配置文件编码器",
);
}
export async function uploadLocalWikiEncoder(
files: File[],
onProgress?: (percent: number) => void,
@@ -688,10 +725,15 @@ export async function uploadLocalWikiEncoder(
payload = null;
}
if (request.status < 200 || request.status >= 300) {
const detail = payload && typeof payload === "object" && "detail" in payload
const rawDetail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`;
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: typeof rawDetail === "string"
? rawDetail
: JSON.stringify(rawDetail);
reject(new Error(userFacingKnowledgeMessage(detail)));
return;
}
onProgress?.(100);
@@ -1268,6 +1310,17 @@ export function useLlmWikiSettings(enabled = true) {
});
}
export function useLlmWikiEmbeddingModels(enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"],
queryFn: listLlmWikiEmbeddingModels,
enabled,
staleTime: 60_000,
refetchOnMount: "always",
});
}
export function useLlmWikiActions() {
const queryClient = useQueryClient();
const invalidateKnowledge = () =>
@@ -34,6 +34,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
@@ -48,7 +55,9 @@ import {
searchLlmWiki,
startLocalWikiEncoder,
uploadLocalWikiEncoder,
restoreConfiguredWikiEncoder,
useLlmWikiActions,
useLlmWikiEmbeddingModels,
useLlmWikiKnowledgeBases,
useLlmWikiSettings,
useLlmWikiSettingsActions,
@@ -68,6 +77,14 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
function encoderProviderLabel(provider?: string | null): string {
const normalized = provider?.trim().toLowerCase();
if (normalized === "local") return "本地 BGE-M3";
if (normalized === "openai") return "OpenAI";
if (normalized === "openai_compatible") return "OpenAI 兼容服务";
return provider?.trim() || "未配置";
}
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
if (mode === "vector") return "本地 Wiki 向量检索";
if (mode === "mixed") return "Wiki 混合检索";
@@ -457,18 +474,50 @@ function CreateKnowledgeDialog({
open: boolean;
pending: boolean;
onOpenChange: (value: boolean) => void;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [wikiEnabled, setWikiEnabled] = useState(true);
const [embeddingModelId, setEmbeddingModelId] = useState("");
const embeddingModels = useLlmWikiEmbeddingModels(open);
const availableEmbeddingModels = useMemo(
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
[embeddingModels.data?.models],
);
useEffect(() => {
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
if (!open) {
setName("");
setDescription("");
setWikiEnabled(true);
setEmbeddingModelId("");
}
}, [open]);
useEffect(() => {
if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return;
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
const defaultId = embeddingModels.data.default_model_id;
setEmbeddingModelId(
availableEmbeddingModels.some((model) => model.id === defaultId)
? defaultId!
: availableEmbeddingModels[0]!.id,
);
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]);
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
const provider = model.provider?.trim();
const implementation = model.model_name?.trim();
const suffix = [provider, implementation].filter(Boolean).join(" · ");
return suffix ? `${model.name}(${suffix})` : model.name;
};
const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
@@ -476,8 +525,33 @@ function CreateKnowledgeDialog({
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
</div></div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-embedding-model">原文向量模型</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground"><Loader2 className="mr-2 size-4 animate-spin" />正在读取知识服务模型</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>重新读取</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={pending}>
<SelectTrigger id="llmwiki-embedding-model" className="w-full"><SelectValue placeholder="请选择向量模型" /></SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">知识服务中没有可用的 Embedding 模型,请先在知识服务中启用本地或 OpenAI 兼容向量模型。</div>
)}
<p className="text-xs leading-5 text-muted-foreground">该模型由知识服务(WeKnora)对上传原文和分块进行向量化,并在创建时绑定到新库。它与 DeerFlow 的“Wiki 镜像编码器”是两套独立配置;选择 OpenAI 兼容模型时,无需再配置本地 BGE-M3。需要跨知识库检索时,请为相关知识库保持相同或兼容的向量模型。</p>
</div>
</div>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim() || modelSelectionUnavailable} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled, embedding_model_id: embeddingModelId })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
</DialogContent>
</Dialog>
);
@@ -501,11 +575,12 @@ export function LocalEncoderSettingsCard() {
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status);
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
setModelPath(status.model_path || "");
};
const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); },
onError: (error) => toast.error(messageOf(error)),
});
const upload = useMutation({
@@ -514,7 +589,7 @@ export function LocalEncoderSettingsCard() {
onSuccess: (status) => {
applyStatus(status);
setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`);
},
onError: (error) => toast.error(messageOf(error)),
});
@@ -526,7 +601,17 @@ export function LocalEncoderSettingsCard() {
},
onError: (error) => toast.error(messageOf(error)),
});
const restoreConfigured = useMutation({
mutationFn: restoreConfiguredWikiEncoder,
onSuccess: (nextStatus) => {
applyStatus(nextStatus);
toast.success(`已恢复 config.yaml 编码器:${encoderProviderLabel(nextStatus.provider)}`);
},
onError: (error) => toast.error(messageOf(error)),
});
const status = encoder.data;
const usingLocal = status?.using_local ?? status?.provider === "local";
const localOverrideActive = status?.mode === "local";
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB`
@@ -535,29 +620,49 @@ export function LocalEncoderSettingsCard() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle>
<CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
<Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}>
{encoder.isError
? "状态读取失败"
: encoder.isLoading
? "读取中"
: !usingLocal
? "配置编码器生效中"
: status?.state === "ready"
? "本地编码器已就绪"
: status?.state === "starting"
? "本地编码器启动中"
: status?.state === "failed"
? "本地编码器启动失败"
: "本地编码器未启动"}
</Badge>
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
{status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>}
{status && <Badge variant="outline">{localOverrideActive ? "页面本地覆盖" : "config.yaml 配置"}</Badge>}
<span className="text-muted-foreground">
{status
? usingLocal
? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}`
: `由 config.yaml 的 ${encoderProviderLabel(status.configured_provider || status.provider)} provider 提供向量服务,无需启动本地模型`
: "正在读取状态"}
</span>
{usingLocal && status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
</div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
{usingLocal && status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
<label className="text-sm font-medium" htmlFor="local-encoder-path">切换到服务器本地模型目录</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button>
</div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p>
</div>
<div className="space-y-2 rounded-xl border border-dashed p-4">
@@ -576,7 +681,7 @@ export function LocalEncoderSettingsCard() {
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"}
</p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
@@ -586,10 +691,16 @@ export function LocalEncoderSettingsCard() {
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
<Button disabled={!usingLocal || !status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
{!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button>
{localOverrideActive && (
<Button variant="secondary" disabled={restoreConfigured.isPending} onClick={() => restoreConfigured.mutate()}>
{restoreConfigured.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
恢复 config.yaml 编码器
</Button>
)}
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button>
@@ -601,13 +601,19 @@ export function WeKnoraWikiManagementPage() {
}
};
const vectorStatusText = !indexStatus.data?.local_index_enabled
? "本地向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: "整库尚未完成向量化,检索使用 Wiki 接口";
const vectorStatusText = indexStatus.isLoading
? "正在读取 Wiki 镜像向量状态"
: indexStatus.isError
? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}`
: !indexStatus.data?.local_index_enabled
? "Wiki 镜像向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: indexStatus.data.last_error
? `整库向量化失败:${indexStatus.data.last_error}`
: "整库尚未完成向量化,检索使用 Wiki 接口";
return (
<div className="flex h-full min-h-0 flex-col bg-background">
@@ -626,7 +632,7 @@ export function WeKnoraWikiManagementPage() {
variant="outline"
size="sm"
onClick={() => void vectorize()}
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
>
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
@@ -652,6 +658,17 @@ export function WeKnoraWikiManagementPage() {
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-3">
{indexStatus.isLoading && (
<div className="mb-3 flex items-center rounded-lg border bg-background p-3 text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取 Wiki 镜像向量状态
</div>
)}
{indexStatus.isError && (
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>Wiki 镜像向量状态读取失败:{indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void indexStatus.refetch()}>重新读取</Button>
</div>
)}
{indexStatus.data && (
<div className="mb-3 rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs">
@@ -0,0 +1,736 @@
import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Bot,
Check,
CirclePlus,
Layers3,
LoaderCircle,
LockKeyhole,
Power,
RotateCcw,
Save,
Sparkles,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { cn } from "@/lib/utils";
import {
createStrategyAngle,
disableStrategyAngle,
STRATEGY_AGENTS_QUERY_KEY,
updateStrategyAgent,
useStrategyAgents,
type StrategyAgent,
type StrategyAgentCategory,
} from "./strategy-agents";
interface AgentConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface AgentConfigDraft {
name: string;
description: string;
soul: string;
model: string | null;
skills: string[];
enabled: boolean;
sortOrder: number;
}
const NEW_ANGLE_ID = "__new_strategy_angle__";
const CATEGORY_COPY: Record<
StrategyAgentCategory,
{ title: string; description: string }
> = {
intent: {
title: "意图理解",
description: "把用户对话整理为结构化任务,固定流程入口",
},
angle: {
title: "分析角度",
description: "并行生成独立策略,可按业务需要扩展",
},
judge: {
title: "智能评判",
description: "统一比较各角度策略并给出推荐结论",
},
solution: {
title: "方案整理",
description: "将用户选中的策略消冲突并汇总成方案",
},
};
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])
);
}
function draftFromAgent(agent: StrategyAgent): AgentConfigDraft {
return {
name: agent.name,
description: agent.description,
soul: agent.soul ?? "",
model: agent.model,
skills: agent.skills ?? [],
enabled: agent.enabled,
sortOrder: agent.sort_order,
};
}
function emptyAngleDraft(sortOrder: number): AgentConfigDraft {
return {
name: "",
description: "",
soul: "",
model: null,
skills: [],
enabled: true,
sortOrder,
};
}
function sameDraft(left: AgentConfigDraft, right: AgentConfigDraft) {
return (
left.name === right.name &&
left.description === right.description &&
left.soul === right.soul &&
left.model === right.model &&
left.enabled === right.enabled &&
left.sortOrder === right.sortOrder &&
sameSkills(left.skills, right.skills)
);
}
export function AgentConfigDialog({
open,
onOpenChange,
}: AgentConfigDialogProps) {
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<AgentConfigDraft>(() =>
emptyAngleDraft(100),
);
const [savedDraft, setSavedDraft] = useState<AgentConfigDraft | null>(null);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const queryClient = useQueryClient();
const {
data: agents = [],
isLoading,
error,
refetch,
} = useStrategyAgents({
enabled: open,
includeDisabled: true,
includeConfig: true,
});
const {
skills,
isLoading: isSkillsLoading,
error: skillsError,
} = useAllSkillsAdmin(open);
const fixedAgents = useMemo(
() => agents.filter((agent) => agent.fixed),
[agents],
);
const angleAgents = useMemo(
() => agents.filter((agent) => agent.category === "angle"),
[agents],
);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedId) ?? null,
[agents, selectedId],
);
const creating = selectedId === NEW_ANGLE_ID;
const dirty = Boolean(savedDraft && !sameDraft(draft, savedDraft));
const selectableSkills = useMemo(
() =>
skills.filter((skill) => {
const globallyAvailable =
Boolean(skill.builtin) || Boolean(skill.published);
return (
skill.enabled &&
(skill.state ?? "active") === "active" &&
globallyAvailable
);
}),
[skills],
);
useEffect(() => {
if (!open || isLoading || creating || agents.length === 0) return;
if (selectedAgent) return;
setSelectedId(agents[0].id);
}, [agents, creating, isLoading, open, selectedAgent]);
useEffect(() => {
if (!open || creating || !selectedAgent) return;
const next = draftFromAgent(selectedAgent);
setDraft(next);
setSavedDraft(next);
}, [creating, open, selectedAgent?.id, selectedAgent?.updated_at]);
const confirmDiscardChanges = () =>
!dirty || window.confirm("当前配置尚未保存,确定放弃这些修改吗?");
const selectAgent = (agentId: string) => {
if (agentId === selectedId || busy || !confirmDiscardChanges()) return;
setSelectedId(agentId);
setSavedDraft(null);
};
const startCreate = () => {
if (busy || !confirmDiscardChanges()) return;
const maxOrder = angleAgents.reduce(
(current, agent) => Math.max(current, agent.sort_order),
99,
);
const next = emptyAngleDraft(maxOrder + 1);
setSelectedId(NEW_ANGLE_ID);
setDraft(next);
setSavedDraft(next);
};
const save = async () => {
const name = draft.name.trim();
if (!name) {
toast.error("请填写智能体名称");
return;
}
setBusy(true);
try {
if (creating) {
const created = await createStrategyAngle({
name,
description: draft.description.trim(),
soul: draft.soul,
model: draft.model,
skills: draft.skills,
sort_order: draft.sortOrder,
});
setSelectedId(created.id);
const next = draftFromAgent(created);
setDraft(next);
setSavedDraft(next);
await queryClient.invalidateQueries({
queryKey: STRATEGY_AGENTS_QUERY_KEY,
});
toast.success(`${created.name} 已加入分析角度库`);
} else if (selectedAgent) {
const updated = await updateStrategyAgent(selectedAgent.id, {
name,
description: draft.description.trim(),
soul: draft.soul,
model: draft.model,
skills: draft.skills,
enabled: selectedAgent.fixed ? undefined : draft.enabled,
sort_order: selectedAgent.fixed ? undefined : draft.sortOrder,
});
const next = draftFromAgent(updated);
setDraft(next);
setSavedDraft(next);
await queryClient.invalidateQueries({
queryKey: STRATEGY_AGENTS_QUERY_KEY,
});
toast.success(`${updated.name} 配置已保存`);
}
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
} finally {
setBusy(false);
}
};
const softDelete = async () => {
if (!selectedAgent || selectedAgent.fixed || busy) return;
const confirmed = window.confirm(
`确定停用“${selectedAgent.name}”吗?\n\n它不会再出现在新项目的角度选择中,但历史项目、SOUL 和技能配置都会保留,可随时恢复。`,
);
if (!confirmed) return;
setBusy(true);
try {
const updated = await disableStrategyAngle(selectedAgent.id);
const next = draftFromAgent(updated);
setDraft(next);
setSavedDraft(next);
await queryClient.invalidateQueries({
queryKey: STRATEGY_AGENTS_QUERY_KEY,
});
toast.success(`${updated.name} 已停用,历史项目不受影响`);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "停用失败");
} finally {
setBusy(false);
}
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && (busy || !confirmDiscardChanges())) return;
onOpenChange(nextOpen);
};
const category = creating ? "angle" : selectedAgent?.category;
const categoryCopy = category ? CATEGORY_COPY[category] : null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="grid h-[min(860px,calc(100vh-40px))] max-w-[1180px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1180px]">
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
<DialogTitle className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
统一策略智能体
</DialogTitle>
<DialogDescription>
配置对所有项目全局生效。流程角色保持唯一;分析角度可扩展、排序和停用,历史项目仍保留原引用。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
<div className="mb-3 flex items-center justify-between px-1">
<div>
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
固定流程角色
</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">
仅可修改配置,不可新增、停用或删除
</p>
</div>
<Badge variant="secondary">{fixedAgents.length}</Badge>
</div>
<div className="space-y-1.5">
{fixedAgents.map((agent) => {
const selected = agent.id === selectedId;
const copy = CATEGORY_COPY[agent.category];
return (
<button
key={agent.id}
type="button"
disabled={busy}
onClick={() => selectAgent(agent.id)}
className={cn(
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary/45 bg-primary/10 shadow-sm"
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<LockKeyhole className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{copy.title}
</span>
</span>
{selected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</span>
<span className="mt-1.5 block truncate pl-5.5 text-xs text-muted-foreground">
{agent.name}
</span>
</button>
);
})}
</div>
<div className="mb-3 mt-6 flex items-center justify-between px-1">
<div>
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
分析角度库
</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">
新项目只使用当前启用的角度
</p>
</div>
<Button
type="button"
size="sm"
className="h-8 gap-1.5"
disabled={busy}
onClick={startCreate}
>
<CirclePlus className="size-3.5" />
新增
</Button>
</div>
<div className="space-y-1.5">
{angleAgents.map((agent) => {
const selected = agent.id === selectedId;
return (
<button
key={agent.id}
type="button"
disabled={busy}
onClick={() => selectAgent(agent.id)}
className={cn(
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary/45 bg-primary/10 shadow-sm"
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
!agent.enabled && "opacity-65",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<Layers3 className="size-3.5 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">
{agent.name}
</span>
</span>
{selected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</span>
<span className="mt-2 flex items-center justify-between gap-2 pl-5.5">
<span className="truncate text-[11px] text-muted-foreground">
顺序 {agent.sort_order}
</span>
<Badge
variant={agent.enabled ? "secondary" : "outline"}
className="h-5 text-[10px]"
>
{agent.enabled ? "已启用" : "已停用"}
</Badge>
</span>
</button>
);
})}
{!isLoading && angleAgents.length === 0 ? (
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
暂无分析角度,点击“新增”建立第一个角度。
</div>
) : null}
</div>
</aside>
<main className="min-h-0 overflow-y-auto px-6 py-5">
{isLoading ? (
<div className="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
正在读取全局配置…
</div>
) : error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
<p className="font-medium">策略智能体配置加载失败</p>
<p className="mt-1 leading-6 opacity-85">
{error instanceof Error ? error.message : "请稍后重试"}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => void refetch()}
>
<RotateCcw className="size-3.5" />
重新加载
</Button>
</div>
) : categoryCopy ? (
<div className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">
{creating ? "新增分析角度" : categoryCopy.title}
</h3>
{selectedAgent?.fixed ? (
<Badge variant="outline" className="gap-1">
<LockKeyhole className="size-3" /> 固定角色
</Badge>
) : null}
{!creating && selectedAgent && !selectedAgent.fixed ? (
<Badge
variant={draft.enabled ? "secondary" : "outline"}
>
{draft.enabled ? "已启用" : "已停用"}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{creating
? "创建后会成为所有策略项目可选的全局分析角度。"
: categoryCopy.description}
</p>
</div>
{!creating && selectedAgent ? (
<Badge
variant="outline"
className="max-w-[300px] truncate font-mono text-[10px]"
title={selectedAgent.id}
>
{selectedAgent.id}
</Badge>
) : null}
</div>
<section className="rounded-xl border bg-muted/15 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">
智能体名称
</span>
<Input
value={draft.name}
disabled={busy}
maxLength={100}
placeholder="例如:科技与产业链视角"
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium">
运行模型
</span>
<ModelNameSelect
value={draft.model}
onChange={(model) =>
setDraft((current) => ({ ...current, model }))
}
disabled={busy}
defaultLabel="使用系统默认模型"
title="选择该全局智能体运行时使用的大模型"
triggerClassName="h-9 w-full bg-background"
/>
</label>
<label className="block sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">
职责简介
</span>
<Textarea
value={draft.description}
disabled={busy}
maxLength={2000}
className="min-h-20 resize-y"
placeholder="说明该角色关注的问题、交付内容和边界…"
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
/>
</label>
</div>
{!creating && selectedAgent && !selectedAgent.fixed ? (
<div className="mt-4 grid gap-3 border-t pt-4 sm:grid-cols-[1fr_180px] dark:border-[#173d68]">
<div className="flex items-center justify-between rounded-lg border bg-background px-3 py-2.5 dark:border-[#28507c] dark:bg-[#08274c]">
<div>
<p className="text-sm font-medium">用于新项目</p>
<p className="mt-0.5 text-xs text-muted-foreground">
停用后历史项目仍可回显,新的角度选择将隐藏它
</p>
</div>
<Switch
checked={draft.enabled}
disabled={busy}
aria-label="启用该分析角度"
onCheckedChange={(enabled) =>
setDraft((current) => ({ ...current, enabled }))
}
/>
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium">
显示顺序
</span>
<Input
type="number"
min={-10000}
max={10000}
value={draft.sortOrder}
disabled={busy}
onChange={(event) =>
setDraft((current) => ({
...current,
sortOrder: Number(event.target.value) || 0,
}))
}
/>
</label>
</div>
) : creating ? (
<label className="mt-4 block max-w-[180px] border-t pt-4 dark:border-[#173d68]">
<span className="mb-1.5 block text-xs font-medium">
显示顺序
</span>
<Input
type="number"
min={-10000}
max={10000}
value={draft.sortOrder}
disabled={busy}
onChange={(event) =>
setDraft((current) => ({
...current,
sortOrder: Number(event.target.value) || 0,
}))
}
/>
</label>
) : null}
</section>
<label className="block">
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-primary" /> SOUL
</span>
<Textarea
value={draft.soul}
disabled={busy}
maxLength={200000}
onChange={(event) =>
setDraft((current) => ({
...current,
soul: event.target.value,
}))
}
className="min-h-[210px] resize-y font-mono text-xs leading-6"
placeholder="定义角色身份、分析方法、行为边界和输出格式…"
/>
</label>
<section className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">系统技能选择</span>
<Badge variant="secondary">
已选 {draft.skills.length} 个
</Badge>
</div>
<p className="text-xs leading-5 text-muted-foreground">
只展示当前启用且全局可见的系统技能;通过穿梭框选择,不接受手工输入技能名。
</p>
<SkillTransferSelector
skills={selectableSkills}
selected={draft.skills}
onChange={(selectedSkills) =>
setDraft((current) => ({
...current,
skills: selectedSkills,
}))
}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
{skillsError ? (
<p className="text-xs text-destructive">
技能库加载失败,请稍后重试。
</p>
) : null}
</section>
</div>
) : (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
暂无可配置角色
</div>
)}
</main>
</div>
<DialogFooter className="flex-row items-center justify-between border-t px-6 py-4 dark:border-[#173d68] sm:justify-between">
<div>
{!creating && selectedAgent && !selectedAgent.fixed ? (
selectedAgent.enabled ? (
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={busy}
onClick={() => void softDelete()}
>
<Trash2 className="size-4" />
停用并从新项目移除
</Button>
) : (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Power className="size-3.5" />
打开“用于新项目”并保存即可恢复
</span>
)
) : (
<span className="text-xs text-muted-foreground">
{creating
? "新角度创建后立即全局可用"
: "固定角色始终参与对应流程阶段"}
</span>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={busy}
onClick={() => handleOpenChange(false)}
>
关闭
</Button>
<Button
disabled={
!categoryCopy ||
!draft.name.trim() ||
busy ||
isLoading ||
isSkillsLoading ||
(!creating && !dirty)
}
onClick={() => void save()}
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : creating ? (
<CirclePlus className="size-4" />
) : (
<Save className="size-4" />
)}
{creating ? "创建分析角度" : "保存当前配置"}
</Button>
</div>
</DialogFooter>
</DialogContent>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDetailSkillName(null);
}}
/>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,44 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { parseSseChunk } from "./lib";
import type { ParallelAgentEvent, ParallelAgentPayload } from "./types";
async function readError(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
export async function streamParallelAgents(
payload: ParallelAgentPayload,
onEvent: (event: ParallelAgentEvent) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/parallel-agents/run/stream`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal,
},
);
if (!response.ok) throw new Error(await readError(response));
if (!response.body) throw new Error("服务器未返回流式响应");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const parsed = parseSseChunk(buffer, done);
buffer = parsed.rest;
parsed.events.forEach(onEvent);
if (done) break;
}
}
@@ -0,0 +1,350 @@
import type {
AgentOutputState,
IntentTurn,
StrategyAngle,
StrategyEvaluation,
StrategyTaskDraft,
} from "./types";
export const STRATEGY_AGENT_ROLES = [
{
id: "strategy-selection-intent",
name: "意图理解智能体",
role: "将自然语言需求整理为可执行任务",
},
{
id: "strategy-selection-angle-governance",
name: "治理统筹智能体",
role: "关注目标、机制、跨部门协同与执行闭环",
},
{
id: "strategy-selection-angle-diplomacy",
name: "外交沟通智能体",
role: "关注沟通渠道、危机管控与多边协调",
},
{
id: "strategy-selection-angle-economy",
name: "经济韧性智能体",
role: "关注供应链、产业、贸易与市场预期",
},
{
id: "strategy-selection-angle-security",
name: "安全风险智能体",
role: "关注预警、误判防范与非升级性风险处置",
},
{
id: "strategy-selection-angle-public-opinion",
name: "社会民生智能体",
role: "关注公众沟通、民生连续性与人道保障",
},
{
id: "strategy-selection-angle-law-policy",
name: "法律规则智能体",
role: "关注适用规则、合规边界与证据链",
},
{
id: "strategy-selection-judge",
name: "策略评判智能体",
role: "以统一量表比较策略的收益、风险与可执行性",
},
{
id: "strategy-selection-solution",
name: "方案整理智能体",
role: "将入选策略去重、消冲突并整理为最终方案",
},
] as const;
export const STRATEGY_ANGLES: StrategyAngle[] = [
{
id: "governance",
agentId: "strategy-selection-angle-governance",
name: "治理与统筹",
shortName: "治理",
description: "目标拆解、组织协同、阶段推进和反馈闭环",
accent: "#6d5dfc",
},
{
id: "diplomacy",
agentId: "strategy-selection-angle-diplomacy",
name: "外交与沟通",
shortName: "外交",
description: "危机沟通、多边协调、信号一致性与降温安排",
accent: "#0ea5e9",
},
{
id: "economy",
agentId: "strategy-selection-angle-economy",
name: "经济与产业韧性",
shortName: "经济",
description: "供应链连续性、市场预期、贸易和关键产业保障",
accent: "#10b981",
},
{
id: "security",
agentId: "strategy-selection-angle-security",
name: "安全与风险管控",
shortName: "安全",
description: "态势预警、误判防范、事件分级和非升级性处置",
accent: "#f97316",
},
{
id: "public_opinion",
agentId: "strategy-selection-angle-public-opinion",
name: "社会民生与舆情",
shortName: "民生",
description: "公众沟通、基本服务、社会心理和人道保障",
accent: "#ec4899",
},
{
id: "law_policy",
agentId: "strategy-selection-angle-law-policy",
name: "法律与规则",
shortName: "法律",
description: "适用规则、合规审查、责任边界与证据留存",
accent: "#a855f7",
},
];
export const EMPTY_TASK_DRAFT: StrategyTaskDraft = {
name: "",
detail: "",
objective: "",
prerequisites: "",
constraints: "",
deliverables: "",
acceptanceCriteria: "",
horizon: "",
selectedAngleIds: STRATEGY_ANGLES.slice(0, 4).map((angle) => angle.id),
};
export const INITIAL_INTENT_TURNS: IntentTurn[] = [
{
id: "welcome",
role: "assistant",
content:
"请描述你想解决的问题、希望达成的目标以及已知约束。我会通过对话补齐关键信息,并把需求实时整理到右侧任务草稿。",
},
];
export const TAIWAN_STRAIT_DEMO_TASK: StrategyTaskDraft = {
name: "台海局势风险管控与区域韧性提升策略",
detail:
"面向台海局势高不确定性场景,形成一套不预设立场、以避免误判和升级为核心的综合政策方案。方案聚焦危机沟通、区域经济与民生连续性、规则遵循及信息透明,不包含战术行动、目标选择或武力运用指导。",
objective:
"未来12—24个月内,降低突发事件转化为系统性危机的概率,保护平民福祉与关键经济活动,维持可验证、可持续的沟通和协调机制。",
prerequisites:
"建立跨部门联合研判机制;明确授权与对外沟通口径;掌握航运、能源、金融、通信和公共服务的基础风险数据;预留与各相关方沟通的技术渠道。",
constraints:
"遵守适用的国际法及人道原则;坚持克制、对等、可逆和非升级;不得输出军事战术、具体目标、武器使用或规避监管建议;关键事实须标注时点并交叉核验。",
deliverables:
"一份综合策略;分阶段行动清单;跨部门责任矩阵;风险预警指标;对外沟通要点;每月复盘和动态调整机制。",
acceptanceCriteria:
"措施均有责任主体、触发条件和退出条件;重要建议至少经两个角度交叉验证;高风险措施设置法律审查与升级门槛;方案可在72小时内启动首轮响应。",
horizon: "12—24个月,按0—30天、1—6个月、6—24个月分三阶段推进",
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
};
export const TAIWAN_STRAIT_DEMO_TURNS: IntentTurn[] = [
...INITIAL_INTENT_TURNS,
{
id: "demo-user",
role: "user",
isDemo: true,
content:
"请围绕台海局势做一个综合风险管控策略,重点是减少误判升级,同时兼顾经济、民生和规则约束,时间范围看未来一到两年。",
},
{
id: "demo-assistant",
role: "assistant",
isDemo: true,
content:
"已将需求整理为“风险管控与区域韧性提升”任务:以降级避险为目标,采用治理、外交、经济、安全、民生和法律六个角度并行论证;明确排除战术性军事建议,并补充了阶段周期、验收标准和启动条件。右侧字段仍可直接修改。",
},
];
export const TAIWAN_STRAIT_DEMO_OUTPUTS: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": {
status: "done",
isDemo: true,
steps: ["拆解目标", "形成协同机制", "设置复盘闭环"],
content: `### 治理统筹策略:建立“联合研判—分级响应—复盘校正”闭环
1. 设立常态化跨部门风险研判单元,统一风险词典、数据口径和事件分级。
2. 为不同风险等级预先定义责任主体、会商时限、可逆措施和退出条件,避免临时决策放大误判。
3. 建立月度桌面推演与季度复盘,重点校验交通、能源、金融、通信和公共服务之间的级联风险。
4. 重大调整实行“政策效果—升级风险—民生影响—法律合规”四项并联审查。
**优先动作:** 30天内完成责任矩阵和跨部门值守清单;90天内完成首次全链路压力测试。`,
},
"strategy-selection-angle-diplomacy": {
status: "done",
isDemo: true,
steps: ["识别沟通对象", "设计危机渠道", "校准公开信号"],
content: `### 外交沟通策略:保持多层次、可验证的危机沟通
1. 维护工作层、专业部门和高层三类沟通渠道,约定紧急事件的确认窗口与回执机制。
2. 对外表述采用“事实、原则、下一步安排”三段式,减少模糊威慑和相互误读。
3. 支持以航行安全、灾害救援、公共卫生等低政治敏感议题开展技术性接触,积累最低限度信任。
4. 引入可信第三方作为信息转递与事实核验的备份通道,但不替代直接沟通。
**触发条件:** 当出现信息不一致、渠道中断或舆情快速放大时,立即启动双渠道交叉确认。`,
},
"strategy-selection-angle-economy": {
status: "done",
isDemo: true,
steps: ["识别关键链路", "评估替代能力", "配置韧性措施"],
content: `### 经济韧性策略:先保连续性,再做结构性分散
1. 对航运、半导体、能源、金融结算和通信链路建立关键节点清单与中断情景。
2. 以库存天数、替代供应商覆盖率、运输改道时间和流动性缺口作为月度韧性指标。
3. 对短期冲击优先使用信息协调、应急库存和融资支持,避免仓促、不可逆的全面脱钩措施。
4. 中长期推动来源与路线多元化,同时保留正常经贸交往的政策空间。
**优先动作:** 对关键民生物资开展30/60/90天压力测试,并为中小企业准备定向流动性工具。`,
},
"strategy-selection-angle-security": {
status: "done",
isDemo: true,
steps: ["构建预警指标", "划分风险等级", "设计降级动作"],
content: `### 安全风险策略:以预警和降级机制减少偶发事件外溢
1. 建立覆盖异常活动、通信中断、错误信息扩散、交通受阻和市场波动的综合预警看板。
2. 将风险划分为关注、紧张、严重三级,每级只配置克制、可逆、可解释的非升级性应对动作。
3. 对来源未核实的信息实行“双源确认+时点标注”,避免单一信号直接触发重大政策响应。
4. 所有高风险决策设置冷静期、法律复核和跨部门反方意见席位。
**止损线:** 一旦沟通渠道失效或民生影响超过阈值,优先恢复沟通与公共服务,暂停可能叠加风险的新措施。`,
},
"strategy-selection-angle-public-opinion": {
status: "done",
isDemo: true,
steps: ["分析公众需求", "保障基本服务", "设计信息发布"],
content: `### 社会民生策略:稳定预期并保护基本服务
1. 建立权威、固定频率的信息发布窗口,明确已知、未知和正在核验的事项。
2. 为交通、医疗、能源、通信和教育准备连续性方案,优先保障脆弱群体。
3. 监测谣言与恐慌性行为时,采用事实澄清和服务指引,不扩大未经证实的信息。
4. 设置独立反馈渠道,收集企业、社区和民众的实际困难并纳入每日研判。
**评价指标:** 基本服务可用率、权威信息触达率、谣言纠正时长和重点群体保障覆盖率。`,
},
"strategy-selection-angle-law-policy": {
status: "done",
isDemo: true,
steps: ["识别规则边界", "建立审查清单", "设计留痕机制"],
content: `### 法律规则策略:把合规审查嵌入每个决策节点
1. 为数据共享、经济措施、应急管理和对外沟通分别建立适用规则清单。
2. 重大措施记录事实依据、授权来源、比例性判断、影响评估和退出条件。
3. 涉及平民权益、基本服务和跨境活动的措施优先进行人道影响评估。
4. 对时效性强的事项采用“快速初审—事后完整复核”,但不得跳过最低合规门槛。
**优先动作:** 建立统一法律审查模板和决策证据包,保证后续可解释、可追溯。`,
},
};
export const TAIWAN_STRAIT_DEMO_EVALUATIONS: StrategyEvaluation[] = [
{
agentId: "strategy-selection-angle-governance",
score: 92,
conclusion: "优先推荐",
comment: "跨部门闭环清晰,是其他策略落地的共同底座。",
strengths: ["责任明确", "阶段完整", "便于复盘"],
risks: ["需要稳定的牵头机制"],
},
{
agentId: "strategy-selection-angle-diplomacy",
score: 90,
conclusion: "优先推荐",
comment: "直接作用于误判风险,且措施克制、可逆。",
strengths: ["降级效果直接", "渠道有备份"],
risks: ["依赖相关方持续参与"],
},
{
agentId: "strategy-selection-angle-economy",
score: 86,
conclusion: "建议采用",
comment: "兼顾短期连续性与长期韧性,需控制过度防御成本。",
strengths: ["指标可量化", "避免一刀切"],
risks: ["跨行业数据获取成本较高"],
},
{
agentId: "strategy-selection-angle-security",
score: 91,
conclusion: "优先推荐",
comment: "预警、确认和冷静期设计可显著降低错误触发。",
strengths: ["止损线清楚", "强调双源核验"],
risks: ["阈值需持续校准"],
},
{
agentId: "strategy-selection-angle-public-opinion",
score: 84,
conclusion: "建议采用",
comment: "增强社会承压能力,建议与经济连续性方案合并实施。",
strengths: ["关注脆弱群体", "沟通指标明确"],
risks: ["需避免信息发布碎片化"],
},
{
agentId: "strategy-selection-angle-law-policy",
score: 88,
conclusion: "建议采用",
comment: "为高压情境下的决策提供必要边界和可追溯性。",
strengths: ["审查嵌入流程", "留痕充分"],
risks: ["快速响应与完整审查需平衡"],
},
];
export const TAIWAN_STRAIT_DEMO_SYNTHESIS = `# 台海局势风险管控与区域韧性提升综合方案
> **示例说明:** 本方案为产品功能演示数据,采用中立、降级避险和民生优先视角,不构成现实决策建议;正式使用前应依据最新可靠信息进行专业复核。
## 一、总体目标
在未来12—24个月内,以“减少误判、阻断升级、保护民生、维持连续性”为主线,建立可监测、可启动、可退出、可复盘的风险管控体系。任何措施均应符合适用规则,坚持克制、对等、可逆和可解释。
## 二、四条主策略
### 1. 联合研判与分级响应
- 建立跨部门常态研判单元,统一风险词典、数据口径和三级事件标准。
- 每个级别明确牵头主体、会商时限、启动阈值、允许动作和退出条件。
- 重大建议同时接受政策效果、升级风险、民生影响和法律合规审查。
### 2. 多层危机沟通与事实核验
- 维护工作层、专业部门和高层沟通渠道,并配置可信第三方备份。
- 紧急事项执行“双渠道回执、双来源确认、统一时点标注”。
- 公开沟通采用“事实—原则—下一步安排”结构,降低信号歧义。
### 3. 经济与公共服务连续性
- 围绕航运、能源、金融、通信、医疗和关键供应链建立中断情景清单。
- 用库存天数、替代覆盖率、改道时长、服务可用率等指标开展月度压力测试。
- 短期优先使用应急库存、信息协调和定向融资;中长期有序提升来源与路线多元化。
### 4. 规则嵌入与社会韧性
- 将授权来源、比例性、人道影响、退出条件和证据留存纳入统一审查模板。
- 通过固定频率的权威发布说明已知、未知和核验进度,提供清晰服务指引。
- 优先保障脆弱群体,并建立企业、社区与公众反馈的快速回流机制。
## 三、阶段安排
| 阶段 | 重点动作 | 主要产出 |
| --- | --- | --- |
| 0—30天 | 完成责任矩阵、沟通清单、关键链路盘点与法律审查模板 | 一图一表一清单、首版预警看板 |
| 1—6个月 | 开展压力测试和桌面推演,打通双渠道确认与公众反馈 | 演练报告、阈值校准、改进台账 |
| 6—24个月 | 推进结构性韧性项目并季度复盘,动态退出低效措施 | 韧性提升项目、季度评估报告 |
## 四、核心预警指标
1. 沟通渠道可用率与紧急事项回执时长。
2. 未核实信息传播速度及权威澄清时长。
3. 关键运输、能源、金融、通信和公共服务可用率。
4. 市场异常波动、关键物资库存天数与替代供应覆盖率。
5. 脆弱群体保障覆盖率和公众反馈闭环时长。
## 五、止损与调整机制
当直接沟通失效、基本服务明显下降、信息可信度不足或风险指标跨级跃升时,优先恢复沟通、事实核验和民生服务;暂停新增高风险措施。每月复盘策略组合,每季度由独立评估组检查目标偏移、措施叠加和退出条件执行情况。
`;
@@ -0,0 +1,329 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/strategy-selection.test.mjs
* node --test .runtime/unit-tests/strategy-selection.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
areStrategyTaskDraftsEquivalent,
buildIntentBrief,
buildJudgeBrief,
buildSynthesisBrief,
extractFencedJson,
normalizeIntentClarification,
parseEvaluationsFromText,
parseSseChunk,
parseTaskDraftFromText,
stripStructuredBlocks,
} from "./lib";
import { EMPTY_TASK_DRAFT, STRATEGY_ANGLES } from "./constants";
import type { AgentOutputState, StrategyAngle } from "./types";
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
const first = parseSseChunk(
'data: {"type":"agent_start","agent_id":"a"}\n\ndata: {"type":"del',
);
assert.equal(first.events.length, 1);
assert.equal(first.events[0].type, "agent_start");
const second = parseSseChunk(`${first.rest}ta","agent_id":"a","text":"你好"}\n\n`);
assert.deepEqual(second.events, [
{ type: "delta", agent_id: "a", text: "你好" },
]);
});
test("normalizeIntentClarification accepts string and object options", () => {
const clarification = normalizeIntentClarification({
type: "agent_clarification",
agent_id: "strategy-selection-intent",
question: "请选择时间范围",
options: ["短期", { id: "long", label: "长期", description: "一年以上" }],
allow_custom: true,
allow_multiple: false,
});
assert.equal(clarification.question, "请选择时间范围");
assert.deepEqual(clarification.options, [
{ id: "option-1", label: "短期", description: undefined },
{ id: "long", label: "长期", description: "一年以上" },
]);
assert.equal(clarification.allowCustom, true);
});
test("task draft equivalence ignores whitespace and angle ordering only", () => {
const left = {
...EMPTY_TASK_DRAFT,
name: "区域 风险方案",
detail: "第一行\n第二行",
selectedAngleIds: ["governance", "diplomacy"],
};
const presentationOnlyChange = {
...left,
name: " 区域 风险方案 ",
detail: "第一行 第二行",
selectedAngleIds: ["diplomacy", "governance", "diplomacy"],
};
assert.equal(
areStrategyTaskDraftsEquivalent(left, presentationOnlyChange),
true,
);
assert.equal(
areStrategyTaskDraftsEquivalent(left, {
...presentationOnlyChange,
objective: "新增目标",
}),
false,
);
});
test("intent brief retains the clarification question and the user's confirmed answer", () => {
const brief = buildIntentBrief(
[
{
id: "clarify",
role: "assistant",
content: "",
clarification: {
question: "方案主要面向谁?",
options: [{ id: "public", label: "公共部门" }],
allowCustom: true,
allowMultiple: false,
},
clarificationAnswered: true,
clarificationAnswer: "公共部门",
},
{ id: "answer", role: "user", content: "对澄清问题的回答:公共部门" },
],
EMPTY_TASK_DRAFT,
);
assert.match(brief, /意图助手澄清:方案主要面向谁/);
assert.match(brief, /用户对该澄清的确认:公共部门/);
});
test("stripStructuredBlocks keeps prose and hides machine-readable payloads", () => {
assert.equal(
stripStructuredBlocks('说明文字\n[INTENT_READY]\n```strategy-task-json\n{"name":"x"}\n```'),
"说明文字",
);
});
test("extractFencedJson and parseTaskDraftFromText read the structured task block", () => {
const text = [
"已整理任务。",
"```strategy-task-json",
JSON.stringify({
name: "测试任务",
detail: "详情",
recommended_angles: ["governance", "unknown", "law_policy"],
}),
"```",
].join("\n");
assert.deepEqual(extractFencedJson(text, "strategy-task-json"), {
name: "测试任务",
detail: "详情",
recommended_angles: ["governance", "unknown", "law_policy"],
});
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
assert.equal(task?.name, "测试任务");
assert.deepEqual(task?.selectedAngleIds, ["governance", "law_policy"]);
});
test("task parser accepts backend SOUL field aliases", () => {
const text = `\`\`\`strategy-task-json\n${JSON.stringify({
task_name: "别名任务",
task_detail: "别名详情",
expected_outputs: "综合方案",
recommended_angles: ["public_opinion", "strategy-selection-angle-law-policy"],
})}\n\`\`\``;
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
assert.equal(task?.name, "别名任务");
assert.equal(task?.detail, "别名详情");
assert.equal(task?.deliverables, "综合方案");
assert.deepEqual(task?.selectedAngleIds, ["public_opinion", "law_policy"]);
});
test("task parser rejects incomplete or unrecognized current-turn payloads", () => {
const completeFallback = {
...EMPTY_TASK_DRAFT,
name: "旧任务",
detail: "旧详情",
selectedAngleIds: ["governance"],
};
const wrap = (payload: unknown) =>
`\`\`\`strategy-task-json\n${JSON.stringify(payload)}\n\`\`\``;
assert.equal(parseTaskDraftFromText(wrap({}), completeFallback), null);
assert.equal(parseTaskDraftFromText(wrap([]), completeFallback), null);
assert.equal(
parseTaskDraftFromText(
wrap({ name: "新任务", detail: "新详情", selectedAngleIds: ["unknown"] }),
completeFallback,
),
null,
);
assert.equal(
parseTaskDraftFromText(
wrap({ name: "新任务", detail: "新详情" }),
completeFallback,
),
null,
);
});
test("evaluation parser validates scores without inventing missing candidates", () => {
const outputs: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": {
status: "done",
content: "A",
steps: [],
},
"strategy-selection-angle-law-policy": { status: "done", content: "B", steps: [] },
"strategy-selection-angle-security": {
status: "error",
content: "PARTIAL_FAILED_OUTPUT",
steps: [],
error: "failed",
},
};
const text = `\`\`\`strategy-evaluation-json\n${JSON.stringify({
evaluations: [
{
agentId: "strategy-selection-angle-governance",
score: 100,
conclusion: "优先推荐",
comment: "清晰",
},
],
})}\n\`\`\``;
const evaluations = parseEvaluationsFromText(text, outputs);
assert.equal(evaluations.length, 1);
assert.equal(evaluations[0].score, 100);
assert.equal(
evaluations.some(
(item) => item.agentId === "strategy-selection-angle-law-policy",
),
false,
);
assert.equal(buildJudgeBrief(EMPTY_TASK_DRAFT, outputs).includes("PARTIAL_FAILED_OUTPUT"), false);
});
test("judge prompt and evaluation parser only use task-selected angles", () => {
const outputs: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": { status: "done", content: "治理策略", steps: [] },
"strategy-selection-angle-law-policy": { status: "done", content: "法律策略", steps: [] },
};
const task = { ...EMPTY_TASK_DRAFT, selectedAngleIds: ["governance"] };
const brief = buildJudgeBrief(task, outputs);
assert.equal(brief.includes("治理策略"), true);
assert.equal(brief.includes("法律策略"), false);
const evaluations = parseEvaluationsFromText("无结构化量表", outputs, [
"strategy-selection-angle-governance",
]);
assert.deepEqual(evaluations, []);
});
test("dynamic administrator angles flow through intent parsing and judging without fabricated evaluation", () => {
const dynamic: StrategyAngle = {
id: "technology",
agentId: "strategy-selection-angle-dynamic-tech",
name: "科技与产业链",
shortName: "科技",
description: "关注技术依赖与产业协同",
accent: "#2563eb",
enabled: true,
};
const task = {
...EMPTY_TASK_DRAFT,
selectedAngleIds: [dynamic.id],
};
const parsed = parseTaskDraftFromText(
`\`\`\`strategy-task-json\n${JSON.stringify({
name: "动态任务",
detail: "测试管理员新增角度",
selectedAngleIds: [dynamic.agentId],
})}\n\`\`\``,
task,
[dynamic],
);
assert.deepEqual(parsed?.selectedAngleIds, [dynamic.id]);
assert.match(buildIntentBrief([], task, [dynamic]), /technology:科技与产业链/);
const outputs: Record<string, AgentOutputState> = {
[dynamic.agentId]: { status: "done", content: "科技策略", steps: [] },
};
assert.match(buildJudgeBrief(task, outputs, [dynamic]), /科技策略/);
assert.deepEqual(
parseEvaluationsFromText("没有结构化评判", outputs, [dynamic.agentId], [dynamic]),
[],
);
});
test("evaluation parser rejects missing fields, invalid scores, and duplicate agent rows", () => {
const agentId = "strategy-selection-angle-governance";
const outputs: Record<string, AgentOutputState> = {
[agentId]: { status: "done", content: "治理策略", steps: [] },
};
const wrap = (evaluations: Record<string, unknown>[]) =>
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations })}\n\`\`\``;
assert.deepEqual(
parseEvaluationsFromText(
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations: [null] })}\n\`\`\``,
outputs,
[agentId],
),
[],
);
assert.deepEqual(
parseEvaluationsFromText(wrap([{ agentId, conclusion: "建议采用" }]), outputs, [agentId]),
[],
);
assert.deepEqual(
parseEvaluationsFromText(
wrap([{ agentId, score: 80.5, conclusion: "建议采用" }]),
outputs,
[agentId],
),
[],
);
assert.deepEqual(
parseEvaluationsFromText(
wrap([
{ agentId, score: 80, conclusion: "建议采用" },
{ agentId, score: 75, conclusion: "保留备选" },
]),
outputs,
[agentId],
),
[],
);
});
test("synthesis prompt exposes only the user's final strategy basket", () => {
const governance = STRATEGY_ANGLES.find((angle) => angle.id === "governance")!;
const security = STRATEGY_ANGLES.find((angle) => angle.id === "security")!;
const task = {
...EMPTY_TASK_DRAFT,
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
};
const outputs: Record<string, AgentOutputState> = {
[governance.agentId]: { status: "done", content: "治理入选正文", steps: [] },
[security.agentId]: { status: "done", content: "安全未入选正文", steps: [] },
};
const brief = buildSynthesisBrief(
task,
[governance.agentId],
outputs,
[],
STRATEGY_ANGLES,
);
assert.match(
brief,
new RegExp(`最终只允许整合以下 1 项:${governance.name}`),
);
assert.match(brief, /治理入选正文/);
assert.equal(brief.includes("安全未入选正文"), false);
assert.match(brief, /"selectedAngleIds": \[\s*"governance"\s*\]/);
});
+408
View File
@@ -0,0 +1,408 @@
import { STRATEGY_ANGLES } from "./constants";
import type {
AgentOutputState,
IntentClarification,
IntentTurn,
ParallelAgentEvent,
StrategyAngle,
StrategyEvaluation,
StrategyTaskDraft,
} from "./types";
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function normalizeIntentClarification(
event: Extract<ParallelAgentEvent, { type: "agent_clarification" }>,
): IntentClarification {
const question =
stringValue(event.question) ||
stringValue(event.content) ||
"请补充完成任务定义所需的信息。";
const options = Array.isArray(event.options)
? event.options
.map((option, index) => {
const label =
typeof option === "string" ? stringValue(option) : stringValue(option?.label);
if (!label) return null;
return {
id:
(typeof option === "string" ? "" : stringValue(option.id)) ||
`option-${index + 1}`,
label,
description:
(typeof option === "string" ? "" : stringValue(option.description)) ||
undefined,
};
})
.filter((option): option is NonNullable<typeof option> => option !== null)
: [];
return {
question,
context:
stringValue(event.clarification_context) ||
stringValue(event.context) ||
undefined,
clarificationType: stringValue(event.clarification_type) || undefined,
options,
allowCustom: event.allow_custom !== false || options.length === 0,
allowMultiple: event.allow_multiple === true,
};
}
function selectedStrategyAgentIds(
task: StrategyTaskDraft,
angles: StrategyAngle[],
): Set<string> {
return new Set(
angles.filter((angle) => task.selectedAngleIds.includes(angle.id)).map(
(angle) => angle.agentId,
),
);
}
const TASK_DRAFT_TEXT_FIELDS = [
"name",
"detail",
"objective",
"prerequisites",
"constraints",
"deliverables",
"acceptanceCriteria",
"horizon",
] as const satisfies readonly (keyof StrategyTaskDraft)[];
function normalizeTaskDraftText(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
/**
* Treat presentation-only whitespace and angle ordering as equivalent so an
* intent-agent round trip cannot invalidate an already generated workflow
* unless the task's meaning actually changed.
*/
export function areStrategyTaskDraftsEquivalent(
left: StrategyTaskDraft,
right: StrategyTaskDraft,
): boolean {
const textFieldsMatch = TASK_DRAFT_TEXT_FIELDS.every(
(field) =>
normalizeTaskDraftText(left[field] as string) ===
normalizeTaskDraftText(right[field] as string),
);
if (!textFieldsMatch) return false;
const leftAngles = [...new Set(left.selectedAngleIds)].sort();
const rightAngles = [...new Set(right.selectedAngleIds)].sort();
return (
leftAngles.length === rightAngles.length &&
leftAngles.every((angleId, index) => angleId === rightAngles[index])
);
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(stringValue).filter(Boolean);
}
export function extractFencedJson<T>(text: string, marker?: string): T | null {
const escaped = marker?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
escaped ? new RegExp("```" + escaped + "\\s*([\\s\\S]*?)```", "i") : null,
/```(?:json)?\s*([\s\S]*?)```/i,
].filter(Boolean) as RegExp[];
for (const pattern of patterns) {
const match = pattern.exec(text);
if (!match?.[1]) continue;
try {
return JSON.parse(match[1].trim()) as T;
} catch {
// The model may have emitted another fenced block first; fall through to
// the balanced-object fallback below.
}
}
const firstObject = text.indexOf("{");
const lastObject = text.lastIndexOf("}");
if (firstObject >= 0 && lastObject > firstObject) {
try {
return JSON.parse(text.slice(firstObject, lastObject + 1)) as T;
} catch {
return null;
}
}
return null;
}
export function stripStructuredBlocks(text: string): string {
return text
.replace(/```(?:strategy-task-json|strategy-evaluation-json)\s*[\s\S]*?```/gi, "")
.replace(/\[(?:INTENT_READY|JUDGEMENT_READY)\]/gi, "")
.trim();
}
export function parseTaskDraftFromText(
text: string,
fallback: StrategyTaskDraft,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyTaskDraft | null {
const parsed = extractFencedJson<Record<string, unknown>>(text, "strategy-task-json");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const rawName = parsed.name ?? parsed.task_name;
const rawDetail = parsed.detail ?? parsed.task_detail;
const rawAngles = parsed.selectedAngleIds ?? parsed.recommended_angles;
if (
typeof rawName !== "string" ||
!rawName.trim() ||
typeof rawDetail !== "string" ||
!rawDetail.trim() ||
!Array.isArray(rawAngles)
) {
return null;
}
const angleIds = [
...new Set(
rawAngles
.map(stringValue)
.map((value) => {
const normalized = value.toLowerCase().replaceAll("-", "_");
return angles.find(
(angle) =>
angle.id === normalized ||
angle.agentId === value ||
angle.name === value ||
angle.shortName === value,
)?.id;
})
.filter((id): id is string => Boolean(id)),
),
];
if (!angleIds.length) return null;
return {
name: stringValue(rawName),
detail: stringValue(rawDetail),
objective: stringValue(parsed.objective) || fallback.objective,
prerequisites:
stringValue(parsed.prerequisites) || fallback.prerequisites,
constraints: stringValue(parsed.constraints) || fallback.constraints,
deliverables:
stringValue(parsed.deliverables ?? parsed.expected_outputs) || fallback.deliverables,
acceptanceCriteria:
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
horizon: stringValue(parsed.horizon) || fallback.horizon,
selectedAngleIds: angleIds,
};
}
export function parseEvaluationsFromText(
text: string,
outputs: Record<string, AgentOutputState>,
allowedAgentIds?: Iterable<string>,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyEvaluation[] {
const parsed = extractFencedJson<
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
>(text, "strategy-evaluation-json");
const rows = Array.isArray(parsed) ? parsed : parsed?.evaluations;
if (!Array.isArray(rows)) return [];
const allowed = allowedAgentIds ? new Set(allowedAgentIds) : null;
const knownIds = new Set(
angles.filter((angle) => !allowed || allowed.has(angle.agentId)).map(
(angle) => angle.agentId,
),
);
const evaluations: StrategyEvaluation[] = [];
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
const agentId = stringValue(row.agentId ?? row.agent_id);
if (!knownIds.has(agentId)) continue;
const score = row.score;
const conclusion = stringValue(row.conclusion);
if (
seen.has(agentId) ||
outputs[agentId]?.status !== "done" ||
!outputs[agentId]?.content.trim() ||
typeof score !== "number" ||
!Number.isFinite(score) ||
!Number.isInteger(score) ||
score < 0 ||
score > 100 ||
(conclusion !== "优先推荐" &&
conclusion !== "建议采用" &&
conclusion !== "保留备选")
) {
// Reject the entire structured result instead of inventing defaults or
// allowing duplicate rows to be selected twice downstream.
return [];
}
seen.add(agentId);
evaluations.push({
agentId,
score,
conclusion,
comment: stringValue(row.comment) || "请结合完整评判正文审阅。",
strengths: stringList(row.strengths),
risks: stringList(row.risks),
});
}
if (allowed && [...allowed].some((agentId) => !seen.has(agentId))) return [];
return evaluations.sort((a, b) => b.score - a.score);
}
export function buildIntentBrief(
turns: IntentTurn[],
currentDraft: StrategyTaskDraft,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): string {
const conversation = turns
.filter(
(turn) =>
turn.content.trim() ||
turn.clarification?.question.trim() ||
turn.clarificationAnswer?.trim(),
)
.map((turn) => {
const lines: string[] = [];
if (turn.content.trim()) {
lines.push(
`${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`,
);
}
if (turn.clarification) {
const optionText = turn.clarification.options
.map((option) => option.label)
.filter(Boolean)
.join(" / ");
lines.push(
`意图助手澄清:${turn.clarification.question}${
optionText ? `(可选:${optionText})` : ""
}`,
);
}
if (turn.clarificationAnswer?.trim()) {
lines.push(`用户对该澄清的确认:${turn.clarificationAnswer}`);
}
return lines.join("\n");
})
.join("\n\n");
const availableAngles = angles
.filter((angle) => angle.enabled !== false)
.map((angle) => `- ${angle.id}:${angle.name}(${angle.description})`)
.join("\n");
const exampleAngleIds = angles
.filter((angle) => angle.enabled !== false)
.slice(0, 3)
.map((angle) => angle.id);
return `你负责意图理解与任务定义。基于以下对话整理任务。不要虚构实时事实。\n\n当前草稿(可据新信息修订):\n${JSON.stringify(currentDraft, null, 2)}\n\n本系统当前可选角度(只能从下列 id 中选择):\n${availableAngles}\n\n对话:\n${conversation}\n\n请严格二选一执行:\n1. 如果不同答案会显著改变任务方向,立即调用 ask_clarification,只提一个最关键问题并提供清晰互斥选项;调用后停止,不输出任务 JSON,也不要在普通正文里假装提问。\n2. 如果信息已足够(包括用户刚回答了澄清),先用简洁自然语言复述整理结果,再在回复末尾追加以下可机器读取代码块,字段名不得改变:\n\`\`\`strategy-task-json\n{"name":"任务名称","detail":"任务详情","objective":"目标","prerequisites":"前置条件","constraints":"约束与边界","deliverables":"交付物","acceptanceCriteria":"验收标准","horizon":"时间范围","selectedAngleIds":${JSON.stringify(exampleAngleIds)}}\n\`\`\`\n只选择与任务有关的角度。`;
}
export function buildStrategyBrief(
task: StrategyTaskDraft,
selectedAngles = STRATEGY_ANGLES.filter((angle) =>
task.selectedAngleIds.includes(angle.id),
),
): string {
const angles = selectedAngles
.map((angle) => `- ${angle.name}:${angle.description}`)
.join("\n");
return `你是其中一个专业角度的策略生成智能体,只从自己的职责角度输出一套可独立审阅的策略。\n\n任务详情:\n${JSON.stringify(task, null, 2)}\n\n本轮并行角度:\n${angles}\n\n请给出:核心判断、3—6项策略动作、实施阶段、触发/退出条件、指标、主要风险与协同依赖。避免空泛口号;涉及动态事实时说明需复核。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入策略正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。对于地缘政治议题,坚持中立、降级避险、民生优先,不输出军事战术、目标选择、武器运用或规避监管建议。`;
}
export function buildJudgeBrief(
task: StrategyTaskDraft,
outputs: Record<string, AgentOutputState>,
angles: StrategyAngle[] = STRATEGY_ANGLES,
): string {
const selectedAgentIds = selectedStrategyAgentIds(task, angles);
const strategies = angles.filter(
(angle) =>
selectedAgentIds.has(angle.agentId) &&
outputs[angle.agentId]?.status === "done" &&
outputs[angle.agentId]?.content.trim(),
)
.map(
(angle) =>
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
)
.join("\n\n");
return `你是独立策略评判智能体。请依据任务目标,对候选策略的目标贡献、可执行性、风险可控性、协同性和合规性进行比较。先输出总体评判与冲突提醒,再在末尾输出结构化量表。工具失败、文件路径、权限问题和检索计划属于执行过程,不得写进评判正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n候选策略:\n${strategies}\n\n末尾必须追加:\n\`\`\`strategy-evaluation-json\n{"evaluations":[{"agentId":"候选agentId","score":0,"conclusion":"优先推荐|建议采用|保留备选","comment":"一句评价","strengths":["优势"],"risks":["风险"]}]}\n\`\`\`\n必须覆盖每个候选策略,score为0—100整数。`;
}
export function buildSynthesisBrief(
task: StrategyTaskDraft,
selectedAgentIds: string[],
outputs: Record<string, AgentOutputState>,
evaluations: StrategyEvaluation[],
angles: StrategyAngle[] = STRATEGY_ANGLES,
): string {
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
const uniqueSelectedAgentIds = [...new Set(selectedAgentIds)];
const selectedAngles = uniqueSelectedAgentIds
.map((agentId) => angles.find((item) => item.agentId === agentId))
.filter((angle): angle is StrategyAngle => Boolean(angle));
const selectedAngleIds = new Set(selectedAngles.map((angle) => angle.agentId));
const exactTask = {
...task,
selectedAngleIds: selectedAngles.map((angle) => angle.id),
};
const selections = uniqueSelectedAgentIds
.filter((agentId) => selectedAngleIds.has(agentId))
.map((agentId) => {
const angle = angles.find((item) => item.agentId === agentId);
const evaluation = evaluationMap.get(agentId);
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
})
.join("\n\n");
const selectedNames = selectedAngles.map((angle) => angle.name).join("、");
return `你是方案整理智能体。请将用户选中的策略消除重复、处理冲突、明确优先级和依赖关系,整理为一份可直接下载的完整 Markdown 方案。\n\n任务(selectedAngleIds 已收敛为用户最终选入的策略角度):\n${JSON.stringify(exactTask, null, 2)}\n\n本次最终只允许整合以下 ${selectedAngles.length} 项:${selectedNames}\n不得补写、复活或暗中纳入任何未列出的角度或策略。\n\n入选策略:\n${selections}\n\n只输出最终 Markdown 正文。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入方案正文;确有信息缺口时仅归入“待核验信息”小节。至少包含:执行摘要、目标与原则、综合策略、阶段路线图、责任与协同、指标与预警、风险及止损机制、待核验信息。不要输出代码围栏。对于地缘政治议题坚持中立、降级避险、民生优先,禁止军事战术和伤害性操作建议。`;
}
export function parseSseChunk(
buffer: string,
flush = false,
): { events: ParallelAgentEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = flush ? "" : (parts.pop() ?? "");
const complete = flush ? parts.concat(parts.length ? [] : [normalized]) : parts;
const events: ParallelAgentEvent[] = [];
for (const frame of complete) {
const payload = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!payload || payload === "[DONE]") continue;
try {
const event = JSON.parse(payload) as ParallelAgentEvent;
if (event && typeof event.type === "string") events.push(event);
} catch {
// Ignore one malformed frame instead of discarding the rest of a stream.
}
}
return { events, rest };
}
export function downloadMarkdown(markdown: string, fileName: string): void {
const cleanName =
fileName.replace(/\.(md|markdown)$/i, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim() ||
"strategy-plan";
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${cleanName}.md`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
strategyProjectListPath,
strategyProjectWorkbenchPath,
} from "./project-routes";
test("solution list paths work in both application shells", () => {
assert.equal(
strategyProjectListPath("/page/strategy/qa/projects", "solution"),
"/page/strategy/qa/projects?type=solution",
);
assert.equal(
strategyProjectListPath("/official/workspace/projects/", "solution"),
"/official/workspace/projects?type=solution",
);
});
test("solution workbench paths encode ids and request the solution module", () => {
assert.equal(
strategyProjectWorkbenchPath(
"/official/workspace/projects",
"strategy-selection-a/b",
"solution",
),
"/official/workspace/projects/strategy-selection-a%2Fb?module=solution",
);
});
@@ -0,0 +1,28 @@
export type StrategyProjectListCategory =
| "all"
| "qa"
| "strategy"
| "solution";
function normalizeBasePath(basePath: string): string {
return basePath.replace(/\/+$/, "");
}
export function strategyProjectListPath(
basePath: string,
category: StrategyProjectListCategory = "all",
): string {
const path = normalizeBasePath(basePath);
return category === "all"
? path
: `${path}?type=${encodeURIComponent(category)}`;
}
export function strategyProjectWorkbenchPath(
basePath: string,
projectId: string,
module?: "solution",
): string {
const path = `${normalizeBasePath(basePath)}/${encodeURIComponent(projectId)}`;
return module === "solution" ? `${path}?module=solution` : path;
}
@@ -0,0 +1,147 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/project-wal.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/project-wal.test.mjs
* node --test .runtime/unit-tests/project-wal.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { StrategyProjectSnapshot } from "./projects";
import {
clearStrategyProjectWal,
isStrategyProjectWalExpired,
LatestByKeyScheduler,
resolveStrategyProjectWal,
strategyProjectSnapshotHash,
strategyProjectWalKey,
writeStrategyProjectWal,
} from "./project-wal";
const createSnapshot = (): StrategyProjectSnapshot => ({
version: 1,
angleCatalog: [],
stage: "intent",
taskDraft: {
name: "",
detail: "",
objective: "",
prerequisites: "",
constraints: "",
deliverables: "",
acceptanceCriteria: "",
horizon: "",
selectedAngleIds: [],
},
turns: [],
outputs: {},
judgeStatus: "idle",
judgeContent: "",
evaluations: [],
selectedStrategyIds: [],
synthesis: "",
synthesisStatus: "idle",
solutionSavedAt: null,
isDemo: false,
workflowStale: false,
});
test("WAL keys isolate accounts and projects", () => {
assert.notEqual(
strategyProjectWalKey("account:one", "project-a"),
strategyProjectWalKey("account:two", "project-a"),
);
assert.notEqual(
strategyProjectWalKey("account:one", "project-a"),
strategyProjectWalKey("account:one", "project-b"),
);
});
test("snapshot hashes are stable and change with project content", async () => {
const snapshot = createSnapshot();
const same = createSnapshot();
const changed = {
...createSnapshot(),
taskDraft: {
...createSnapshot().taskDraft,
name: "已修改任务",
},
};
assert.equal(
await strategyProjectSnapshotHash(snapshot),
await strategyProjectSnapshotHash(same),
);
assert.notEqual(
await strategyProjectSnapshotHash(snapshot),
await strategyProjectSnapshotHash(changed),
);
});
test("WAL degrades safely when IndexedDB is unavailable", async () => {
const snapshot = createSnapshot();
assert.equal(
await writeStrategyProjectWal(
"account:test",
"project-test",
"base-hash",
snapshot,
),
null,
);
assert.equal(
await clearStrategyProjectWal("account:test", "project-test", snapshot),
false,
);
});
test("WAL recovery uses ancestry hashes instead of incomparable clocks", async () => {
const server = createSnapshot();
const local = {
...createSnapshot(),
taskDraft: { ...createSnapshot().taskDraft, name: "本地编辑" },
};
const otherServer = {
...createSnapshot(),
taskDraft: { ...createSnapshot().taskDraft, name: "服务器编辑" },
};
const serverHash = await strategyProjectSnapshotHash(server);
const localHash = await strategyProjectSnapshotHash(local);
const otherServerHash = await strategyProjectSnapshotHash(otherServer);
const wal = { baseServerHash: serverHash, snapshotHash: localHash };
assert.equal(resolveStrategyProjectWal(wal, serverHash), "recover");
assert.equal(resolveStrategyProjectWal(wal, localHash), "same");
assert.equal(resolveStrategyProjectWal(wal, otherServerHash), "conflict");
});
test("latest-by-key scheduler bounds queued streaming snapshots", async () => {
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const calls: number[] = [];
const scheduler = new LatestByKeyScheduler<number, number>(async (value) => {
calls.push(value);
if (value === 1) await firstBlocked;
return value;
});
const first = scheduler.schedule("project", 1);
await Promise.resolve();
const superseded = scheduler.schedule("project", 2);
const latest = scheduler.schedule("project", 3);
assert.equal(await superseded, null);
releaseFirst();
assert.equal(await first, 1);
assert.equal(await latest, 3);
assert.deepEqual(calls, [1, 3]);
await scheduler.waitForIdle("project");
});
test("WAL expiry is deterministic at the retention boundary", () => {
const now = Date.now();
const sixDaysAgo = now - 6 * 24 * 60 * 60 * 1000;
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
assert.equal(isStrategyProjectWalExpired(sixDaysAgo, now), false);
assert.equal(isStrategyProjectWalExpired(eightDaysAgo, now), true);
});
@@ -0,0 +1,362 @@
import type { StrategyProjectSnapshot } from "./projects";
const DATABASE_NAME = "deerflow-strategy-project-wal";
const STORE_NAME = "drafts";
const DATABASE_VERSION = 1;
const WAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface StrategyProjectWalRecord {
key: string;
userKey: string;
projectId: string;
/** Server snapshot on top of which this local edit was made. */
baseServerHash: string;
snapshotHash: string;
snapshot: StrategyProjectSnapshot;
updatedAt: number;
}
interface PendingJob<Input, Result> {
input: Input;
resolve: (result: Result | null) => void;
reject: (reason: unknown) => void;
}
interface KeyQueueState<Input, Result> {
running: boolean;
pending?: PendingJob<Input, Result>;
idleWaiters: Array<() => void>;
}
/** Keep at most one active and one latest pending operation per key. */
export class LatestByKeyScheduler<Input, Result> {
private readonly states = new Map<string, KeyQueueState<Input, Result>>();
public constructor(
private readonly worker: (input: Input) => Promise<Result>,
) {}
public schedule(key: string, input: Input): Promise<Result | null> {
let state = this.states.get(key);
if (!state) {
state = { running: false, idleWaiters: [] };
this.states.set(key, state);
}
return new Promise<Result | null>((resolve, reject) => {
// The superseded value has not started hashing or IndexedDB work. Resolve
// it now so its growing snapshot closure can be released immediately.
state!.pending?.resolve(null);
state!.pending = { input, resolve, reject };
void this.pump(key, state!);
});
}
public waitForIdle(key: string): Promise<void> {
const state = this.states.get(key);
if (!state || (!state.running && !state.pending)) return Promise.resolve();
return new Promise((resolve) => state.idleWaiters.push(resolve));
}
private async pump(
key: string,
state: KeyQueueState<Input, Result>,
): Promise<void> {
if (state.running) return;
const job = state.pending;
if (!job) {
this.finishKey(key, state);
return;
}
state.pending = undefined;
state.running = true;
try {
job.resolve(await this.worker(job.input));
} catch (reason) {
job.reject(reason);
} finally {
state.running = false;
if (state.pending) {
void this.pump(key, state);
} else {
this.finishKey(key, state);
}
}
}
private finishKey(
key: string,
state: KeyQueueState<Input, Result>,
): void {
this.states.delete(key);
state.idleWaiters.splice(0).forEach((resolve) => resolve());
}
}
export function strategyProjectWalKey(
userKey: string,
projectId: string,
): string {
return `${encodeURIComponent(userKey)}::${encodeURIComponent(projectId)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isUsableSnapshot(value: unknown): value is StrategyProjectSnapshot {
if (!isRecord(value) || value.version !== 1) return false;
if (value.stage !== "intent" && value.stage !== "workspace") return false;
if (!isRecord(value.taskDraft) || !Array.isArray(value.taskDraft.selectedAngleIds)) {
return false;
}
return (
Array.isArray(value.angleCatalog) &&
Array.isArray(value.turns) &&
isRecord(value.outputs) &&
Array.isArray(value.evaluations) &&
Array.isArray(value.selectedStrategyIds) &&
typeof value.synthesis === "string"
);
}
function fallbackHash(text: string): string {
let first = 0x811c9dc5;
let second = 0x9e3779b9;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
first = Math.imul(first ^ code, 0x01000193);
second = Math.imul(second ^ code, 0x85ebca6b);
}
return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0)
.toString(16)
.padStart(8, "0")}`;
}
export async function strategyProjectSnapshotHash(
snapshot: StrategyProjectSnapshot,
): Promise<string> {
const serialized = JSON.stringify(snapshot);
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(serialized),
);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
return fallbackHash(serialized);
}
export function isStrategyProjectWalExpired(
updatedAt: number,
now = Date.now(),
): boolean {
return now - updatedAt > WAL_TTL_MS;
}
export function resolveStrategyProjectWal(
wal: Pick<StrategyProjectWalRecord, "baseServerHash" | "snapshotHash">,
serverHash: string,
): "same" | "recover" | "conflict" {
if (wal.snapshotHash === serverHash) return "same";
if (wal.baseServerHash === serverHash) return "recover";
return "conflict";
}
function openDatabase(): Promise<IDBDatabase | null> {
if (typeof indexedDB === "undefined") return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: "key" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("无法打开项目恢复存储"));
request.onblocked = () => reject(new Error("项目恢复存储正在升级,请关闭其他页面后重试"));
});
}
function waitForTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("项目恢复存储事务失败"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("项目恢复存储事务已中止"));
});
}
function readRequest(request: IDBRequest): Promise<unknown> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("无法读取项目恢复记录"));
});
}
async function writeWalRecord(
record: StrategyProjectWalRecord,
): Promise<StrategyProjectWalRecord | null> {
const database = await openDatabase();
if (!database) return null;
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const transactionDone = waitForTransaction(transaction);
const store = transaction.objectStore(STORE_NAME);
// Queue the mutation from inside IDB's success callback. Awaiting the GET
// and only then issuing PUT is racy in Safari because the transaction may
// become inactive between promise turns.
const outcome = new Promise<StrategyProjectWalRecord>((resolve, reject) => {
const request = store.get(record.key);
request.onerror = () =>
reject(request.error ?? new Error("无法读取项目恢复记录"));
request.onsuccess = () => {
try {
const existing = request.result;
if (
!isRecord(existing) ||
typeof existing.updatedAt !== "number" ||
existing.updatedAt <= record.updatedAt
) {
store.put(record);
resolve(record);
return;
}
resolve(existing as unknown as StrategyProjectWalRecord);
} catch (reason) {
reject(reason);
}
};
});
const [result] = await Promise.all([outcome, transactionDone]);
return result;
} finally {
database.close();
}
}
interface WalWriteInput {
userKey: string;
projectId: string;
baseServerHash: string;
snapshot: StrategyProjectSnapshot;
updatedAt: number;
}
const writeScheduler = new LatestByKeyScheduler<
WalWriteInput,
StrategyProjectWalRecord | null
>(async ({ userKey, projectId, baseServerHash, snapshot, updatedAt }) => {
const record: StrategyProjectWalRecord = {
key: strategyProjectWalKey(userKey, projectId),
userKey,
projectId,
baseServerHash,
snapshotHash: await strategyProjectSnapshotHash(snapshot),
snapshot,
updatedAt,
};
return writeWalRecord(record);
});
export function writeStrategyProjectWal(
userKey: string,
projectId: string,
baseServerHash: string,
snapshot: StrategyProjectSnapshot,
): Promise<StrategyProjectWalRecord | null> {
const key = strategyProjectWalKey(userKey, projectId);
return writeScheduler.schedule(key, {
userKey,
projectId,
baseServerHash,
snapshot,
updatedAt: Date.now(),
});
}
export async function readStrategyProjectWal(
userKey: string,
projectId: string,
): Promise<StrategyProjectWalRecord | null> {
const key = strategyProjectWalKey(userKey, projectId);
await writeScheduler.waitForIdle(key);
const database = await openDatabase();
if (!database) return null;
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const transactionDone = waitForTransaction(transaction);
const [record] = await Promise.all([
readRequest(transaction.objectStore(STORE_NAME).get(key)),
transactionDone,
]);
if (!isRecord(record) || !isUsableSnapshot(record.snapshot)) return null;
if (
record.userKey !== userKey ||
record.projectId !== projectId ||
typeof record.baseServerHash !== "string" ||
typeof record.snapshotHash !== "string" ||
typeof record.updatedAt !== "number"
) {
return null;
}
const typed = record as unknown as StrategyProjectWalRecord;
if (isStrategyProjectWalExpired(typed.updatedAt)) {
void clearStrategyProjectWal(userKey, projectId, typed.snapshot).catch(
() => undefined,
);
return null;
}
return typed;
} finally {
database.close();
}
}
export async function clearStrategyProjectWal(
userKey: string,
projectId: string,
expectedSnapshot?: StrategyProjectSnapshot,
): Promise<boolean> {
const key = strategyProjectWalKey(userKey, projectId);
const expectedHash = expectedSnapshot
? await strategyProjectSnapshotHash(expectedSnapshot)
: null;
await writeScheduler.waitForIdle(key);
const database = await openDatabase();
if (!database) return false;
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const transactionDone = waitForTransaction(transaction);
const store = transaction.objectStore(STORE_NAME);
if (!expectedSnapshot) {
store.delete(key);
await transactionDone;
return true;
}
const deletedPromise = new Promise<boolean>((resolve, reject) => {
const request = store.get(key);
request.onerror = () =>
reject(request.error ?? new Error("无法读取项目恢复记录"));
request.onsuccess = () => {
try {
const existing = request.result;
const matches =
isRecord(existing) && existing.snapshotHash === expectedHash;
if (matches) store.delete(key);
resolve(matches);
} catch (reason) {
reject(reason);
}
};
});
const [deleted] = await Promise.all([deletedPromise, transactionDone]);
return deleted;
} finally {
database.close();
}
}
@@ -0,0 +1,469 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getAccountIdentityKey } from "@/core/auth";
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,
StrategyAngle,
StrategyEvaluation,
StrategyStage,
StrategyTaskDraft,
} from "./types";
import { clearStrategyProjectWal } from "./project-wal";
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
const SNAPSHOT_KIND = "strategy-selection-project";
// A strategy project can be mounted twice briefly during route transitions or
// opened in more than one tab. Serialize saves in this JS realm and, where
// supported, across same-browser tabs so an older PUT cannot finish after and
// overwrite a newer snapshot.
const projectSaveQueues = new Map<string, Promise<void>>();
async function withBrowserProjectLock<T>(
key: string,
operation: () => Promise<T>,
): Promise<T> {
if (typeof navigator !== "undefined" && navigator.locks) {
return navigator.locks.request(`deerflow-strategy-save:${key}`, operation);
}
return operation();
}
function serializeProjectSave<T>(
key: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = projectSaveQueues.get(key) ?? Promise.resolve();
const request = previous
.catch(() => undefined)
.then(() => withBrowserProjectLock(key, operation));
const tail = request.then(
() => undefined,
() => undefined,
);
projectSaveQueues.set(key, tail);
void tail.then(() => {
if (projectSaveQueues.get(key) === tail) projectSaveQueues.delete(key);
});
return request;
}
const DEMO_JUDGE_CONTENT = `### 评判结论
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
**组合提醒:** 对外沟通节奏、市场稳定措施和风险等级调整必须使用同一事实基线;任何高风险政策动作均需设置冷静期、法律复核与退出条件。`;
export interface StrategyProjectSnapshot {
version: 1;
/** Copy of the angle catalog used by this project, for historical rendering. */
angleCatalog: StrategyAngle[];
stage: StrategyStage;
taskDraft: StrategyTaskDraft;
turns: IntentTurn[];
outputs: Record<string, AgentOutputState>;
judgeStatus: AgentOutputState["status"];
judgeContent: string;
evaluations: StrategyEvaluation[];
selectedStrategyIds: string[];
synthesis: string;
synthesisStatus: AgentOutputState["status"];
solutionSavedAt: string | null;
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;
}
export class StrategyProjectConflictError extends Error {
public constructor(message: string) {
super(message);
this.name = "StrategyProjectConflictError";
}
}
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 cloneAngles = (angles: StrategyAngle[]): StrategyAngle[] =>
angles.map((angle) => ({ ...angle }));
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(
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyProjectSnapshot {
const enabledAngles = angleCatalog.filter((angle) => angle.enabled !== false);
return {
version: 1,
angleCatalog: cloneAngles(enabledAngles),
stage: "intent",
taskDraft: {
...cloneTask(EMPTY_TASK_DRAFT),
selectedAngleIds: enabledAngles.slice(0, 4).map((angle) => angle.id),
},
turns: cloneTurns(INITIAL_INTENT_TURNS),
outputs: {},
judgeStatus: "idle",
judgeContent: "",
evaluations: [],
selectedStrategyIds: [],
synthesis: "",
synthesisStatus: "idle",
solutionSavedAt: null,
isDemo: false,
workflowStale: false,
};
}
export function canCreateTaiwanDemo(
angleCatalog: StrategyAngle[],
): boolean {
const enabledAgentIds = new Set(
angleCatalog
.filter((angle) => angle.enabled !== false)
.map((angle) => angle.agentId),
);
return STRATEGY_ANGLES.every((angle) => enabledAgentIds.has(angle.agentId));
}
export function createDemoStrategySnapshot(
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
): StrategyProjectSnapshot {
if (!canCreateTaiwanDemo(angleCatalog)) {
throw new Error("台海示例所需的六个默认角度未全部启用,请先由管理员恢复配置");
}
const enabledAgentIds = new Set(
angleCatalog
.filter((angle) => angle.enabled !== false)
.map((angle) => angle.agentId),
);
const defaultDemoAgentIds = new Set(
STRATEGY_ANGLES.map((angle) => angle.agentId),
);
const demoAngles = angleCatalog.filter(
(angle) =>
angle.enabled !== false &&
enabledAgentIds.has(angle.agentId) &&
defaultDemoAgentIds.has(angle.agentId),
);
const effectiveAngles = demoAngles;
const demoOutputs = Object.fromEntries(
Object.entries(cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS)).filter(([agentId]) =>
effectiveAngles.some((angle) => angle.agentId === agentId),
),
);
const demoEvaluations = TAIWAN_STRAIT_DEMO_EVALUATIONS.filter((evaluation) =>
effectiveAngles.some((angle) => angle.agentId === evaluation.agentId),
);
return {
version: 1,
angleCatalog: cloneAngles(effectiveAngles),
stage: "workspace",
taskDraft: {
...cloneTask(TAIWAN_STRAIT_DEMO_TASK),
selectedAngleIds: effectiveAngles.map((angle) => angle.id),
},
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
outputs: demoOutputs,
judgeStatus: "done",
judgeContent: DEMO_JUDGE_CONTENT,
evaluations: cloneEvaluations(demoEvaluations),
selectedStrategyIds: demoEvaluations.filter(
(item) => item.score >= 88,
).map((item) => item.agentId),
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
synthesisStatus: "done",
solutionSavedAt: null,
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,
angleCatalog: cloneAngles(
Array.isArray(snapshot.angleCatalog) && snapshot.angleCatalog.length
? snapshot.angleCatalog.filter(
(angle) =>
isObject(angle) &&
typeof angle.id === "string" &&
typeof angle.agentId === "string" &&
typeof angle.name === "string",
)
: STRATEGY_ANGLES,
),
workflowStale: snapshot.workflowStale === true,
solutionSavedAt:
typeof snapshot.solutionSavedAt === "string"
? snapshot.solutionSavedAt
: null,
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 = snapshot.angleCatalog.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: {
expectedUpdatedAt?: string | null;
keepalive?: boolean;
signal?: AbortSignal;
} = {},
): Promise<StrategyProject> {
const account = getAccountIdentityKey() ?? "anonymous";
return serializeProjectSave(`${account}:${id}`, async () => {
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)],
expected_updated_at: options.expectedUpdatedAt ?? undefined,
}),
});
if (!response.ok) {
const message = await readError(response);
if (response.status === 409) {
throw new StrategyProjectConflictError(message);
}
throw new Error(message);
}
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));
}
void clearStrategyProjectWal(
getAccountIdentityKey() ?? "anonymous",
id,
).catch(() => undefined);
}
@@ -0,0 +1,112 @@
import type { StrategyAngle } from "./types";
export type StrategyAgentCategory =
| "intent"
| "angle"
| "judge"
| "solution";
export interface StrategyAgent {
id: string;
category: StrategyAgentCategory;
fixed: boolean;
enabled: boolean;
sort_order: number;
name: string;
description: string;
model: string | null;
soul: string | null;
skills: string[] | null;
created_at: string | null;
updated_at: string | null;
}
const STRATEGY_ANGLE_PREFIX = "strategy-selection-angle-";
const LEGACY_ANGLE_IDS: Record<string, string> = {
"strategy-selection-angle-governance": "governance",
"strategy-selection-angle-diplomacy": "diplomacy",
"strategy-selection-angle-economy": "economy",
"strategy-selection-angle-security": "security",
"strategy-selection-angle-public-opinion": "public_opinion",
"strategy-selection-angle-law-policy": "law_policy",
};
const LEGACY_SHORT_NAMES: Record<string, string> = {
"strategy-selection-angle-governance": "治理",
"strategy-selection-angle-diplomacy": "外交",
"strategy-selection-angle-economy": "经济",
"strategy-selection-angle-security": "安全",
"strategy-selection-angle-public-opinion": "民生",
"strategy-selection-angle-law-policy": "法律",
};
const LEGACY_ACCENTS: Record<string, string> = {
"strategy-selection-angle-governance": "#6d5dfc",
"strategy-selection-angle-diplomacy": "#0ea5e9",
"strategy-selection-angle-economy": "#10b981",
"strategy-selection-angle-security": "#f97316",
"strategy-selection-angle-public-opinion": "#ec4899",
"strategy-selection-angle-law-policy": "#a855f7",
};
const ACCENT_PALETTE = [
"#2563eb",
"#0891b2",
"#059669",
"#ca8a04",
"#ea580c",
"#dc2626",
"#db2777",
"#7c3aed",
] as const;
function stableAccent(id: string) {
if (LEGACY_ACCENTS[id]) return LEGACY_ACCENTS[id];
let hash = 2166136261;
for (let index = 0; index < id.length; index += 1) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ACCENT_PALETTE[(hash >>> 0) % ACCENT_PALETTE.length];
}
function displayAngleName(name: string) {
const cleaned = name
.trim()
.replace(/^策略(?:分析)?视角[·•::\s-]*/, "")
.replace(/(?:分析)?智能体$/, "")
.trim();
return cleaned || name.trim() || "未命名视角";
}
/** Map the public API shape to the workbench's stable angle representation. */
export function strategyAgentToAngle(
agent: StrategyAgent,
): StrategyAngle | null {
if (agent.category !== "angle") return null;
const suffix = agent.id.startsWith(STRATEGY_ANGLE_PREFIX)
? agent.id.slice(STRATEGY_ANGLE_PREFIX.length)
: agent.id;
const name = displayAngleName(agent.name);
return {
id: LEGACY_ANGLE_IDS[agent.id] ?? suffix,
agentId: agent.id,
name,
shortName:
LEGACY_SHORT_NAMES[agent.id] ??
name.split(/[与和及、·/]/u, 1)[0].slice(0, 6),
description: agent.description,
accent: stableAccent(agent.id),
enabled: agent.enabled,
};
}
export function strategyAgentsToAngles(
agents: StrategyAgent[],
): StrategyAngle[] {
return agents.flatMap((agent) => {
const angle = strategyAgentToAngle(agent);
return angle ? [angle] : [];
});
}
@@ -0,0 +1,66 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/strategy-agents.test.ts --bundle --format=esm --platform=node --outfile=.tmp-tests/strategy-agents.test.mjs
* node --test .tmp-tests/strategy-agents.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
strategyAgentToAngle,
strategyAgentsToAngles,
type StrategyAgent,
} from "./strategy-agent-mapping";
function agent(overrides: Partial<StrategyAgent> = {}): StrategyAgent {
return {
id: "strategy-selection-angle-public-opinion",
category: "angle",
fixed: false,
enabled: true,
sort_order: 100,
name: "策略视角·社会民生",
description: "关注公共服务连续性",
model: null,
soul: null,
skills: null,
created_at: null,
updated_at: null,
...overrides,
};
}
test("keeps legacy project ids while accepting the public redacted shape", () => {
assert.deepEqual(strategyAgentToAngle(agent()), {
id: "public_opinion",
agentId: "strategy-selection-angle-public-opinion",
name: "社会民生",
shortName: "民生",
description: "关注公共服务连续性",
accent: "#ec4899",
enabled: true,
});
});
test("derives stable dynamic ids and accents from the reserved prefix", () => {
const dynamic = agent({
id: "strategy-selection-angle-abc123",
name: "科技与产业链智能体",
});
const first = strategyAgentToAngle(dynamic);
const second = strategyAgentToAngle({ ...dynamic });
assert.equal(first?.id, "abc123");
assert.equal(first?.shortName, "科技");
assert.equal(first?.accent, second?.accent);
});
test("filters fixed workflow roles out of the angle catalog", () => {
assert.equal(
strategyAgentsToAngles([
agent(),
agent({ id: "strategy-selection-intent", category: "intent" }),
]).length,
1,
);
});
@@ -0,0 +1,128 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import {
strategyAgentsToAngles,
type StrategyAgent,
} from "./strategy-agent-mapping";
export {
strategyAgentToAngle,
strategyAgentsToAngles,
} from "./strategy-agent-mapping";
export type {
StrategyAgent,
StrategyAgentCategory,
} from "./strategy-agent-mapping";
export interface CreateStrategyAngleInput {
name: string;
description?: string;
soul?: string;
model?: string | null;
skills?: string[];
sort_order?: number;
}
export interface UpdateStrategyAgentInput {
name?: string;
description?: string;
soul?: string;
model?: string | null;
skills?: string[];
enabled?: boolean;
sort_order?: number;
}
export const STRATEGY_AGENTS_QUERY_KEY = ["strategy-agents"] as const;
function endpoint(suffix = "") {
return `${getBackendBaseURL()}/api/strategy-agents${suffix}`;
}
async function responseError(response: Response) {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
export async function listStrategyAgents(options?: {
includeDisabled?: boolean;
includeConfig?: boolean;
}): Promise<StrategyAgent[]> {
const query = new URLSearchParams();
if (options?.includeDisabled) query.set("include_disabled", "true");
if (options?.includeConfig) query.set("include_config", "true");
const response = await apiFetch(
`${endpoint()}${query.size ? `?${query.toString()}` : ""}`,
);
if (!response.ok) throw new Error(await responseError(response));
const body = (await response.json()) as { agents?: StrategyAgent[] };
return body.agents ?? [];
}
export async function createStrategyAngle(
input: CreateStrategyAngleInput,
): Promise<StrategyAgent> {
const response = await apiFetch(endpoint("/angles"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await responseError(response));
return (await response.json()) as StrategyAgent;
}
export async function updateStrategyAgent(
id: string,
input: UpdateStrategyAgentInput,
): Promise<StrategyAgent> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await responseError(response));
return (await response.json()) as StrategyAgent;
}
export async function disableStrategyAngle(id: string): Promise<StrategyAgent> {
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
method: "DELETE",
});
if (!response.ok) throw new Error(await responseError(response));
return (await response.json()) as StrategyAgent;
}
export function useStrategyAgents(options?: {
enabled?: boolean;
includeDisabled?: boolean;
includeConfig?: boolean;
}) {
const includeDisabled = options?.includeDisabled ?? false;
const includeConfig = options?.includeConfig ?? false;
return useQuery({
queryKey: [
...STRATEGY_AGENTS_QUERY_KEY,
includeDisabled,
includeConfig,
],
queryFn: () => listStrategyAgents({ includeDisabled, includeConfig }),
enabled: options?.enabled ?? true,
staleTime: 30_000,
});
}
/** Reusable active-angle query for the workbench and project creation flow. */
export function useStrategyAngleCatalog(enabled = true) {
const query = useStrategyAgents({ enabled });
const angles = useMemo(
() => strategyAgentsToAngles(query.data ?? []),
[query.data],
);
return { ...query, angles };
}
@@ -0,0 +1,128 @@
export type StrategyStage = "intent" | "workspace";
export type AgentRunStatus =
| "idle"
| "queued"
| "running"
| "done"
| "error";
export interface StrategyAngle {
id: string;
agentId: string;
name: string;
shortName: string;
description: string;
accent: string;
/** False when an angle survives only in an existing project snapshot. */
enabled?: boolean;
}
export interface StrategyTaskDraft {
name: string;
detail: string;
objective: string;
prerequisites: string;
constraints: string;
deliverables: string;
acceptanceCriteria: string;
horizon: string;
selectedAngleIds: string[];
}
export interface IntentTurn {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
isDemo?: boolean;
clarification?: IntentClarification;
clarificationAnswered?: boolean;
clarificationAnswer?: string;
reasoning?: string;
error?: string;
retryPrompt?: string;
retryClarificationTurnId?: string;
}
export interface IntentClarificationOption {
id: string;
label: string;
description?: string;
}
export interface IntentClarification {
question: string;
context?: string;
clarificationType?: string;
options: IntentClarificationOption[];
allowCustom: boolean;
allowMultiple: boolean;
}
export interface AgentOutputState {
status: AgentRunStatus;
content: string;
steps: string[];
reasoning?: string;
error?: string;
/** A failed refresh kept the last successful content for this same task. */
preservedFromPrevious?: boolean;
isDemo?: boolean;
}
export interface StrategyEvaluation {
agentId: string;
score: number;
conclusion: "优先推荐" | "建议采用" | "保留备选";
comment: string;
strengths: string[];
risks: string[];
}
export interface ParallelAgentPayload {
task_name: string;
brief: string;
agent_ids: string[];
model?: string;
}
export type ParallelAgentEvent =
| { type: "agent_start"; agent_id: string }
| { type: "delta"; agent_id: string; text: string }
| {
type: "step";
agent_id: string;
key?: string;
label?: string;
args?: Record<string, unknown>;
}
| { type: "agent_done"; agent_id: string; content: string }
| { type: "agent_error"; agent_id: string; message: string }
| {
type: "agent_clarification";
agent_id: string;
content?: string;
question?: string;
clarification_type?: string;
clarification_context?: string;
context?: string;
options?: Array<
| string
| {
id?: string;
label?: string;
description?: string;
}
>;
allow_custom?: boolean;
allow_multiple?: boolean;
}
| {
type: "thinking" | "reasoning" | "reasoning_delta";
agent_id: string;
text?: string;
delta?: string;
content?: string;
}
| { type: "all_done" };