feat: 完善策略优选项目工作流
This commit is contained in:
1 parent
a26fd32eba
commit
5d6627af50
45 files changed
+7235
-768
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bot, Check, LoaderCircle, Save, Sparkles } from "lucide-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";
|
||||
@@ -15,13 +28,21 @@ import {
|
||||
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 { getAgent, updateAgent } from "@/core/agents/api";
|
||||
import type { Agent } from "@/core/agents/types";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { STRATEGY_AGENT_ROLES } from "./constants";
|
||||
import {
|
||||
createStrategyAngle,
|
||||
disableStrategyAngle,
|
||||
STRATEGY_AGENTS_QUERY_KEY,
|
||||
updateStrategyAgent,
|
||||
useStrategyAgents,
|
||||
type StrategyAgent,
|
||||
type StrategyAgentCategory,
|
||||
} from "./strategy-agents";
|
||||
|
||||
interface AgentConfigDialogProps {
|
||||
open: boolean;
|
||||
@@ -29,13 +50,43 @@ interface AgentConfigDialogProps {
|
||||
}
|
||||
|
||||
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));
|
||||
return [...new Set(skills)].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function sameSkills(left: string[], right: string[]) {
|
||||
@@ -47,27 +98,84 @@ function sameSkills(left: string[], right: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps) {
|
||||
const [selectedId, setSelectedId] = useState<string>(STRATEGY_AGENT_ROLES[0].id);
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [soul, setSoul] = useState("");
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selectedRole = useMemo(
|
||||
() => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!,
|
||||
[selectedId],
|
||||
);
|
||||
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) => {
|
||||
@@ -81,223 +189,539 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
||||
}),
|
||||
[skills],
|
||||
);
|
||||
const dirty = Boolean(
|
||||
agent &&
|
||||
savedDraft &&
|
||||
(soul !== savedDraft.soul ||
|
||||
model !== savedDraft.model ||
|
||||
!sameSkills(selectedSkills, savedDraft.skills)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedId) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setAgent(null);
|
||||
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);
|
||||
void getAgent(selectedId)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
setAgent(data);
|
||||
const nextDraft = {
|
||||
soul: data.soul ?? "",
|
||||
model: data.model ?? null,
|
||||
skills: data.skills ?? [],
|
||||
};
|
||||
setSoul(nextDraft.soul);
|
||||
setModel(nextDraft.model);
|
||||
setSelectedSkills(nextDraft.skills);
|
||||
setSavedDraft(nextDraft);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!active) return;
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "智能体配置加载失败,请确认后端已完成默认配置初始化。",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, selectedId]);
|
||||
};
|
||||
|
||||
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 () => {
|
||||
if (!agent) return;
|
||||
setSaving(true);
|
||||
const name = draft.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请填写智能体名称");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await updateAgent(agent.id, {
|
||||
soul: soul.trim(),
|
||||
model,
|
||||
skills: selectedSkills,
|
||||
});
|
||||
setAgent(updated);
|
||||
setSoul(updated.soul ?? "");
|
||||
setModel(updated.model ?? null);
|
||||
setSelectedSkills(updated.skills ?? []);
|
||||
setSavedDraft({
|
||||
soul: updated.soul ?? "",
|
||||
model: updated.model ?? null,
|
||||
skills: updated.skills ?? [],
|
||||
});
|
||||
toast.success(`${updated.name} 配置已保存`);
|
||||
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 {
|
||||
setSaving(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDiscardChanges = () =>
|
||||
!dirty || window.confirm("当前智能体配置尚未保存,确定放弃这些修改吗?");
|
||||
|
||||
const selectRole = (roleId: string) => {
|
||||
if (roleId === selectedId || saving || !confirmDiscardChanges()) return;
|
||||
setSelectedId(roleId);
|
||||
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 && (saving || !confirmDiscardChanges())) return;
|
||||
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="h-[min(780px,calc(100vh-48px))] max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1080px]">
|
||||
<DialogContent className="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>
|
||||
这是全局统一配置,不属于某一个项目。管理员保存后,所有策略项目下一次运行都会使用这里的模型、SOUL 和技能。
|
||||
配置对所有项目全局生效。流程角色保持唯一;分析角度可扩展、排序和停用,历史项目仍保留原引用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[290px_minmax(0,1fr)]">
|
||||
<div className="min-h-0 overflow-y-auto border-r bg-muted/20 p-3 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||
<div className="mb-2 px-2 text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
流程角色 · {STRATEGY_AGENT_ROLES.length}
|
||||
<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">
|
||||
{STRATEGY_AGENT_ROLES.map((role) => {
|
||||
const selected = role.id === selectedId;
|
||||
{fixedAgents.map((agent) => {
|
||||
const selected = agent.id === selectedId;
|
||||
const copy = CATEGORY_COPY[agent.category];
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
key={agent.id}
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => selectRole(role.id)}
|
||||
disabled={busy}
|
||||
onClick={() => selectAgent(agent.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"w-full rounded-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 hover:border-border hover:bg-background dark:hover:border-[#28507c] dark:hover:bg-[#08274c]",
|
||||
: "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="text-sm font-medium">{role.name}</span>
|
||||
{selected && <Check className="size-4 text-primary" />}
|
||||
<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 block text-xs leading-5 text-muted-foreground">
|
||||
{role.role}
|
||||
<span className="mt-1.5 block truncate pl-5.5 text-xs text-muted-foreground">
|
||||
{agent.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-3">
|
||||
<div className="mb-3 mt-6 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h3 className="font-semibold">{selectedRole.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedRole.role}</p>
|
||||
<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="outline" className="font-mono text-[11px]">
|
||||
{selectedRole.id}
|
||||
</Badge>
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-56 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<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-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<p className="font-medium">暂时无法加载该智能体</p>
|
||||
<p className="mt-1 leading-6 opacity-85">{error}</p>
|
||||
<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>
|
||||
) : agent ? (
|
||||
) : categoryCopy ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] gap-3 rounded-lg border bg-muted/20 p-3 text-sm dark:border-[#173d68] dark:bg-[#031b38]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">当前名称</span>
|
||||
<div className="mt-1 font-medium">{agent.name}</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>
|
||||
<label className="block">
|
||||
<span className="text-xs text-muted-foreground">运行模型</span>
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={saving}
|
||||
defaultLabel="使用系统默认模型"
|
||||
title="选择该全局智能体运行时使用的大模型;未指定时使用系统默认模型"
|
||||
triggerClassName="mt-1 w-full bg-background"
|
||||
/>
|
||||
</label>
|
||||
{!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={soul}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSoul(event.target.value)}
|
||||
className="min-h-[220px] resize-y font-mono text-xs leading-6"
|
||||
placeholder="定义智能体的角色、分析方法、边界和输出要求…"
|
||||
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>
|
||||
|
||||
<div className="space-y-2">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">技能库选择</span>
|
||||
<Badge variant="secondary">已选 {selectedSkills.length} 个</Badge>
|
||||
<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={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
selected={draft.skills}
|
||||
onChange={(selectedSkills) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
skills: selectedSkills,
|
||||
}))
|
||||
}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
{skillsError ? (
|
||||
<p className="text-xs text-destructive">技能库加载失败,请稍后重试。</p>
|
||||
<p className="text-xs text-destructive">
|
||||
技能库加载失败,请稍后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
暂无可配置角色
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4 dark:border-[#173d68]">
|
||||
<Button variant="outline" disabled={saving} onClick={() => handleOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button disabled={!agent || loading || saving || isSkillsLoading} onClick={() => void save()}>
|
||||
{saving ? <LoaderCircle className="animate-spin" /> : <Save />}
|
||||
保存当前智能体
|
||||
</Button>
|
||||
<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
|
||||
|
||||
@@ -31,9 +31,14 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiFetch, getAPIClient } from "@/core/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
useStrategyProjectSettings,
|
||||
useUpdateStrategyProjectSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
@@ -49,9 +54,13 @@ import {
|
||||
} from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { STRATEGY_ANGLES } from "./constants";
|
||||
import { AgentConfigDialog } from "./AgentConfigDialog";
|
||||
import {
|
||||
strategyProjectListPath,
|
||||
strategyProjectWorkbenchPath,
|
||||
} from "./project-routes";
|
||||
import {
|
||||
canCreateTaiwanDemo,
|
||||
createDemoStrategySnapshot,
|
||||
createEmptyStrategySnapshot,
|
||||
createStrategyProject,
|
||||
@@ -60,6 +69,7 @@ import {
|
||||
type StrategyProjectSnapshot,
|
||||
type StrategyProjectSummary,
|
||||
} from "./projects";
|
||||
import { useStrategyAngleCatalog } from "./strategy-agents";
|
||||
|
||||
type ProjectCategory = "all" | "qa" | "strategy" | "solution";
|
||||
type StrategyProjectListItem = StrategyProjectSummary & {
|
||||
@@ -324,7 +334,14 @@ function ProjectCard({
|
||||
const progress = projectProgress(project);
|
||||
const isSolution = category === "solution";
|
||||
return (
|
||||
<article className="group relative flex min-h-[260px] flex-col overflow-hidden rounded-2xl border bg-card shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-lg dark:border-[#244d78] dark:bg-[#06274b]">
|
||||
<article
|
||||
className="group relative flex min-h-[260px] cursor-pointer flex-col overflow-hidden rounded-2xl border bg-card shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-lg dark:border-[#244d78] dark:bg-[#06274b]"
|
||||
onClick={(event) => {
|
||||
if (!(event.target instanceof Element) || !event.target.closest("button")) {
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("h-1 bg-gradient-to-r", isSolution ? "from-emerald-500 via-cyan-500 to-blue-500" : "from-violet-500 via-blue-500 to-cyan-400")} />
|
||||
<div className="flex min-h-0 flex-1 flex-col p-5 text-left">
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
@@ -386,13 +403,23 @@ function ProjectCard({
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={deleting}
|
||||
onClick={onDelete}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label={`删除${project.title}`}
|
||||
>
|
||||
{deleting ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onOpen}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{isSolution
|
||||
? project.hasSolution
|
||||
? "打开方案"
|
||||
@@ -409,7 +436,7 @@ function isSolutionReadyProject(project: StrategyProjectListItem): boolean {
|
||||
const snapshot = project.snapshot;
|
||||
if (!snapshot || snapshot.workflowStale) return false;
|
||||
|
||||
const selectedAngles = STRATEGY_ANGLES.filter((angle) =>
|
||||
const selectedAngles = snapshot.angleCatalog.filter((angle) =>
|
||||
snapshot.taskDraft.selectedAngleIds.includes(angle.id),
|
||||
);
|
||||
const strategiesSettled = Boolean(
|
||||
@@ -458,6 +485,17 @@ export default function StrategyProjectsPage({
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const storedAuth = getStoredAuth();
|
||||
const canConfigure = storedAuth?.system_role === "admin";
|
||||
const {
|
||||
data: strategyProjectSettings,
|
||||
isLoading: demoSettingLoading,
|
||||
isError: demoSettingError,
|
||||
} = useStrategyProjectSettings();
|
||||
const updateStrategyProjectSettings = useUpdateStrategyProjectSettings();
|
||||
const strategyAngleCatalog = useStrategyAngleCatalog();
|
||||
const createAngles = strategyAngleCatalog.angles;
|
||||
const demoEnabled =
|
||||
strategyProjectSettings?.taiwan_strait_demo_enabled === true;
|
||||
const demoAvailable = canCreateTaiwanDemo(createAngles);
|
||||
const {
|
||||
data: qaThreads = [],
|
||||
isLoading: qaLoading,
|
||||
@@ -496,15 +534,19 @@ export default function StrategyProjectsPage({
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// The global switch controls only whether users can create/load the preset.
|
||||
// Existing demo projects remain visible like normal saved work, so hiding an
|
||||
// entry never looks like data loss.
|
||||
const availableProjects = projects;
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return projects;
|
||||
return projects.filter(
|
||||
if (!keyword) return availableProjects;
|
||||
return availableProjects.filter(
|
||||
(project) =>
|
||||
project.title.toLowerCase().includes(keyword) ||
|
||||
project.brief.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [projects, query]);
|
||||
}, [availableProjects, query]);
|
||||
const filteredQaThreads = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return ordinaryThreads;
|
||||
@@ -518,15 +560,35 @@ export default function StrategyProjectsPage({
|
||||
|
||||
const create = async (kind: "blank" | "demo") => {
|
||||
if (creating) return;
|
||||
if (strategyAngleCatalog.isLoading) {
|
||||
toast.info("正在加载全局策略智能体配置,请稍候");
|
||||
return;
|
||||
}
|
||||
if (strategyAngleCatalog.isError) {
|
||||
toast.error("全局策略智能体配置加载失败,请刷新后重试");
|
||||
return;
|
||||
}
|
||||
if (kind === "demo" && !demoEnabled) {
|
||||
toast.info("台海示例当前未开放");
|
||||
return;
|
||||
}
|
||||
if (kind === "demo" && !demoAvailable) {
|
||||
toast.error("台海示例所需的六个默认角度未全部启用,请联系管理员恢复配置");
|
||||
return;
|
||||
}
|
||||
if (!createAngles.length) {
|
||||
toast.error("当前没有可用的策略角度智能体,请先联系管理员启用或新增角度");
|
||||
return;
|
||||
}
|
||||
setCreating(kind);
|
||||
try {
|
||||
const project = await createStrategyProject(
|
||||
kind === "demo"
|
||||
? createDemoStrategySnapshot()
|
||||
: createEmptyStrategySnapshot(),
|
||||
? createDemoStrategySnapshot(createAngles)
|
||||
: createEmptyStrategySnapshot(createAngles),
|
||||
);
|
||||
setCreateDialogOpen(false);
|
||||
navigate(`${basePath}/${encodeURIComponent(project.id)}`);
|
||||
navigate(strategyProjectWorkbenchPath(basePath, project.id));
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "项目创建失败");
|
||||
setCreating(null);
|
||||
@@ -553,7 +615,7 @@ export default function StrategyProjectsPage({
|
||||
}
|
||||
};
|
||||
|
||||
const solutionReadyProjects = projects.filter(isSolutionReadyProject);
|
||||
const solutionReadyProjects = availableProjects.filter(isSolutionReadyProject);
|
||||
const solutionProjects = filtered.filter(isSolutionReadyProject);
|
||||
const visibleStrategyProjects = category === "solution" ? solutionProjects : filtered;
|
||||
const isOfficial = basePath.startsWith("/official/");
|
||||
@@ -609,6 +671,29 @@ export default function StrategyProjectsPage({
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const openSolutionList = () => {
|
||||
setCreateDialogOpen(false);
|
||||
setQuery("");
|
||||
navigate(strategyProjectListPath(basePath, "solution"));
|
||||
};
|
||||
|
||||
const toggleDemoVisibility = (enabled: boolean) => {
|
||||
if (!canConfigure || updateStrategyProjectSettings.isPending) return;
|
||||
updateStrategyProjectSettings.mutate(
|
||||
{ taiwan_strait_demo_enabled: enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(enabled ? "台海示例已显示" : "台海示例已隐藏");
|
||||
},
|
||||
onError: (reason) => {
|
||||
toast.error(
|
||||
reason instanceof Error ? reason.message : "台海示例设置保存失败",
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="size-full min-h-0 overflow-y-auto bg-slate-50/70 text-foreground dark:bg-[#01152f]">
|
||||
<div className="mx-auto w-full max-w-[1480px] px-5 py-5 lg:px-8 lg:py-6">
|
||||
@@ -626,6 +711,30 @@ export default function StrategyProjectsPage({
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{canConfigure && (
|
||||
<div
|
||||
className="flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs shadow-xs dark:border-[#244d78] dark:bg-[#021a37]"
|
||||
title={
|
||||
demoSettingError
|
||||
? "设置读取失败,请刷新页面后重试"
|
||||
: "控制所有用户是否能看到台海示例入口"
|
||||
}
|
||||
>
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
台海示例
|
||||
</span>
|
||||
<Switch
|
||||
aria-label="显示台海示例"
|
||||
checked={demoEnabled}
|
||||
disabled={
|
||||
demoSettingLoading ||
|
||||
demoSettingError ||
|
||||
updateStrategyProjectSettings.isPending
|
||||
}
|
||||
onCheckedChange={toggleDemoVisibility}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canConfigure && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -656,7 +765,7 @@ export default function StrategyProjectsPage({
|
||||
id: "all" as const,
|
||||
label: "全部项目",
|
||||
description: "集中查看所有工作",
|
||||
count: ordinaryThreads.length + projects.length,
|
||||
count: ordinaryThreads.length + availableProjects.length,
|
||||
icon: FolderKanban,
|
||||
accent: "slate",
|
||||
},
|
||||
@@ -672,7 +781,7 @@ export default function StrategyProjectsPage({
|
||||
id: "strategy" as const,
|
||||
label: "策略优选",
|
||||
description: "意图、策略生成与评判",
|
||||
count: projects.length,
|
||||
count: availableProjects.length,
|
||||
icon: Sparkles,
|
||||
accent: "violet",
|
||||
},
|
||||
@@ -811,7 +920,9 @@ export default function StrategyProjectsPage({
|
||||
project={project}
|
||||
category="strategy"
|
||||
deleting={deletingId === project.id}
|
||||
onOpen={() => navigate(`${basePath}/${encodeURIComponent(project.id)}`)}
|
||||
onOpen={() =>
|
||||
navigate(strategyProjectWorkbenchPath(basePath, project.id))
|
||||
}
|
||||
onDelete={() => void remove(project)}
|
||||
/>
|
||||
))}
|
||||
@@ -884,7 +995,15 @@ export default function StrategyProjectsPage({
|
||||
project={project}
|
||||
category={category === "solution" ? "solution" : "strategy"}
|
||||
deleting={deletingId === project.id}
|
||||
onOpen={() => navigate(`${basePath}/${encodeURIComponent(project.id)}${category === "solution" ? "?module=solution" : ""}`)}
|
||||
onOpen={() =>
|
||||
navigate(
|
||||
strategyProjectWorkbenchPath(
|
||||
basePath,
|
||||
project.id,
|
||||
category === "solution" ? "solution" : undefined,
|
||||
),
|
||||
)
|
||||
}
|
||||
onDelete={() => void remove(project)}
|
||||
/>
|
||||
))}
|
||||
@@ -902,7 +1021,9 @@ export default function StrategyProjectsPage({
|
||||
? "换一个关键词试试,或清空搜索查看全部项目。"
|
||||
: category === "solution"
|
||||
? "方案生成需要已有策略结果。请先创建策略项目并完成策略生成与智能评判,再回来整理综合方案。"
|
||||
: "从空白任务开始梳理需求,或载入已经完成全流程的台海示例。"}
|
||||
: demoEnabled
|
||||
? "从空白任务开始梳理需求,或载入已经完成全流程的台海示例。"
|
||||
: "从空白任务开始梳理需求,再由多角度智能体生成并评判策略。"}
|
||||
</p>
|
||||
{!query && (
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
@@ -916,9 +1037,11 @@ export default function StrategyProjectsPage({
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void create("demo")} disabled={Boolean(creating)}>
|
||||
<WandSparkles /> 打开台海示例
|
||||
</Button>
|
||||
{demoEnabled && demoAvailable && (
|
||||
<Button variant="outline" onClick={() => void create("demo")} disabled={Boolean(creating)}>
|
||||
<WandSparkles /> 打开台海示例
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void create("blank")} disabled={Boolean(creating)}>
|
||||
<Plus /> 新建策略项目
|
||||
</Button>
|
||||
@@ -992,10 +1115,7 @@ export default function StrategyProjectsPage({
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(creating)}
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
switchCategory("solution");
|
||||
}}
|
||||
onClick={openSolutionList}
|
||||
className="group flex min-h-52 flex-col rounded-2xl border bg-background p-3 text-left transition-all hover:-translate-y-0.5 hover:border-emerald-400/50 hover:shadow-md disabled:pointer-events-none disabled:opacity-50 dark:border-[#244d78] dark:bg-[#031b38] sm:p-5"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
|
||||
@@ -1031,23 +1151,25 @@ export default function StrategyProjectsPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-[#173d68]">
|
||||
<div>
|
||||
<strong className="text-sm">快速体验完整流程</strong>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
台海示例包含预置任务、策略评判和综合方案,仅作为演示入口。
|
||||
</p>
|
||||
{demoEnabled && demoAvailable && (
|
||||
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-[#173d68]">
|
||||
<div>
|
||||
<strong className="text-sm">快速体验完整流程</strong>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
台海示例包含预置任务、策略评判和综合方案,仅作为演示入口。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant="outline"
|
||||
disabled={Boolean(creating)}
|
||||
onClick={() => void create("demo")}
|
||||
>
|
||||
{creating === "demo" ? <LoaderCircle className="animate-spin" /> : <WandSparkles />}
|
||||
新建台海示例
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant="outline"
|
||||
disabled={Boolean(creating)}
|
||||
onClick={() => void create("demo")}
|
||||
>
|
||||
{creating === "demo" ? <LoaderCircle className="animate-spin" /> : <WandSparkles />}
|
||||
新建台海示例
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{canConfigure && (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,25 @@
|
||||
/**
|
||||
* Run from frontend-web:
|
||||
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.tmp-tests/strategy-selection.test.mjs
|
||||
* node --test .tmp-tests/strategy-selection.test.mjs
|
||||
* 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,
|
||||
createFallbackEvaluations,
|
||||
buildSynthesisBrief,
|
||||
extractFencedJson,
|
||||
normalizeIntentClarification,
|
||||
parseEvaluationsFromText,
|
||||
parseSseChunk,
|
||||
parseTaskDraftFromText,
|
||||
stripStructuredBlocks,
|
||||
} from "./lib";
|
||||
import { EMPTY_TASK_DRAFT } from "./constants";
|
||||
import type { AgentOutputState } from "./types";
|
||||
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(
|
||||
@@ -30,6 +33,73 @@ test("parseSseChunk preserves incomplete frames and parses subsequent frames", (
|
||||
]);
|
||||
});
|
||||
|
||||
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```'),
|
||||
@@ -72,7 +142,35 @@ test("task parser accepts backend SOUL field aliases", () => {
|
||||
assert.deepEqual(task?.selectedAngleIds, ["public_opinion", "law_policy"]);
|
||||
});
|
||||
|
||||
test("evaluation parser normalizes scores and fills missing candidates", () => {
|
||||
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",
|
||||
@@ -91,20 +189,141 @@ test("evaluation parser normalizes scores and fills missing candidates", () => {
|
||||
evaluations: [
|
||||
{
|
||||
agentId: "strategy-selection-angle-governance",
|
||||
score: 101,
|
||||
score: 100,
|
||||
conclusion: "优先推荐",
|
||||
comment: "清晰",
|
||||
},
|
||||
],
|
||||
})}\n\`\`\``;
|
||||
const evaluations = parseEvaluationsFromText(text, outputs);
|
||||
assert.equal(evaluations.length, 2);
|
||||
assert.equal(evaluations.length, 1);
|
||||
assert.equal(evaluations[0].score, 100);
|
||||
assert.ok(
|
||||
assert.equal(
|
||||
evaluations.some(
|
||||
(item) => item.agentId === "strategy-selection-angle-law-policy",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(createFallbackEvaluations(outputs).length, 2);
|
||||
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*\]/);
|
||||
});
|
||||
@@ -1,22 +1,106 @@
|
||||
import { STRATEGY_ANGLES } from "./constants";
|
||||
import type {
|
||||
AgentOutputState,
|
||||
IntentClarification,
|
||||
IntentTurn,
|
||||
ParallelAgentEvent,
|
||||
StrategyAngle,
|
||||
StrategyEvaluation,
|
||||
StrategyTaskDraft,
|
||||
} from "./types";
|
||||
|
||||
const clampScore = (value: unknown): number => {
|
||||
const score = Number(value);
|
||||
if (!Number.isFinite(score)) return 80;
|
||||
return Math.min(100, Math.max(0, Math.round(score)));
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -62,16 +146,29 @@ export function stripStructuredBlocks(text: string): string {
|
||||
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") return null;
|
||||
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;
|
||||
const angleIds = Array.isArray(rawAngles)
|
||||
? rawAngles
|
||||
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 STRATEGY_ANGLES.find(
|
||||
return angles.find(
|
||||
(angle) =>
|
||||
angle.id === normalized ||
|
||||
angle.agentId === value ||
|
||||
@@ -79,12 +176,14 @@ export function parseTaskDraftFromText(
|
||||
angle.shortName === value,
|
||||
)?.id;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
: fallback.selectedAngleIds;
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
if (!angleIds.length) return null;
|
||||
|
||||
return {
|
||||
name: stringValue(parsed.name ?? parsed.task_name) || fallback.name,
|
||||
detail: stringValue(parsed.detail ?? parsed.task_detail) || fallback.detail,
|
||||
name: stringValue(rawName),
|
||||
detail: stringValue(rawDetail),
|
||||
objective: stringValue(parsed.objective) || fallback.objective,
|
||||
prerequisites:
|
||||
stringValue(parsed.prerequisites) || fallback.prerequisites,
|
||||
@@ -94,87 +193,115 @@ export function parseTaskDraftFromText(
|
||||
acceptanceCriteria:
|
||||
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
|
||||
horizon: stringValue(parsed.horizon) || fallback.horizon,
|
||||
selectedAngleIds: angleIds.length ? angleIds : fallback.selectedAngleIds,
|
||||
selectedAngleIds: angleIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFallbackEvaluations(
|
||||
outputs: Record<string, AgentOutputState>,
|
||||
): StrategyEvaluation[] {
|
||||
return STRATEGY_ANGLES.filter(
|
||||
(angle) =>
|
||||
outputs[angle.agentId]?.status === "done" &&
|
||||
outputs[angle.agentId]?.content.trim(),
|
||||
).map((angle, index) => ({
|
||||
agentId: angle.agentId,
|
||||
score: Math.max(76, 88 - index),
|
||||
conclusion: index < 2 ? "优先推荐" : "建议采用",
|
||||
comment: `${angle.name}策略已形成完整交付,建议结合其他入选策略做冲突消解与阶段排序。`,
|
||||
strengths: ["角度明确", "具备行动结构"],
|
||||
risks: ["关键阈值仍需结合最新信息复核"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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 createFallbackEvaluations(outputs);
|
||||
if (!Array.isArray(rows)) return [];
|
||||
|
||||
const knownIds = new Set(STRATEGY_ANGLES.map((angle) => angle.agentId));
|
||||
const evaluations = rows
|
||||
.map((row): StrategyEvaluation | null => {
|
||||
const agentId = stringValue(row.agentId ?? row.agent_id);
|
||||
if (
|
||||
!knownIds.has(agentId) ||
|
||||
outputs[agentId]?.status !== "done" ||
|
||||
!outputs[agentId]?.content.trim()
|
||||
) return null;
|
||||
const rawConclusion = stringValue(row.conclusion);
|
||||
const conclusion: StrategyEvaluation["conclusion"] =
|
||||
rawConclusion === "优先推荐" ||
|
||||
rawConclusion === "建议采用" ||
|
||||
rawConclusion === "保留备选"
|
||||
? rawConclusion
|
||||
: clampScore(row.score) >= 88
|
||||
? "优先推荐"
|
||||
: clampScore(row.score) >= 78
|
||||
? "建议采用"
|
||||
: "保留备选";
|
||||
return {
|
||||
agentId,
|
||||
score: clampScore(row.score),
|
||||
conclusion,
|
||||
comment: stringValue(row.comment) || "请结合完整评判正文审阅。",
|
||||
strengths: stringList(row.strengths),
|
||||
risks: stringList(row.risks),
|
||||
};
|
||||
})
|
||||
.filter((item): item is StrategyEvaluation => item !== null);
|
||||
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;
|
||||
|
||||
if (!evaluations.length) return createFallbackEvaluations(outputs);
|
||||
|
||||
const byAgent = new Map(evaluations.map((item) => [item.agentId, item]));
|
||||
for (const fallback of createFallbackEvaluations(outputs)) {
|
||||
if (!byAgent.has(fallback.agentId)) byAgent.set(fallback.agentId, fallback);
|
||||
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),
|
||||
});
|
||||
}
|
||||
return [...byAgent.values()].sort((a, b) => b.score - a.score);
|
||||
|
||||
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())
|
||||
.map((turn) => `${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`)
|
||||
.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");
|
||||
return `你负责意图理解与任务定义。基于以下对话,先用简洁自然语言回应用户,再整理一份完整任务草稿。信息不全时做保守假设并明确标注,不要虚构实时事实。\n\n当前草稿(可据新信息修订):\n${JSON.stringify(currentDraft, null, 2)}\n\n对话:\n${conversation}\n\n回复末尾必须追加一个可机器读取的代码块,字段名不得改变:\n\`\`\`strategy-task-json\n{"name":"任务名称","detail":"任务详情","objective":"目标","prerequisites":"前置条件","constraints":"约束与边界","deliverables":"交付物","acceptanceCriteria":"验收标准","horizon":"时间范围","selectedAngleIds":["governance","diplomacy","economy","security","public_opinion","law_policy"]}\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(
|
||||
@@ -186,15 +313,18 @@ export function buildStrategyBrief(
|
||||
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项策略动作、实施阶段、触发/退出条件、指标、主要风险与协同依赖。避免空泛口号;涉及动态事实时说明需复核。对于地缘政治议题,坚持中立、降级避险、民生优先,不输出军事战术、目标选择、武器运用或规避监管建议。`;
|
||||
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 strategies = STRATEGY_ANGLES.filter(
|
||||
const selectedAgentIds = selectedStrategyAgentIds(task, angles);
|
||||
const strategies = angles.filter(
|
||||
(angle) =>
|
||||
selectedAgentIds.has(angle.agentId) &&
|
||||
outputs[angle.agentId]?.status === "done" &&
|
||||
outputs[angle.agentId]?.content.trim(),
|
||||
)
|
||||
@@ -203,7 +333,7 @@ export function buildJudgeBrief(
|
||||
`## ${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整数。`;
|
||||
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(
|
||||
@@ -211,16 +341,28 @@ export function buildSynthesisBrief(
|
||||
selectedAgentIds: string[],
|
||||
outputs: Record<string, AgentOutputState>,
|
||||
evaluations: StrategyEvaluation[],
|
||||
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): string {
|
||||
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
|
||||
const selections = selectedAgentIds
|
||||
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 = STRATEGY_ANGLES.find((item) => item.agentId === 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");
|
||||
return `你是方案整理智能体。请将用户选中的策略消除重复、处理冲突、明确优先级和依赖关系,整理为一份可直接下载的完整 Markdown 方案。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n入选策略:\n${selections}\n\n只输出最终 Markdown 正文。至少包含:执行摘要、目标与原则、综合策略、阶段路线图、责任与协同、指标与预警、风险及止损机制、待核验信息。不要输出代码围栏。对于地缘政治议题坚持中立、降级避险、民生优先,禁止军事战术和伤害性操作建议。`;
|
||||
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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getAccountIdentityKey } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
@@ -15,14 +16,51 @@ import {
|
||||
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 = `### 评判结论
|
||||
|
||||
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
|
||||
@@ -31,6 +69,8 @@ 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[];
|
||||
@@ -41,6 +81,7 @@ export interface StrategyProjectSnapshot {
|
||||
selectedStrategyIds: string[];
|
||||
synthesis: string;
|
||||
synthesisStatus: AgentOutputState["status"];
|
||||
solutionSavedAt: string | null;
|
||||
isDemo: boolean;
|
||||
workflowStale: boolean;
|
||||
}
|
||||
@@ -65,6 +106,13 @@ 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;
|
||||
@@ -90,6 +138,9 @@ const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
|
||||
selectedAngleIds: [...task.selectedAngleIds],
|
||||
});
|
||||
|
||||
const cloneAngles = (angles: StrategyAngle[]): StrategyAngle[] =>
|
||||
angles.map((angle) => ({ ...angle }));
|
||||
|
||||
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
|
||||
turns.map((turn) => ({ ...turn, streaming: false }));
|
||||
|
||||
@@ -110,11 +161,18 @@ const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
|
||||
risks: [...item.risks],
|
||||
}));
|
||||
|
||||
export function createEmptyStrategySnapshot(): StrategyProjectSnapshot {
|
||||
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),
|
||||
taskDraft: {
|
||||
...cloneTask(EMPTY_TASK_DRAFT),
|
||||
selectedAngleIds: enabledAngles.slice(0, 4).map((angle) => angle.id),
|
||||
},
|
||||
turns: cloneTurns(INITIAL_INTENT_TURNS),
|
||||
outputs: {},
|
||||
judgeStatus: "idle",
|
||||
@@ -123,26 +181,71 @@ export function createEmptyStrategySnapshot(): StrategyProjectSnapshot {
|
||||
selectedStrategyIds: [],
|
||||
synthesis: "",
|
||||
synthesisStatus: "idle",
|
||||
solutionSavedAt: null,
|
||||
isDemo: false,
|
||||
workflowStale: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDemoStrategySnapshot(): StrategyProjectSnapshot {
|
||||
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),
|
||||
taskDraft: {
|
||||
...cloneTask(TAIWAN_STRAIT_DEMO_TASK),
|
||||
selectedAngleIds: effectiveAngles.map((angle) => angle.id),
|
||||
},
|
||||
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
|
||||
outputs: cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS),
|
||||
outputs: demoOutputs,
|
||||
judgeStatus: "done",
|
||||
judgeContent: DEMO_JUDGE_CONTENT,
|
||||
evaluations: cloneEvaluations(TAIWAN_STRAIT_DEMO_EVALUATIONS),
|
||||
selectedStrategyIds: TAIWAN_STRAIT_DEMO_EVALUATIONS.filter(
|
||||
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,
|
||||
};
|
||||
@@ -172,7 +275,22 @@ function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | nul
|
||||
}
|
||||
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),
|
||||
@@ -260,7 +378,7 @@ export async function getStrategyProject(id: string): Promise<StrategyProject> {
|
||||
|
||||
function projectAgents(snapshot: StrategyProjectSnapshot) {
|
||||
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
|
||||
const angleAgents = STRATEGY_ANGLES.filter((angle) => selected.has(angle.id)).map(
|
||||
const angleAgents = snapshot.angleCatalog.filter((angle) => selected.has(angle.id)).map(
|
||||
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
|
||||
);
|
||||
return [
|
||||
@@ -304,26 +422,37 @@ export async function saveStrategyProject(
|
||||
id: string,
|
||||
snapshot: StrategyProjectSnapshot,
|
||||
options: {
|
||||
expectedUpdatedAt?: string | null;
|
||||
keepalive?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<StrategyProject> {
|
||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: options.keepalive,
|
||||
signal: options.signal,
|
||||
body: JSON.stringify({
|
||||
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||||
brief: snapshot.taskDraft.detail.trim() || null,
|
||||
agents: projectAgents(snapshot),
|
||||
messages: [snapshotEnvelope(snapshot)],
|
||||
}),
|
||||
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;
|
||||
});
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||||
if (!project) throw new Error("项目已保存,但返回数据无法识别");
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function deleteStrategyProject(id: string): Promise<void> {
|
||||
@@ -333,4 +462,8 @@ export async function deleteStrategyProject(id: string): Promise<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface StrategyAngle {
|
||||
shortName: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
/** False when an angle survives only in an existing project snapshot. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyTaskDraft {
|
||||
@@ -34,13 +36,38 @@ export interface IntentTurn {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -77,5 +104,25 @@ export type ParallelAgentEvent =
|
||||
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" };
|
||||
Reference in new issue
Block a user