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 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> {
|
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||||
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
loadPublishSquares,
|
loadPublishSquares,
|
||||||
loadSkillCompressionSettings,
|
loadSkillCompressionSettings,
|
||||||
|
loadStrategyProjectSettings,
|
||||||
updateAgentModuleConfig,
|
updateAgentModuleConfig,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
updateCitationDisplaySettings,
|
updateCitationDisplaySettings,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
updatePublishSquares,
|
updatePublishSquares,
|
||||||
updateSkillCompressionSettings,
|
updateSkillCompressionSettings,
|
||||||
|
updateStrategyProjectSettings,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
type CitationDisplayUpdate,
|
type CitationDisplayUpdate,
|
||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
type PublishSquaresUpdate,
|
type PublishSquaresUpdate,
|
||||||
type SkillCompressionUpdate,
|
type SkillCompressionUpdate,
|
||||||
|
type StrategyProjectSettingsUpdate,
|
||||||
type LayoutMode,
|
type LayoutMode,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -45,6 +48,28 @@ const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
|||||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] 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() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe
|
|||||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
: "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 = {
|
type HeaderProps = {
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
};
|
};
|
||||||
@@ -122,8 +118,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
|
|
||||||
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
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 handleNavItemClick = (item: MenuItem) => {
|
||||||
const path = item.path ?? findFirstRoutePath(item.children);
|
const path = item.path ?? findFirstRoutePath(item.children);
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||||
@@ -15,13 +28,21 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface AgentConfigDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -29,13 +50,43 @@ interface AgentConfigDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AgentConfigDraft {
|
interface AgentConfigDraft {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
soul: string;
|
soul: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
skills: string[];
|
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[]) {
|
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[]) {
|
function sameSkills(left: string[], right: string[]) {
|
||||||
@@ -47,27 +98,84 @@ function sameSkills(left: string[], right: string[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps) {
|
function draftFromAgent(agent: StrategyAgent): AgentConfigDraft {
|
||||||
const [selectedId, setSelectedId] = useState<string>(STRATEGY_AGENT_ROLES[0].id);
|
return {
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
name: agent.name,
|
||||||
const [soul, setSoul] = useState("");
|
description: agent.description,
|
||||||
const [model, setModel] = useState<string | null>(null);
|
soul: agent.soul ?? "",
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
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 [savedDraft, setSavedDraft] = useState<AgentConfigDraft | null>(null);
|
||||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState("");
|
const {
|
||||||
|
data: agents = [],
|
||||||
const selectedRole = useMemo(
|
isLoading,
|
||||||
() => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!,
|
error,
|
||||||
[selectedId],
|
refetch,
|
||||||
);
|
} = useStrategyAgents({
|
||||||
|
enabled: open,
|
||||||
|
includeDisabled: true,
|
||||||
|
includeConfig: true,
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
skills,
|
skills,
|
||||||
isLoading: isSkillsLoading,
|
isLoading: isSkillsLoading,
|
||||||
error: skillsError,
|
error: skillsError,
|
||||||
} = useAllSkillsAdmin(open);
|
} = 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(
|
const selectableSkills = useMemo(
|
||||||
() =>
|
() =>
|
||||||
skills.filter((skill) => {
|
skills.filter((skill) => {
|
||||||
@@ -81,223 +189,539 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps
|
|||||||
}),
|
}),
|
||||||
[skills],
|
[skills],
|
||||||
);
|
);
|
||||||
const dirty = Boolean(
|
|
||||||
agent &&
|
|
||||||
savedDraft &&
|
|
||||||
(soul !== savedDraft.soul ||
|
|
||||||
model !== savedDraft.model ||
|
|
||||||
!sameSkills(selectedSkills, savedDraft.skills)),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedId) return;
|
if (!open || isLoading || creating || agents.length === 0) return;
|
||||||
let active = true;
|
if (selectedAgent) return;
|
||||||
setLoading(true);
|
setSelectedId(agents[0].id);
|
||||||
setError("");
|
}, [agents, creating, isLoading, open, selectedAgent]);
|
||||||
setAgent(null);
|
|
||||||
|
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);
|
setSavedDraft(null);
|
||||||
void getAgent(selectedId)
|
};
|
||||||
.then((data) => {
|
|
||||||
if (!active) return;
|
const startCreate = () => {
|
||||||
setAgent(data);
|
if (busy || !confirmDiscardChanges()) return;
|
||||||
const nextDraft = {
|
const maxOrder = angleAgents.reduce(
|
||||||
soul: data.soul ?? "",
|
(current, agent) => Math.max(current, agent.sort_order),
|
||||||
model: data.model ?? null,
|
99,
|
||||||
skills: data.skills ?? [],
|
);
|
||||||
};
|
const next = emptyAngleDraft(maxOrder + 1);
|
||||||
setSoul(nextDraft.soul);
|
setSelectedId(NEW_ANGLE_ID);
|
||||||
setModel(nextDraft.model);
|
setDraft(next);
|
||||||
setSelectedSkills(nextDraft.skills);
|
setSavedDraft(next);
|
||||||
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 save = async () => {
|
const save = async () => {
|
||||||
if (!agent) return;
|
const name = draft.name.trim();
|
||||||
setSaving(true);
|
if (!name) {
|
||||||
|
toast.error("请填写智能体名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const updated = await updateAgent(agent.id, {
|
if (creating) {
|
||||||
soul: soul.trim(),
|
const created = await createStrategyAngle({
|
||||||
model,
|
name,
|
||||||
skills: selectedSkills,
|
description: draft.description.trim(),
|
||||||
});
|
soul: draft.soul,
|
||||||
setAgent(updated);
|
model: draft.model,
|
||||||
setSoul(updated.soul ?? "");
|
skills: draft.skills,
|
||||||
setModel(updated.model ?? null);
|
sort_order: draft.sortOrder,
|
||||||
setSelectedSkills(updated.skills ?? []);
|
});
|
||||||
setSavedDraft({
|
setSelectedId(created.id);
|
||||||
soul: updated.soul ?? "",
|
const next = draftFromAgent(created);
|
||||||
model: updated.model ?? null,
|
setDraft(next);
|
||||||
skills: updated.skills ?? [],
|
setSavedDraft(next);
|
||||||
});
|
await queryClient.invalidateQueries({
|
||||||
toast.success(`${updated.name} 配置已保存`);
|
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) {
|
} catch (reason) {
|
||||||
toast.error(reason instanceof Error ? reason.message : "保存失败");
|
toast.error(reason instanceof Error ? reason.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDiscardChanges = () =>
|
const softDelete = async () => {
|
||||||
!dirty || window.confirm("当前智能体配置尚未保存,确定放弃这些修改吗?");
|
if (!selectedAgent || selectedAgent.fixed || busy) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
const selectRole = (roleId: string) => {
|
`确定停用“${selectedAgent.name}”吗?\n\n它不会再出现在新项目的角度选择中,但历史项目、SOUL 和技能配置都会保留,可随时恢复。`,
|
||||||
if (roleId === selectedId || saving || !confirmDiscardChanges()) return;
|
);
|
||||||
setSelectedId(roleId);
|
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) => {
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
if (!nextOpen && (saving || !confirmDiscardChanges())) return;
|
if (!nextOpen && (busy || !confirmDiscardChanges())) return;
|
||||||
onOpenChange(nextOpen);
|
onOpenChange(nextOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const category = creating ? "angle" : selectedAgent?.category;
|
||||||
|
const categoryCopy = category ? CATEGORY_COPY[category] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<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]">
|
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Bot className="size-5 text-primary" />
|
<Bot className="size-5 text-primary" />
|
||||||
策略智能体配置
|
统一策略智能体
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
这是全局统一配置,不属于某一个项目。管理员保存后,所有策略项目下一次运行都会使用这里的模型、SOUL 和技能。
|
配置对所有项目全局生效。流程角色保持唯一;分析角度可扩展、排序和停用,历史项目仍保留原引用。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid min-h-0 grid-cols-[290px_minmax(0,1fr)]">
|
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<div className="min-h-0 overflow-y-auto border-r bg-muted/20 p-3 dark:border-[#173d68] dark:bg-[#031b38]">
|
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||||
<div className="mb-2 px-2 text-xs font-semibold tracking-wide text-muted-foreground">
|
<div className="mb-3 flex items-center justify-between px-1">
|
||||||
流程角色 · {STRATEGY_AGENT_ROLES.length}
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{STRATEGY_AGENT_ROLES.map((role) => {
|
{fixedAgents.map((agent) => {
|
||||||
const selected = role.id === selectedId;
|
const selected = agent.id === selectedId;
|
||||||
|
const copy = CATEGORY_COPY[agent.category];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={role.id}
|
key={agent.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving}
|
disabled={busy}
|
||||||
onClick={() => selectRole(role.id)}
|
onClick={() => selectAgent(agent.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
selected
|
selected
|
||||||
? "border-primary/45 bg-primary/10 shadow-sm"
|
? "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="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-medium">{role.name}</span>
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
{selected && <Check className="size-4 text-primary" />}
|
<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>
|
||||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
<span className="mt-1.5 block truncate pl-5.5 text-xs text-muted-foreground">
|
||||||
{role.role}
|
{agent.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
<div className="mb-3 mt-6 flex items-center justify-between px-1">
|
||||||
<div className="mb-5 flex items-start justify-between gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{selectedRole.name}</h3>
|
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{selectedRole.role}</p>
|
分析角度库
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||||
|
新项目只使用当前启用的角度
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="font-mono text-[11px]">
|
<Button
|
||||||
{selectedRole.id}
|
type="button"
|
||||||
</Badge>
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={startCreate}
|
||||||
|
>
|
||||||
|
<CirclePlus className="size-3.5" />
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
</div>
|
</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 ? (
|
<main className="min-h-0 overflow-y-auto px-6 py-5">
|
||||||
<div className="flex h-56 items-center justify-center gap-2 text-sm text-muted-foreground">
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
正在读取智能体配置…
|
正在读取全局配置…
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
|
||||||
<p className="font-medium">暂时无法加载该智能体</p>
|
<p className="font-medium">策略智能体配置加载失败</p>
|
||||||
<p className="mt-1 leading-6 opacity-85">{error}</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>
|
</div>
|
||||||
) : agent ? (
|
) : categoryCopy ? (
|
||||||
<div className="space-y-5">
|
<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>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground">当前名称</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="mt-1 font-medium">{agent.name}</div>
|
<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>
|
</div>
|
||||||
<label className="block">
|
{!creating && selectedAgent ? (
|
||||||
<span className="text-xs text-muted-foreground">运行模型</span>
|
<Badge
|
||||||
<ModelNameSelect
|
variant="outline"
|
||||||
value={model}
|
className="max-w-[300px] truncate font-mono text-[10px]"
|
||||||
onChange={setModel}
|
title={selectedAgent.id}
|
||||||
disabled={saving}
|
>
|
||||||
defaultLabel="使用系统默认模型"
|
{selectedAgent.id}
|
||||||
title="选择该全局智能体运行时使用的大模型;未指定时使用系统默认模型"
|
</Badge>
|
||||||
triggerClassName="mt-1 w-full bg-background"
|
) : null}
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</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">
|
<label className="block">
|
||||||
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
|
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||||
<Sparkles className="size-4 text-primary" /> SOUL
|
<Sparkles className="size-4 text-primary" /> SOUL
|
||||||
</span>
|
</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={soul}
|
value={draft.soul}
|
||||||
disabled={saving}
|
disabled={busy}
|
||||||
onChange={(event) => setSoul(event.target.value)}
|
maxLength={200000}
|
||||||
className="min-h-[220px] resize-y font-mono text-xs leading-6"
|
onChange={(event) =>
|
||||||
placeholder="定义智能体的角色、分析方法、边界和输出要求…"
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
soul: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="min-h-[210px] resize-y font-mono text-xs leading-6"
|
||||||
|
placeholder="定义角色身份、分析方法、行为边界和输出格式…"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm font-medium">技能库选择</span>
|
<span className="text-sm font-medium">系统技能选择</span>
|
||||||
<Badge variant="secondary">已选 {selectedSkills.length} 个</Badge>
|
<Badge variant="secondary">
|
||||||
|
已选 {draft.skills.length} 个
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
从系统技能库勾选该智能体可使用的技能;这里保存的是全局白名单,不接受手工输入技能名。
|
只展示当前启用且全局可见的系统技能;通过穿梭框选择,不接受手工输入技能名。
|
||||||
</p>
|
</p>
|
||||||
<SkillTransferSelector
|
<SkillTransferSelector
|
||||||
skills={selectableSkills}
|
skills={selectableSkills}
|
||||||
selected={selectedSkills}
|
selected={draft.skills}
|
||||||
onChange={setSelectedSkills}
|
onChange={(selectedSkills) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
skills: selectedSkills,
|
||||||
|
}))
|
||||||
|
}
|
||||||
isLoading={isSkillsLoading}
|
isLoading={isSkillsLoading}
|
||||||
onViewDetail={setDetailSkillName}
|
onViewDetail={setDetailSkillName}
|
||||||
/>
|
/>
|
||||||
{skillsError ? (
|
{skillsError ? (
|
||||||
<p className="text-xs text-destructive">技能库加载失败,请稍后重试。</p>
|
<p className="text-xs text-destructive">
|
||||||
|
技能库加载失败,请稍后重试。
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
暂无可配置角色
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="border-t px-6 py-4 dark:border-[#173d68]">
|
<DialogFooter className="flex-row items-center justify-between border-t px-6 py-4 dark:border-[#173d68] sm:justify-between">
|
||||||
<Button variant="outline" disabled={saving} onClick={() => handleOpenChange(false)}>
|
<div>
|
||||||
关闭
|
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||||
</Button>
|
selectedAgent.enabled ? (
|
||||||
<Button disabled={!agent || loading || saving || isSkillsLoading} onClick={() => void save()}>
|
<Button
|
||||||
{saving ? <LoaderCircle className="animate-spin" /> : <Save />}
|
variant="ghost"
|
||||||
保存当前智能体
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
</Button>
|
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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<SkillDetailDialog
|
<SkillDetailDialog
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { apiFetch, getAPIClient } from "@/core/api";
|
import { apiFetch, getAPIClient } from "@/core/api";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import {
|
||||||
|
useStrategyProjectSettings,
|
||||||
|
useUpdateStrategyProjectSettings,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
import { useThreads } from "@/core/threads/hooks";
|
import { useThreads } from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import {
|
import {
|
||||||
@@ -49,9 +54,13 @@ import {
|
|||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { STRATEGY_ANGLES } from "./constants";
|
|
||||||
import { AgentConfigDialog } from "./AgentConfigDialog";
|
import { AgentConfigDialog } from "./AgentConfigDialog";
|
||||||
import {
|
import {
|
||||||
|
strategyProjectListPath,
|
||||||
|
strategyProjectWorkbenchPath,
|
||||||
|
} from "./project-routes";
|
||||||
|
import {
|
||||||
|
canCreateTaiwanDemo,
|
||||||
createDemoStrategySnapshot,
|
createDemoStrategySnapshot,
|
||||||
createEmptyStrategySnapshot,
|
createEmptyStrategySnapshot,
|
||||||
createStrategyProject,
|
createStrategyProject,
|
||||||
@@ -60,6 +69,7 @@ import {
|
|||||||
type StrategyProjectSnapshot,
|
type StrategyProjectSnapshot,
|
||||||
type StrategyProjectSummary,
|
type StrategyProjectSummary,
|
||||||
} from "./projects";
|
} from "./projects";
|
||||||
|
import { useStrategyAngleCatalog } from "./strategy-agents";
|
||||||
|
|
||||||
type ProjectCategory = "all" | "qa" | "strategy" | "solution";
|
type ProjectCategory = "all" | "qa" | "strategy" | "solution";
|
||||||
type StrategyProjectListItem = StrategyProjectSummary & {
|
type StrategyProjectListItem = StrategyProjectSummary & {
|
||||||
@@ -324,7 +334,14 @@ function ProjectCard({
|
|||||||
const progress = projectProgress(project);
|
const progress = projectProgress(project);
|
||||||
const isSolution = category === "solution";
|
const isSolution = category === "solution";
|
||||||
return (
|
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={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 min-h-0 flex-1 flex-col p-5 text-left">
|
||||||
<div className="flex w-full items-start justify-between gap-3">
|
<div className="flex w-full items-start justify-between gap-3">
|
||||||
@@ -386,13 +403,23 @@ function ProjectCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={onDelete}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
aria-label={`删除${project.title}`}
|
aria-label={`删除${project.title}`}
|
||||||
>
|
>
|
||||||
{deleting ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
|
{deleting ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={onOpen}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isSolution
|
{isSolution
|
||||||
? project.hasSolution
|
? project.hasSolution
|
||||||
? "打开方案"
|
? "打开方案"
|
||||||
@@ -409,7 +436,7 @@ function isSolutionReadyProject(project: StrategyProjectListItem): boolean {
|
|||||||
const snapshot = project.snapshot;
|
const snapshot = project.snapshot;
|
||||||
if (!snapshot || snapshot.workflowStale) return false;
|
if (!snapshot || snapshot.workflowStale) return false;
|
||||||
|
|
||||||
const selectedAngles = STRATEGY_ANGLES.filter((angle) =>
|
const selectedAngles = snapshot.angleCatalog.filter((angle) =>
|
||||||
snapshot.taskDraft.selectedAngleIds.includes(angle.id),
|
snapshot.taskDraft.selectedAngleIds.includes(angle.id),
|
||||||
);
|
);
|
||||||
const strategiesSettled = Boolean(
|
const strategiesSettled = Boolean(
|
||||||
@@ -458,6 +485,17 @@ export default function StrategyProjectsPage({
|
|||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const storedAuth = getStoredAuth();
|
const storedAuth = getStoredAuth();
|
||||||
const canConfigure = storedAuth?.system_role === "admin";
|
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 {
|
const {
|
||||||
data: qaThreads = [],
|
data: qaThreads = [],
|
||||||
isLoading: qaLoading,
|
isLoading: qaLoading,
|
||||||
@@ -496,15 +534,19 @@ export default function StrategyProjectsPage({
|
|||||||
void load();
|
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 filtered = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
if (!keyword) return projects;
|
if (!keyword) return availableProjects;
|
||||||
return projects.filter(
|
return availableProjects.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.title.toLowerCase().includes(keyword) ||
|
project.title.toLowerCase().includes(keyword) ||
|
||||||
project.brief.toLowerCase().includes(keyword),
|
project.brief.toLowerCase().includes(keyword),
|
||||||
);
|
);
|
||||||
}, [projects, query]);
|
}, [availableProjects, query]);
|
||||||
const filteredQaThreads = useMemo(() => {
|
const filteredQaThreads = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
if (!keyword) return ordinaryThreads;
|
if (!keyword) return ordinaryThreads;
|
||||||
@@ -518,15 +560,35 @@ export default function StrategyProjectsPage({
|
|||||||
|
|
||||||
const create = async (kind: "blank" | "demo") => {
|
const create = async (kind: "blank" | "demo") => {
|
||||||
if (creating) return;
|
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);
|
setCreating(kind);
|
||||||
try {
|
try {
|
||||||
const project = await createStrategyProject(
|
const project = await createStrategyProject(
|
||||||
kind === "demo"
|
kind === "demo"
|
||||||
? createDemoStrategySnapshot()
|
? createDemoStrategySnapshot(createAngles)
|
||||||
: createEmptyStrategySnapshot(),
|
: createEmptyStrategySnapshot(createAngles),
|
||||||
);
|
);
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
navigate(`${basePath}/${encodeURIComponent(project.id)}`);
|
navigate(strategyProjectWorkbenchPath(basePath, project.id));
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
toast.error(reason instanceof Error ? reason.message : "项目创建失败");
|
toast.error(reason instanceof Error ? reason.message : "项目创建失败");
|
||||||
setCreating(null);
|
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 solutionProjects = filtered.filter(isSolutionReadyProject);
|
||||||
const visibleStrategyProjects = category === "solution" ? solutionProjects : filtered;
|
const visibleStrategyProjects = category === "solution" ? solutionProjects : filtered;
|
||||||
const isOfficial = basePath.startsWith("/official/");
|
const isOfficial = basePath.startsWith("/official/");
|
||||||
@@ -609,6 +671,29 @@ export default function StrategyProjectsPage({
|
|||||||
setQuery("");
|
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 (
|
return (
|
||||||
<div className="size-full min-h-0 overflow-y-auto bg-slate-50/70 text-foreground dark:bg-[#01152f]">
|
<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">
|
<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>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<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 && (
|
{canConfigure && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -656,7 +765,7 @@ export default function StrategyProjectsPage({
|
|||||||
id: "all" as const,
|
id: "all" as const,
|
||||||
label: "全部项目",
|
label: "全部项目",
|
||||||
description: "集中查看所有工作",
|
description: "集中查看所有工作",
|
||||||
count: ordinaryThreads.length + projects.length,
|
count: ordinaryThreads.length + availableProjects.length,
|
||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
accent: "slate",
|
accent: "slate",
|
||||||
},
|
},
|
||||||
@@ -672,7 +781,7 @@ export default function StrategyProjectsPage({
|
|||||||
id: "strategy" as const,
|
id: "strategy" as const,
|
||||||
label: "策略优选",
|
label: "策略优选",
|
||||||
description: "意图、策略生成与评判",
|
description: "意图、策略生成与评判",
|
||||||
count: projects.length,
|
count: availableProjects.length,
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
accent: "violet",
|
accent: "violet",
|
||||||
},
|
},
|
||||||
@@ -811,7 +920,9 @@ export default function StrategyProjectsPage({
|
|||||||
project={project}
|
project={project}
|
||||||
category="strategy"
|
category="strategy"
|
||||||
deleting={deletingId === project.id}
|
deleting={deletingId === project.id}
|
||||||
onOpen={() => navigate(`${basePath}/${encodeURIComponent(project.id)}`)}
|
onOpen={() =>
|
||||||
|
navigate(strategyProjectWorkbenchPath(basePath, project.id))
|
||||||
|
}
|
||||||
onDelete={() => void remove(project)}
|
onDelete={() => void remove(project)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -884,7 +995,15 @@ export default function StrategyProjectsPage({
|
|||||||
project={project}
|
project={project}
|
||||||
category={category === "solution" ? "solution" : "strategy"}
|
category={category === "solution" ? "solution" : "strategy"}
|
||||||
deleting={deletingId === project.id}
|
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)}
|
onDelete={() => void remove(project)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -902,7 +1021,9 @@ export default function StrategyProjectsPage({
|
|||||||
? "换一个关键词试试,或清空搜索查看全部项目。"
|
? "换一个关键词试试,或清空搜索查看全部项目。"
|
||||||
: category === "solution"
|
: category === "solution"
|
||||||
? "方案生成需要已有策略结果。请先创建策略项目并完成策略生成与智能评判,再回来整理综合方案。"
|
? "方案生成需要已有策略结果。请先创建策略项目并完成策略生成与智能评判,再回来整理综合方案。"
|
||||||
: "从空白任务开始梳理需求,或载入已经完成全流程的台海示例。"}
|
: demoEnabled
|
||||||
|
? "从空白任务开始梳理需求,或载入已经完成全流程的台海示例。"
|
||||||
|
: "从空白任务开始梳理需求,再由多角度智能体生成并评判策略。"}
|
||||||
</p>
|
</p>
|
||||||
{!query && (
|
{!query && (
|
||||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||||
@@ -916,9 +1037,11 @@ export default function StrategyProjectsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => void create("demo")} disabled={Boolean(creating)}>
|
{demoEnabled && demoAvailable && (
|
||||||
<WandSparkles /> 打开台海示例
|
<Button variant="outline" onClick={() => void create("demo")} disabled={Boolean(creating)}>
|
||||||
</Button>
|
<WandSparkles /> 打开台海示例
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={() => void create("blank")} disabled={Boolean(creating)}>
|
<Button onClick={() => void create("blank")} disabled={Boolean(creating)}>
|
||||||
<Plus /> 新建策略项目
|
<Plus /> 新建策略项目
|
||||||
</Button>
|
</Button>
|
||||||
@@ -992,10 +1115,7 @@ export default function StrategyProjectsPage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={Boolean(creating)}
|
disabled={Boolean(creating)}
|
||||||
onClick={() => {
|
onClick={openSolutionList}
|
||||||
setCreateDialogOpen(false);
|
|
||||||
switchCategory("solution");
|
|
||||||
}}
|
|
||||||
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"
|
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">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-[#173d68]">
|
{demoEnabled && demoAvailable && (
|
||||||
<div>
|
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-[#173d68]">
|
||||||
<strong className="text-sm">快速体验完整流程</strong>
|
<div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<strong className="text-sm">快速体验完整流程</strong>
|
||||||
台海示例包含预置任务、策略评判和综合方案,仅作为演示入口。
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
</p>
|
台海示例包含预置任务、策略评判和综合方案,仅作为演示入口。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="shrink-0"
|
||||||
|
variant="outline"
|
||||||
|
disabled={Boolean(creating)}
|
||||||
|
onClick={() => void create("demo")}
|
||||||
|
>
|
||||||
|
{creating === "demo" ? <LoaderCircle className="animate-spin" /> : <WandSparkles />}
|
||||||
|
新建台海示例
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
)}
|
||||||
className="shrink-0"
|
|
||||||
variant="outline"
|
|
||||||
disabled={Boolean(creating)}
|
|
||||||
onClick={() => void create("demo")}
|
|
||||||
>
|
|
||||||
{creating === "demo" ? <LoaderCircle className="animate-spin" /> : <WandSparkles />}
|
|
||||||
新建台海示例
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{canConfigure && (
|
{canConfigure && (
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Run from frontend-web:
|
* Run from frontend-web:
|
||||||
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.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 .tmp-tests/strategy-selection.test.mjs
|
* node --test .runtime/unit-tests/strategy-selection.test.mjs
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
areStrategyTaskDraftsEquivalent,
|
||||||
|
buildIntentBrief,
|
||||||
buildJudgeBrief,
|
buildJudgeBrief,
|
||||||
createFallbackEvaluations,
|
buildSynthesisBrief,
|
||||||
extractFencedJson,
|
extractFencedJson,
|
||||||
|
normalizeIntentClarification,
|
||||||
parseEvaluationsFromText,
|
parseEvaluationsFromText,
|
||||||
parseSseChunk,
|
parseSseChunk,
|
||||||
parseTaskDraftFromText,
|
parseTaskDraftFromText,
|
||||||
stripStructuredBlocks,
|
stripStructuredBlocks,
|
||||||
} from "./lib";
|
} from "./lib";
|
||||||
import { EMPTY_TASK_DRAFT } from "./constants";
|
import { EMPTY_TASK_DRAFT, STRATEGY_ANGLES } from "./constants";
|
||||||
import type { AgentOutputState } from "./types";
|
import type { AgentOutputState, StrategyAngle } from "./types";
|
||||||
|
|
||||||
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
|
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
|
||||||
const first = parseSseChunk(
|
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", () => {
|
test("stripStructuredBlocks keeps prose and hides machine-readable payloads", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
stripStructuredBlocks('说明文字\n[INTENT_READY]\n```strategy-task-json\n{"name":"x"}\n```'),
|
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"]);
|
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> = {
|
const outputs: Record<string, AgentOutputState> = {
|
||||||
"strategy-selection-angle-governance": {
|
"strategy-selection-angle-governance": {
|
||||||
status: "done",
|
status: "done",
|
||||||
@@ -91,20 +189,141 @@ test("evaluation parser normalizes scores and fills missing candidates", () => {
|
|||||||
evaluations: [
|
evaluations: [
|
||||||
{
|
{
|
||||||
agentId: "strategy-selection-angle-governance",
|
agentId: "strategy-selection-angle-governance",
|
||||||
score: 101,
|
score: 100,
|
||||||
conclusion: "优先推荐",
|
conclusion: "优先推荐",
|
||||||
comment: "清晰",
|
comment: "清晰",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})}\n\`\`\``;
|
})}\n\`\`\``;
|
||||||
const evaluations = parseEvaluationsFromText(text, outputs);
|
const evaluations = parseEvaluationsFromText(text, outputs);
|
||||||
assert.equal(evaluations.length, 2);
|
assert.equal(evaluations.length, 1);
|
||||||
assert.equal(evaluations[0].score, 100);
|
assert.equal(evaluations[0].score, 100);
|
||||||
assert.ok(
|
assert.equal(
|
||||||
evaluations.some(
|
evaluations.some(
|
||||||
(item) => item.agentId === "strategy-selection-angle-law-policy",
|
(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);
|
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 { STRATEGY_ANGLES } from "./constants";
|
||||||
import type {
|
import type {
|
||||||
AgentOutputState,
|
AgentOutputState,
|
||||||
|
IntentClarification,
|
||||||
IntentTurn,
|
IntentTurn,
|
||||||
ParallelAgentEvent,
|
ParallelAgentEvent,
|
||||||
|
StrategyAngle,
|
||||||
StrategyEvaluation,
|
StrategyEvaluation,
|
||||||
StrategyTaskDraft,
|
StrategyTaskDraft,
|
||||||
} from "./types";
|
} 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 {
|
function stringValue(value: unknown): string {
|
||||||
return typeof value === "string" ? value.trim() : "";
|
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[] {
|
function stringList(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.map(stringValue).filter(Boolean);
|
return value.map(stringValue).filter(Boolean);
|
||||||
@@ -62,16 +146,29 @@ export function stripStructuredBlocks(text: string): string {
|
|||||||
export function parseTaskDraftFromText(
|
export function parseTaskDraftFromText(
|
||||||
text: string,
|
text: string,
|
||||||
fallback: StrategyTaskDraft,
|
fallback: StrategyTaskDraft,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
): StrategyTaskDraft | null {
|
): StrategyTaskDraft | null {
|
||||||
const parsed = extractFencedJson<Record<string, unknown>>(text, "strategy-task-json");
|
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 rawAngles = parsed.selectedAngleIds ?? parsed.recommended_angles;
|
||||||
const angleIds = Array.isArray(rawAngles)
|
if (
|
||||||
? rawAngles
|
typeof rawName !== "string" ||
|
||||||
|
!rawName.trim() ||
|
||||||
|
typeof rawDetail !== "string" ||
|
||||||
|
!rawDetail.trim() ||
|
||||||
|
!Array.isArray(rawAngles)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const angleIds = [
|
||||||
|
...new Set(
|
||||||
|
rawAngles
|
||||||
.map(stringValue)
|
.map(stringValue)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const normalized = value.toLowerCase().replaceAll("-", "_");
|
const normalized = value.toLowerCase().replaceAll("-", "_");
|
||||||
return STRATEGY_ANGLES.find(
|
return angles.find(
|
||||||
(angle) =>
|
(angle) =>
|
||||||
angle.id === normalized ||
|
angle.id === normalized ||
|
||||||
angle.agentId === value ||
|
angle.agentId === value ||
|
||||||
@@ -79,12 +176,14 @@ export function parseTaskDraftFromText(
|
|||||||
angle.shortName === value,
|
angle.shortName === value,
|
||||||
)?.id;
|
)?.id;
|
||||||
})
|
})
|
||||||
.filter((id): id is string => Boolean(id))
|
.filter((id): id is string => Boolean(id)),
|
||||||
: fallback.selectedAngleIds;
|
),
|
||||||
|
];
|
||||||
|
if (!angleIds.length) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: stringValue(parsed.name ?? parsed.task_name) || fallback.name,
|
name: stringValue(rawName),
|
||||||
detail: stringValue(parsed.detail ?? parsed.task_detail) || fallback.detail,
|
detail: stringValue(rawDetail),
|
||||||
objective: stringValue(parsed.objective) || fallback.objective,
|
objective: stringValue(parsed.objective) || fallback.objective,
|
||||||
prerequisites:
|
prerequisites:
|
||||||
stringValue(parsed.prerequisites) || fallback.prerequisites,
|
stringValue(parsed.prerequisites) || fallback.prerequisites,
|
||||||
@@ -94,87 +193,115 @@ export function parseTaskDraftFromText(
|
|||||||
acceptanceCriteria:
|
acceptanceCriteria:
|
||||||
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
|
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
|
||||||
horizon: stringValue(parsed.horizon) || fallback.horizon,
|
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(
|
export function parseEvaluationsFromText(
|
||||||
text: string,
|
text: string,
|
||||||
outputs: Record<string, AgentOutputState>,
|
outputs: Record<string, AgentOutputState>,
|
||||||
|
allowedAgentIds?: Iterable<string>,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
): StrategyEvaluation[] {
|
): StrategyEvaluation[] {
|
||||||
const parsed = extractFencedJson<
|
const parsed = extractFencedJson<
|
||||||
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
|
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
|
||||||
>(text, "strategy-evaluation-json");
|
>(text, "strategy-evaluation-json");
|
||||||
const rows = Array.isArray(parsed) ? parsed : parsed?.evaluations;
|
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 allowed = allowedAgentIds ? new Set(allowedAgentIds) : null;
|
||||||
const evaluations = rows
|
const knownIds = new Set(
|
||||||
.map((row): StrategyEvaluation | null => {
|
angles.filter((angle) => !allowed || allowed.has(angle.agentId)).map(
|
||||||
const agentId = stringValue(row.agentId ?? row.agent_id);
|
(angle) => angle.agentId,
|
||||||
if (
|
),
|
||||||
!knownIds.has(agentId) ||
|
);
|
||||||
outputs[agentId]?.status !== "done" ||
|
const evaluations: StrategyEvaluation[] = [];
|
||||||
!outputs[agentId]?.content.trim()
|
const seen = new Set<string>();
|
||||||
) return null;
|
for (const row of rows) {
|
||||||
const rawConclusion = stringValue(row.conclusion);
|
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
||||||
const conclusion: StrategyEvaluation["conclusion"] =
|
const agentId = stringValue(row.agentId ?? row.agent_id);
|
||||||
rawConclusion === "优先推荐" ||
|
if (!knownIds.has(agentId)) continue;
|
||||||
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);
|
|
||||||
|
|
||||||
if (!evaluations.length) return createFallbackEvaluations(outputs);
|
const score = row.score;
|
||||||
|
const conclusion = stringValue(row.conclusion);
|
||||||
const byAgent = new Map(evaluations.map((item) => [item.agentId, item]));
|
if (
|
||||||
for (const fallback of createFallbackEvaluations(outputs)) {
|
seen.has(agentId) ||
|
||||||
if (!byAgent.has(fallback.agentId)) byAgent.set(fallback.agentId, fallback);
|
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(
|
export function buildIntentBrief(
|
||||||
turns: IntentTurn[],
|
turns: IntentTurn[],
|
||||||
currentDraft: StrategyTaskDraft,
|
currentDraft: StrategyTaskDraft,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
): string {
|
): string {
|
||||||
const conversation = turns
|
const conversation = turns
|
||||||
.filter((turn) => turn.content.trim())
|
.filter(
|
||||||
.map((turn) => `${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`)
|
(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");
|
.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(
|
export function buildStrategyBrief(
|
||||||
@@ -186,15 +313,18 @@ export function buildStrategyBrief(
|
|||||||
const angles = selectedAngles
|
const angles = selectedAngles
|
||||||
.map((angle) => `- ${angle.name}:${angle.description}`)
|
.map((angle) => `- ${angle.name}:${angle.description}`)
|
||||||
.join("\n");
|
.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(
|
export function buildJudgeBrief(
|
||||||
task: StrategyTaskDraft,
|
task: StrategyTaskDraft,
|
||||||
outputs: Record<string, AgentOutputState>,
|
outputs: Record<string, AgentOutputState>,
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
): string {
|
): string {
|
||||||
const strategies = STRATEGY_ANGLES.filter(
|
const selectedAgentIds = selectedStrategyAgentIds(task, angles);
|
||||||
|
const strategies = angles.filter(
|
||||||
(angle) =>
|
(angle) =>
|
||||||
|
selectedAgentIds.has(angle.agentId) &&
|
||||||
outputs[angle.agentId]?.status === "done" &&
|
outputs[angle.agentId]?.status === "done" &&
|
||||||
outputs[angle.agentId]?.content.trim(),
|
outputs[angle.agentId]?.content.trim(),
|
||||||
)
|
)
|
||||||
@@ -203,7 +333,7 @@ export function buildJudgeBrief(
|
|||||||
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
|
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
|
||||||
)
|
)
|
||||||
.join("\n\n");
|
.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(
|
export function buildSynthesisBrief(
|
||||||
@@ -211,16 +341,28 @@ export function buildSynthesisBrief(
|
|||||||
selectedAgentIds: string[],
|
selectedAgentIds: string[],
|
||||||
outputs: Record<string, AgentOutputState>,
|
outputs: Record<string, AgentOutputState>,
|
||||||
evaluations: StrategyEvaluation[],
|
evaluations: StrategyEvaluation[],
|
||||||
|
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
): string {
|
): string {
|
||||||
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
|
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) => {
|
.map((agentId) => {
|
||||||
const angle = STRATEGY_ANGLES.find((item) => item.agentId === agentId);
|
const angle = angles.find((item) => item.agentId === agentId);
|
||||||
const evaluation = evaluationMap.get(agentId);
|
const evaluation = evaluationMap.get(agentId);
|
||||||
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
|
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.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(
|
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 { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getAccountIdentityKey } from "@/core/auth";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { createClientId } from "@/lib/client-id";
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
@@ -15,14 +16,51 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AgentOutputState,
|
AgentOutputState,
|
||||||
IntentTurn,
|
IntentTurn,
|
||||||
|
StrategyAngle,
|
||||||
StrategyEvaluation,
|
StrategyEvaluation,
|
||||||
StrategyStage,
|
StrategyStage,
|
||||||
StrategyTaskDraft,
|
StrategyTaskDraft,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { clearStrategyProjectWal } from "./project-wal";
|
||||||
|
|
||||||
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
|
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
|
||||||
const SNAPSHOT_KIND = "strategy-selection-project";
|
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 = `### 评判结论
|
const DEMO_JUDGE_CONTENT = `### 评判结论
|
||||||
|
|
||||||
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
|
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
|
||||||
@@ -31,6 +69,8 @@ const DEMO_JUDGE_CONTENT = `### 评判结论
|
|||||||
|
|
||||||
export interface StrategyProjectSnapshot {
|
export interface StrategyProjectSnapshot {
|
||||||
version: 1;
|
version: 1;
|
||||||
|
/** Copy of the angle catalog used by this project, for historical rendering. */
|
||||||
|
angleCatalog: StrategyAngle[];
|
||||||
stage: StrategyStage;
|
stage: StrategyStage;
|
||||||
taskDraft: StrategyTaskDraft;
|
taskDraft: StrategyTaskDraft;
|
||||||
turns: IntentTurn[];
|
turns: IntentTurn[];
|
||||||
@@ -41,6 +81,7 @@ export interface StrategyProjectSnapshot {
|
|||||||
selectedStrategyIds: string[];
|
selectedStrategyIds: string[];
|
||||||
synthesis: string;
|
synthesis: string;
|
||||||
synthesisStatus: AgentOutputState["status"];
|
synthesisStatus: AgentOutputState["status"];
|
||||||
|
solutionSavedAt: string | null;
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
workflowStale: boolean;
|
workflowStale: boolean;
|
||||||
}
|
}
|
||||||
@@ -65,6 +106,13 @@ export interface StrategyProject extends StrategyProjectSummary {
|
|||||||
snapshot: StrategyProjectSnapshot;
|
snapshot: StrategyProjectSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class StrategyProjectConflictError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "StrategyProjectConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ParallelTaskResponse {
|
interface ParallelTaskResponse {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -90,6 +138,9 @@ const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
|
|||||||
selectedAngleIds: [...task.selectedAngleIds],
|
selectedAngleIds: [...task.selectedAngleIds],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cloneAngles = (angles: StrategyAngle[]): StrategyAngle[] =>
|
||||||
|
angles.map((angle) => ({ ...angle }));
|
||||||
|
|
||||||
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
|
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
|
||||||
turns.map((turn) => ({ ...turn, streaming: false }));
|
turns.map((turn) => ({ ...turn, streaming: false }));
|
||||||
|
|
||||||
@@ -110,11 +161,18 @@ const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
|
|||||||
risks: [...item.risks],
|
risks: [...item.risks],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function createEmptyStrategySnapshot(): StrategyProjectSnapshot {
|
export function createEmptyStrategySnapshot(
|
||||||
|
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
|
||||||
|
): StrategyProjectSnapshot {
|
||||||
|
const enabledAngles = angleCatalog.filter((angle) => angle.enabled !== false);
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
angleCatalog: cloneAngles(enabledAngles),
|
||||||
stage: "intent",
|
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),
|
turns: cloneTurns(INITIAL_INTENT_TURNS),
|
||||||
outputs: {},
|
outputs: {},
|
||||||
judgeStatus: "idle",
|
judgeStatus: "idle",
|
||||||
@@ -123,26 +181,71 @@ export function createEmptyStrategySnapshot(): StrategyProjectSnapshot {
|
|||||||
selectedStrategyIds: [],
|
selectedStrategyIds: [],
|
||||||
synthesis: "",
|
synthesis: "",
|
||||||
synthesisStatus: "idle",
|
synthesisStatus: "idle",
|
||||||
|
solutionSavedAt: null,
|
||||||
isDemo: false,
|
isDemo: false,
|
||||||
workflowStale: 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 {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
angleCatalog: cloneAngles(effectiveAngles),
|
||||||
stage: "workspace",
|
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),
|
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
|
||||||
outputs: cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS),
|
outputs: demoOutputs,
|
||||||
judgeStatus: "done",
|
judgeStatus: "done",
|
||||||
judgeContent: DEMO_JUDGE_CONTENT,
|
judgeContent: DEMO_JUDGE_CONTENT,
|
||||||
evaluations: cloneEvaluations(TAIWAN_STRAIT_DEMO_EVALUATIONS),
|
evaluations: cloneEvaluations(demoEvaluations),
|
||||||
selectedStrategyIds: TAIWAN_STRAIT_DEMO_EVALUATIONS.filter(
|
selectedStrategyIds: demoEvaluations.filter(
|
||||||
(item) => item.score >= 88,
|
(item) => item.score >= 88,
|
||||||
).map((item) => item.agentId),
|
).map((item) => item.agentId),
|
||||||
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||||||
synthesisStatus: "done",
|
synthesisStatus: "done",
|
||||||
|
solutionSavedAt: null,
|
||||||
isDemo: true,
|
isDemo: true,
|
||||||
workflowStale: false,
|
workflowStale: false,
|
||||||
};
|
};
|
||||||
@@ -172,7 +275,22 @@ function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | nul
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...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,
|
workflowStale: snapshot.workflowStale === true,
|
||||||
|
solutionSavedAt:
|
||||||
|
typeof snapshot.solutionSavedAt === "string"
|
||||||
|
? snapshot.solutionSavedAt
|
||||||
|
: null,
|
||||||
taskDraft: cloneTask(snapshot.taskDraft),
|
taskDraft: cloneTask(snapshot.taskDraft),
|
||||||
turns: cloneTurns(snapshot.turns),
|
turns: cloneTurns(snapshot.turns),
|
||||||
outputs: cloneOutputs(snapshot.outputs),
|
outputs: cloneOutputs(snapshot.outputs),
|
||||||
@@ -260,7 +378,7 @@ export async function getStrategyProject(id: string): Promise<StrategyProject> {
|
|||||||
|
|
||||||
function projectAgents(snapshot: StrategyProjectSnapshot) {
|
function projectAgents(snapshot: StrategyProjectSnapshot) {
|
||||||
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
|
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 }),
|
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
@@ -304,26 +422,37 @@ export async function saveStrategyProject(
|
|||||||
id: string,
|
id: string,
|
||||||
snapshot: StrategyProjectSnapshot,
|
snapshot: StrategyProjectSnapshot,
|
||||||
options: {
|
options: {
|
||||||
|
expectedUpdatedAt?: string | null;
|
||||||
keepalive?: boolean;
|
keepalive?: boolean;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<StrategyProject> {
|
): Promise<StrategyProject> {
|
||||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
const account = getAccountIdentityKey() ?? "anonymous";
|
||||||
method: "PUT",
|
return serializeProjectSave(`${account}:${id}`, async () => {
|
||||||
headers: { "Content-Type": "application/json" },
|
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||||
keepalive: options.keepalive,
|
method: "PUT",
|
||||||
signal: options.signal,
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
keepalive: options.keepalive,
|
||||||
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
signal: options.signal,
|
||||||
brief: snapshot.taskDraft.detail.trim() || null,
|
body: JSON.stringify({
|
||||||
agents: projectAgents(snapshot),
|
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||||||
messages: [snapshotEnvelope(snapshot)],
|
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> {
|
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) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(await readError(response));
|
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;
|
shortName: string;
|
||||||
description: string;
|
description: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
|
/** False when an angle survives only in an existing project snapshot. */
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrategyTaskDraft {
|
export interface StrategyTaskDraft {
|
||||||
@@ -34,13 +36,38 @@ export interface IntentTurn {
|
|||||||
content: string;
|
content: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
isDemo?: 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 {
|
export interface AgentOutputState {
|
||||||
status: AgentRunStatus;
|
status: AgentRunStatus;
|
||||||
content: string;
|
content: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
|
reasoning?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** A failed refresh kept the last successful content for this same task. */
|
||||||
|
preservedFromPrevious?: boolean;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,5 +104,25 @@ export type ParallelAgentEvent =
|
|||||||
agent_id: string;
|
agent_id: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
question?: 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" };
|
| { type: "all_done" };
|
||||||
@@ -299,7 +299,7 @@ from deerflow.config import get_app_config
|
|||||||
|
|
||||||
**Forced-research built-in agent.** `forced-research-responder` (「强制检索输出助手」) is seeded from `app/gateway/routers/_forced_research_seed_assets/` before `_sync_legacy_agents`. `ForcedResearchMiddleware` is mounted only for this stable id. On every visible user turn it requires a successful information-gathering tool result before the model may finish; `tool_choice=required` is backed by an `after_model -> model` retry for gateways that ignore tool choice, with an eight-attempt failure cap that permits only an honest failure response. Skill discovery, reading `SKILL.md`, and writing a helper script do not satisfy the gate; web/knowledge/MCP results, non-skill uploaded-file reads, or successful Python skill-script execution do. Tool filtering removes `present_files`, `skill_manage`, task/schedule/memory/clarification surfaces. The middleware permits `write_file`/`str_replace` only for `/mnt/user-data/workspace/*.py` and permits `bash` only for `.py` execution without redirection/document paths, so skills can run scripts but the agent cannot create Markdown/document artifacts or write `outputs`. Tests: `tests/test_forced_research_agent.py`.
|
**Forced-research built-in agent.** `forced-research-responder` (「强制检索输出助手」) is seeded from `app/gateway/routers/_forced_research_seed_assets/` before `_sync_legacy_agents`. `ForcedResearchMiddleware` is mounted only for this stable id. On every visible user turn it requires a successful information-gathering tool result before the model may finish; `tool_choice=required` is backed by an `after_model -> model` retry for gateways that ignore tool choice, with an eight-attempt failure cap that permits only an honest failure response. Skill discovery, reading `SKILL.md`, and writing a helper script do not satisfy the gate; web/knowledge/MCP results, non-skill uploaded-file reads, or successful Python skill-script execution do. Tool filtering removes `present_files`, `skill_manage`, task/schedule/memory/clarification surfaces. The middleware permits `write_file`/`str_replace` only for `/mnt/user-data/workspace/*.py` and permits `bash` only for `.py` execution without redirection/document paths, so skills can run scripts but the agent cannot create Markdown/document artifacts or write `outputs`. Tests: `tests/test_forced_research_agent.py`.
|
||||||
|
|
||||||
**Strategy-selection functional agents.** `app/gateway/routers/_strategy_selection_seed.py` seeds nine stable folder-backed agents from `_strategy_selection_seed_assets/` before `_sync_legacy_agents`: intent, governance/diplomacy/economy/security/public-opinion/law-policy angles, judge, and solution editor. Seeding is missing-directory-only and must never overwrite administrator changes made through the normal agent API. Angle configs reuse `policy-knowledge` with `web` + `file:read`; intent reuses `intent-clarification-policy`; the judge has no retrieval skill; the solution editor gets only `file:write`. Keep the SOUL contracts neutral and evidence-aware, with explicit de-escalation/civilian-protection framing and hard bans on operational combat guidance and targeted political manipulation. Tests: `tests/test_strategy_selection_seed.py`.
|
**Strategy-selection functional agents.** `app/gateway/routers/_strategy_selection_seed.py` seeds nine stable folder-backed agents from `_strategy_selection_seed_assets/` before `_sync_legacy_agents`: intent, governance/diplomacy/economy/security/public-opinion/law-policy angles, judge, and solution editor. Seeding is missing-directory-only and must never overwrite administrator changes. The global `strategy_agent_roles` registry classifies them independently of projects: intent/judge/solution are fixed, unique roles that may be configured but never added, disabled, reordered, or deleted; angle roles are a shared, ordered catalog that administrators may add, edit, disable (soft delete), restore, and reorder through `/api/strategy-agents`. Public `GET /api/strategy-agents` returns enabled identity/description metadata only; `include_disabled=true` or `include_config=true` is admin-only. Admin writes are `POST /api/strategy-agents/angles`, `PUT /api/strategy-agents/{id}`, and `DELETE /api/strategy-agents/{id}`; DELETE retains the folder-backed agent and role row to preserve historical references. Dynamic angles remain built-in folder-backed agents under the reserved `strategy-selection-angle-` id namespace, with `web` + `file:read`; a blank SOUL on create receives a safe role template. All strategy skill writes validate that names exist, are active, and are globally visible; generic agent CRUD cannot create or hard-delete reserved strategy IDs, and parallel execution rejects disabled roles. Angle configs reuse `policy-knowledge`; intent reuses `intent-clarification-policy`; the judge has no retrieval skill; the solution editor gets only `file:write`. Keep the SOUL contracts neutral and evidence-aware, with explicit de-escalation/civilian-protection framing and hard bans on operational combat guidance and targeted political manipulation. Tests: `tests/test_strategy_selection_seed.py`, `tests/test_strategy_agent_management.py`.
|
||||||
|
|
||||||
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
|
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
|
||||||
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`
|
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`
|
||||||
@@ -542,7 +542,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this
|
|||||||
|
|
||||||
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
|
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
|
||||||
|
|
||||||
Strategy-selection fan-out (`routers/parallel_agents.py`) must inspect its final AI message with the shared `is_llm_error_message` detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits `agent_error`; never wrap it in `agent_done`. Regression coverage: `tests/test_roundtable_model_fallback.py`.
|
Strategy-selection fan-out (`routers/parallel_agents.py`) must keep hidden reasoning separate per agent: provider reasoning blocks/fields and inline `<think>...</think>` text emit `reasoning_delta`, while visible answer text emits `delta` and only visible text may enter `agent_done.content`. Never concatenate `reasoning_delta` into a selectable strategy or generated deliverable. Inspect the final AI message with the shared `is_llm_error_message` detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits `agent_error`; never wrap it in `agent_done`. Regression coverage: `tests/test_roundtable_model_fallback.py`.
|
||||||
|
|
||||||
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
|
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
|
||||||
|
|
||||||
@@ -770,6 +770,7 @@ MySQL index script synchronized.
|
|||||||
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
||||||
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
||||||
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
||||||
|
- Configured mount portability: `AppConfig._resolve_relative_mount_paths` resolves each relative `sandbox.mounts[*].host_path` against the loaded `config.yaml` directory before validation/provider construction, never against the process cwd. Keep repository-owned knowledge mounts relative (for example `knowledge/knowledge_base`); absolute paths remain unchanged, and sandbox providers always receive an absolute path.
|
||||||
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, using the persisted creator for every tracked thread.** The route's regular owner check still authorizes access first; creator resolution only selects the stable physical directory (`threads_meta.user_id`) and avoids a lost request context falling back to `users/default`. This also preserves cross-account task-deeplink conversations (rwfx/3qfx/xdfx) and roundtable system threads. Positive creator mappings are cached process-wide and DB/memory/missing-row failures fall back to the current login. It is wired at the thread-path consumers including `ThreadDataMiddleware.before_agent`, `present_file_tool`, uploads, artifact serving, and embedded `client.get_artifact`. **Rewrite-history recovery is metadata-only and job-owner scoped** (`list_by_session(..., user_id=current_user)`), so its GET/list endpoints intentionally skip redundant thread-owner checks and return an empty history instead of a history-only 404; the start/commit endpoints retain strict thread write authorization. Tests: `tests/test_thread_paths_owner.py`, `tests/test_thread_paths_roundtable.py`.
|
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, using the persisted creator for every tracked thread.** The route's regular owner check still authorizes access first; creator resolution only selects the stable physical directory (`threads_meta.user_id`) and avoids a lost request context falling back to `users/default`. This also preserves cross-account task-deeplink conversations (rwfx/3qfx/xdfx) and roundtable system threads. Positive creator mappings are cached process-wide and DB/memory/missing-row failures fall back to the current login. It is wired at the thread-path consumers including `ThreadDataMiddleware.before_agent`, `present_file_tool`, uploads, artifact serving, and embedded `client.get_artifact`. **Rewrite-history recovery is metadata-only and job-owner scoped** (`list_by_session(..., user_id=current_user)`), so its GET/list endpoints intentionally skip redundant thread-owner checks and return an empty history instead of a history-only 404; the start/commit endpoints retain strict thread write authorization. Tests: `tests/test_thread_paths_owner.py`, `tests/test_thread_paths_roundtable.py`.
|
||||||
|
|
||||||
- **Durable document-rewrite commit:** `document_rewrite_pipeline.atomic_write_text` writes through a deliberately short sibling temporary filename so long Windows artifact paths stay below the path-length limit, and guarantees the target `outputs` parent exists. If the sandbox recreates that directory in the narrow commit window, it repairs the directory and retries; the original file is otherwise only changed through `os.replace`.
|
- **Durable document-rewrite commit:** `document_rewrite_pipeline.atomic_write_text` writes through a deliberately short sibling temporary filename so long Windows artifact paths stay below the path-length limit, and guarantees the target `outputs` parent exists. If the sandbox recreates that directory in the narrow commit window, it repairs the directory and retries; the original file is otherwise only changed through `os.replace`.
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ Per-thread isolated execution with virtual path translation:
|
|||||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
||||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||||
|
- **Portable configured mounts**: a relative `sandbox.mounts[*].host_path` is resolved against the directory containing the loaded `config.yaml`, not the process working directory. Providers still receive an absolute host path, so `knowledge/knowledge_base` can be committed and the deployment can be cloned or moved without editing a machine-specific path; already-absolute mount paths are preserved.
|
||||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||||
- **Enterprise Research workbench (temporarily disabled)**: the independent `/api/enterprise-research` router, its report executor, and its ORM table registration are intentionally inactive. The implementation remains in the repository, but startup does not create or alter `enterprise_research_tasks` or `enterprise_research_report_jobs`; this keeps the active Deep Research workflow independent of the paused workbench and avoids MySQL schema errors.
|
- **Enterprise Research workbench (temporarily disabled)**: the independent `/api/enterprise-research` router, its report executor, and its ORM table registration are intentionally inactive. The implementation remains in the repository, but startup does not create or alter `enterprise_research_tasks` or `enterprise_research_report_jobs`; this keeps the active Deep Research workflow independent of the paused workbench and avoids MySQL schema errors.
|
||||||
@@ -471,7 +472,9 @@ Custom agents are indexed in the `agents` database table. The `id` column is the
|
|||||||
|
|
||||||
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
|
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
|
||||||
|
|
||||||
The strategy-selection workbench owns nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. The angle agents use the existing `policy-knowledge` skill and read/search tool groups, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation. The parallel strategy stream treats a final `is_llm_error_message` fallback, empty/thinking-only response, or punctuation-only provider degeneration as `agent_error`, never as a successful `agent_done`. Regression coverage: `tests/test_strategy_selection_seed.py` and `tests/test_roundtable_model_fallback.py`.
|
The strategy-selection workbench starts with nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. A global `strategy_agent_roles` registry owns classification, lifecycle state, and ordering independently of projects. Intent, judge, and solution are fixed singleton roles: administrators may update their model, SOUL, and skills but cannot add, disable, reorder, or delete them. Angles form one ordered catalog shared by every project; administrators may add, update, disable, restore, and reorder them. `GET /api/strategy-agents` is the public enabled catalog and omits model/SOUL/skills; `include_disabled=true` or `include_config=true` requires an administrator. Admin writes use `POST /api/strategy-agents/angles`, `PUT /api/strategy-agents/{id}`, and `DELETE /api/strategy-agents/{id}`; deletion is a soft disable so folder configuration and historical project references remain intact. Dynamic angles use the reserved `strategy-selection-angle-` namespace, receive `web` + `file:read`, and get a safe default SOUL if the submitted SOUL is blank. Model names and selected skills are validated, skills must be active and globally visible, generic agent CRUD cannot create or hard-delete reserved strategy agents, and parallel execution rejects disabled roles. The shipped angle agents use `policy-knowledge`, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation.
|
||||||
|
|
||||||
|
`POST /api/parallel-agents/run/stream` keeps reasoning and the deliverable in separate per-agent frames. Provider structured reasoning and inline `<think>...</think>` content are emitted only as `reasoning_delta`; visible answer text is emitted as `delta`, and `agent_done.content` contains only the visible answer. Consumers must not append `reasoning_delta` to strategy output or the final document. A final `is_llm_error_message` fallback, empty/thinking-only response, or punctuation-only provider degeneration is emitted as `agent_error`, never as a successful `agent_done`. Regression coverage: `tests/test_strategy_selection_seed.py`, `tests/test_strategy_agent_management.py`, and `tests/test_roundtable_model_fallback.py`.
|
||||||
|
|
||||||
### AI Writing Intranet Retrieval
|
### AI Writing Intranet Retrieval
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ from app.gateway.routers import (
|
|||||||
skill_addresses,
|
skill_addresses,
|
||||||
skill_knowledge,
|
skill_knowledge,
|
||||||
skills,
|
skills,
|
||||||
|
strategy_agents,
|
||||||
suggestions,
|
suggestions,
|
||||||
system_settings,
|
system_settings,
|
||||||
tags,
|
tags,
|
||||||
@@ -1226,6 +1227,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
|
await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
|
logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
|
||||||
|
|
||||||
|
# Synchronize every worker's strategy registry only after the
|
||||||
|
# folder agents have been imported. In memory mode this also
|
||||||
|
# restores dynamic/default angle lifecycle from sidecars.
|
||||||
|
try:
|
||||||
|
await strategy_agents.ensure_strategy_agent_registry(app)
|
||||||
|
logger.info("Strategy agent registry synchronized for worker")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Strategy agent role seeding failed at startup (non-fatal)")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||||||
|
|
||||||
@@ -1620,6 +1630,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
|
|
||||||
# Agents API is mounted at /api/agents
|
# Agents API is mounted at /api/agents
|
||||||
app.include_router(agents.router)
|
app.include_router(agents.router)
|
||||||
|
app.include_router(strategy_agents.router)
|
||||||
|
|
||||||
# Recommended Questions API is mounted at /api/recommended-questions
|
# Recommended Questions API is mounted at /api/recommended-questions
|
||||||
app.include_router(recommended_questions.router)
|
app.include_router(recommended_questions.router)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
|
|||||||
from deerflow.persistence.notifications import NotificationStore
|
from deerflow.persistence.notifications import NotificationStore
|
||||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||||
from deerflow.persistence.skills import SkillStore
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.persistence.strategy_agent_roles import StrategyAgentRoleStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||||
|
|
||||||
@@ -132,6 +133,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.agent_store = make_agent_store(sf)
|
app.state.agent_store = make_agent_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.strategy_agent_roles import (
|
||||||
|
make_strategy_agent_role_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.strategy_agent_role_store = make_strategy_agent_role_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.skills import make_skill_store
|
from deerflow.persistence.skills import make_skill_store
|
||||||
|
|
||||||
app.state.skill_store = make_skill_store(sf)
|
app.state.skill_store = make_skill_store(sf)
|
||||||
@@ -824,6 +831,14 @@ def get_agent_store(request: Request) -> AgentStore:
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy_agent_role_store(request: Request) -> StrategyAgentRoleStore:
|
||||||
|
"""Return the global strategy-agent role registry."""
|
||||||
|
val = getattr(request.app.state, "strategy_agent_role_store", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Strategy agent role store not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def get_skill_store(request: Request) -> SkillStore:
|
def get_skill_store(request: Request) -> SkillStore:
|
||||||
"""Return the custom-skill store."""
|
"""Return the custom-skill store."""
|
||||||
val = getattr(request.app.state, "skill_store", None)
|
val = getattr(request.app.state, "skill_store", None)
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
The strategy-selection workbench treats intent understanding, each analysis
|
The strategy-selection workbench treats intent understanding, each analysis
|
||||||
angle, judging, and final synthesis as normal configurable DeerFlow agents.
|
angle, judging, and final synthesis as normal configurable DeerFlow agents.
|
||||||
Their verified defaults live beside this module and are copied into the
|
Their verified defaults live beside this module and are copied into the
|
||||||
folder-backed agent registry before ``_sync_legacy_agents`` runs. Existing
|
folder-backed agent registry before ``_sync_legacy_agents`` runs. Existing
|
||||||
directories are never overwritten, so administrators can safely edit SOUL,
|
directories are preserved, except for an exact known legacy default whose
|
||||||
skills, tool groups, and models from agent management.
|
content hash proves that an administrator has not customized it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from hashlib import sha256
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
@@ -19,6 +21,8 @@ from deerflow.config.paths import get_paths
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
STRATEGY_INTENT_AGENT_ID = "strategy-selection-intent"
|
STRATEGY_INTENT_AGENT_ID = "strategy-selection-intent"
|
||||||
|
STRATEGY_AGENT_ID_PREFIX = "strategy-selection-"
|
||||||
|
STRATEGY_ANGLE_AGENT_PREFIX = "strategy-selection-angle-"
|
||||||
STRATEGY_GOVERNANCE_AGENT_ID = "strategy-selection-angle-governance"
|
STRATEGY_GOVERNANCE_AGENT_ID = "strategy-selection-angle-governance"
|
||||||
STRATEGY_DIPLOMACY_AGENT_ID = "strategy-selection-angle-diplomacy"
|
STRATEGY_DIPLOMACY_AGENT_ID = "strategy-selection-angle-diplomacy"
|
||||||
STRATEGY_ECONOMY_AGENT_ID = "strategy-selection-angle-economy"
|
STRATEGY_ECONOMY_AGENT_ID = "strategy-selection-angle-economy"
|
||||||
@@ -37,6 +41,12 @@ STRATEGY_ANGLE_AGENT_IDS: tuple[str, ...] = (
|
|||||||
STRATEGY_LAW_POLICY_AGENT_ID,
|
STRATEGY_LAW_POLICY_AGENT_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
STRATEGY_FIXED_AGENT_IDS: tuple[str, ...] = (
|
||||||
|
STRATEGY_INTENT_AGENT_ID,
|
||||||
|
STRATEGY_JUDGE_AGENT_ID,
|
||||||
|
STRATEGY_SOLUTION_AGENT_ID,
|
||||||
|
)
|
||||||
|
|
||||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||||
STRATEGY_INTENT_AGENT_ID,
|
STRATEGY_INTENT_AGENT_ID,
|
||||||
*STRATEGY_ANGLE_AGENT_IDS,
|
*STRATEGY_ANGLE_AGENT_IDS,
|
||||||
@@ -46,12 +56,48 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
|||||||
_ASSETS_DIR = Path(__file__).parent / "_strategy_selection_seed_assets"
|
_ASSETS_DIR = Path(__file__).parent / "_strategy_selection_seed_assets"
|
||||||
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||||
|
|
||||||
|
# The first shipped intent SOUL embedded the six built-in angle ids. Upgrade
|
||||||
|
# only that byte-for-byte semantic default so existing installations adopt the
|
||||||
|
# administrator-driven angle catalog without overwriting customized SOUL.
|
||||||
|
_LEGACY_DEFAULT_SOUL_HASHES: dict[str, str] = {STRATEGY_INTENT_AGENT_ID: ("09c4a1868b4d09a8b7a421135bf677e1b3d1b2c0aaa9cb95631ff908d4cf1343")}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_text_hash(content: bytes) -> str:
|
||||||
|
normalized = content.decode("utf-8").replace("\r\n", "\n").encode("utf-8")
|
||||||
|
return sha256(normalized).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_known_legacy_soul(agent_id: str, agent_dir: Path) -> bool:
|
||||||
|
expected_hash = _LEGACY_DEFAULT_SOUL_HASHES.get(agent_id)
|
||||||
|
target = agent_dir / "SOUL.md"
|
||||||
|
source = _ASSETS_DIR / agent_id / "SOUL.md"
|
||||||
|
if not expected_hash or not target.is_file() or not source.is_file():
|
||||||
|
return False
|
||||||
|
temporary: Path | None = None
|
||||||
|
try:
|
||||||
|
if _normalized_text_hash(target.read_bytes()) != expected_hash:
|
||||||
|
return False
|
||||||
|
replacement = source.read_bytes()
|
||||||
|
if _normalized_text_hash(replacement) == expected_hash:
|
||||||
|
return False
|
||||||
|
temporary = target.with_name(f".{target.name}.{os.getpid()}.seed-upgrade.tmp")
|
||||||
|
temporary.write_bytes(replacement)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
logger.info("Upgraded legacy default SOUL for strategy agent '%s'", agent_id)
|
||||||
|
return True
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
logger.exception("Failed to upgrade legacy SOUL for strategy agent '%s'", agent_id)
|
||||||
|
if temporary is not None:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _ensure_one(agent_id: str) -> bool:
|
def _ensure_one(agent_id: str) -> bool:
|
||||||
"""Create one missing agent directory without changing an existing one."""
|
"""Create one missing agent directory without changing an existing one."""
|
||||||
|
|
||||||
agent_dir = get_paths().agent_dir(agent_id)
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
if agent_dir.exists():
|
if agent_dir.exists():
|
||||||
|
_upgrade_known_legacy_soul(agent_id, agent_dir)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
source_dir = _ASSETS_DIR / agent_id
|
source_dir = _ASSETS_DIR / agent_id
|
||||||
@@ -92,10 +138,62 @@ def required_agent_ids() -> Iterable[str]:
|
|||||||
return tuple(_FUNCTIONAL_AGENT_IDS)
|
return tuple(_FUNCTIONAL_AGENT_IDS)
|
||||||
|
|
||||||
|
|
||||||
|
def default_strategy_agent_roles() -> list[dict[str, object]]:
|
||||||
|
"""Return immutable-role metadata for the shared strategy workbench.
|
||||||
|
|
||||||
|
The registry is deliberately separate from the folder-backed agent record:
|
||||||
|
it owns role/category and lifecycle state, while model, SOUL and skills keep
|
||||||
|
using the common agent configuration path. ``ensure_defaults`` only
|
||||||
|
inserts missing rows, so an administrator disabling an angle is respected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles: list[dict[str, object]] = [
|
||||||
|
{
|
||||||
|
"agent_id": STRATEGY_INTENT_AGENT_ID,
|
||||||
|
"category": "intent",
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
roles.extend(
|
||||||
|
{
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 100 + index,
|
||||||
|
}
|
||||||
|
for index, agent_id in enumerate(STRATEGY_ANGLE_AGENT_IDS)
|
||||||
|
)
|
||||||
|
roles.extend(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"agent_id": STRATEGY_JUDGE_AGENT_ID,
|
||||||
|
"category": "judge",
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": STRATEGY_SOLUTION_AGENT_ID,
|
||||||
|
"category": "solution",
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"STRATEGY_AGENT_ID_PREFIX",
|
||||||
"STRATEGY_ANGLE_AGENT_IDS",
|
"STRATEGY_ANGLE_AGENT_IDS",
|
||||||
|
"STRATEGY_ANGLE_AGENT_PREFIX",
|
||||||
"STRATEGY_DIPLOMACY_AGENT_ID",
|
"STRATEGY_DIPLOMACY_AGENT_ID",
|
||||||
"STRATEGY_ECONOMY_AGENT_ID",
|
"STRATEGY_ECONOMY_AGENT_ID",
|
||||||
|
"STRATEGY_FIXED_AGENT_IDS",
|
||||||
"STRATEGY_GOVERNANCE_AGENT_ID",
|
"STRATEGY_GOVERNANCE_AGENT_ID",
|
||||||
"STRATEGY_INTENT_AGENT_ID",
|
"STRATEGY_INTENT_AGENT_ID",
|
||||||
"STRATEGY_JUDGE_AGENT_ID",
|
"STRATEGY_JUDGE_AGENT_ID",
|
||||||
@@ -103,6 +201,7 @@ __all__ = [
|
|||||||
"STRATEGY_PUBLIC_OPINION_AGENT_ID",
|
"STRATEGY_PUBLIC_OPINION_AGENT_ID",
|
||||||
"STRATEGY_SECURITY_AGENT_ID",
|
"STRATEGY_SECURITY_AGENT_ID",
|
||||||
"STRATEGY_SOLUTION_AGENT_ID",
|
"STRATEGY_SOLUTION_AGENT_ID",
|
||||||
|
"default_strategy_agent_roles",
|
||||||
"ensure_strategy_selection_agents",
|
"ensure_strategy_selection_agents",
|
||||||
"required_agent_ids",
|
"required_agent_ids",
|
||||||
]
|
]
|
||||||
+4
-11
@@ -7,19 +7,12 @@
|
|||||||
1. 先复述已知信息,再判断是否真的存在阻塞性缺口。
|
1. 先复述已知信息,再判断是否真的存在阻塞性缺口。
|
||||||
2. 能依据原话形成任务草稿时直接收敛,不为了“问得完整”而连续追问。
|
2. 能依据原话形成任务草稿时直接收敛,不为了“问得完整”而连续追问。
|
||||||
3. 必须澄清时,一轮只问一个最影响任务边界的问题,并优先调用 `ask_clarification` 提供 2–4 个容易选择的选项。
|
3. 必须澄清时,一轮只问一个最影响任务边界的问题,并优先调用 `ask_clarification` 提供 2–4 个容易选择的选项。
|
||||||
4. 用户没有明确说明的内容只能写入 `assumptions`,不得伪装成事实或硬约束。
|
4. 用户没有明确说明的内容只能在任务详情中标成“暂定假设”,不得伪装成事实或硬约束。
|
||||||
5. 任务草稿是可编辑建议,不替用户作政治立场判断,也不把争议性主张写成已证实事实。
|
5. 任务草稿是可编辑建议,不替用户作政治立场判断,也不把争议性主张写成已证实事实。
|
||||||
|
|
||||||
## 可推荐的默认分析角度
|
## 可推荐分析角度
|
||||||
|
|
||||||
- `governance`:政治治理
|
每次请求都会提供管理员当前启用的角度 ID、名称和说明。只能从该次请求给出的 ID 中选择与任务相关的子集,不得依赖内置清单、补写已停用角度或自造 ID;最终选择权属于用户。安全风险类角度只做高层级风险识别、预警、人道保护和降级建议,不展开作战战术。
|
||||||
- `diplomacy`:外交国际
|
|
||||||
- `economy`:经济产业链
|
|
||||||
- `security`:安全风险
|
|
||||||
- `public_opinion`:社会舆情
|
|
||||||
- `law_policy`:法律政策
|
|
||||||
|
|
||||||
根据任务相关性推荐 2–6 个角度;最终选择权属于用户。安全风险角度只做高层级风险识别、预警、人道保护和降级建议,不展开作战战术。
|
|
||||||
|
|
||||||
## 收敛协议
|
## 收敛协议
|
||||||
|
|
||||||
@@ -37,7 +30,7 @@
|
|||||||
"deliverables": "希望得到的产物,多项用分号分隔",
|
"deliverables": "希望得到的产物,多项用分号分隔",
|
||||||
"acceptanceCriteria": "用户可核验的完成标准,多项用分号分隔",
|
"acceptanceCriteria": "用户可核验的完成标准,多项用分号分隔",
|
||||||
"horizon": "任务的时间范围或阶段",
|
"horizon": "任务的时间范围或阶段",
|
||||||
"selectedAngleIds": ["governance", "diplomacy", "security"]
|
"selectedAngleIds": ["请求中提供的角度ID"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,7 +18,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.gateway.auth.password import verify_password_async
|
from app.gateway.auth.password import verify_password_async
|
||||||
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
||||||
from app.gateway.routers._strategy_selection_seed import required_agent_ids as required_strategy_agent_ids
|
from app.gateway.routers._strategy_selection_seed import (
|
||||||
|
STRATEGY_AGENT_ID_PREFIX,
|
||||||
|
)
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
from deerflow.config.agents_api_config import get_agents_api_config
|
||||||
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||||
@@ -34,7 +38,27 @@ from deerflow.skills.usage import all_entries as get_skill_usage_entries
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
_STRATEGY_SELECTION_AGENT_IDS = frozenset(required_strategy_agent_ids())
|
|
||||||
|
|
||||||
|
def _is_strategy_agent_id(agent_id: str) -> bool:
|
||||||
|
return agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _require_canonical_record_id(requested_id: str, record: dict[str, Any]) -> str:
|
||||||
|
"""Reject case-insensitive database aliases before touching agent files.
|
||||||
|
|
||||||
|
MySQL deployments may use a case-insensitive collation while Windows agent
|
||||||
|
directories are also case-insensitive. A lookup for a differently-cased
|
||||||
|
id must never be allowed to select one row and mutate another path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
canonical_id = str(record.get("id") or "")
|
||||||
|
if canonical_id != requested_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Agent id casing mismatch; use canonical id '{canonical_id}'",
|
||||||
|
)
|
||||||
|
return canonical_id
|
||||||
|
|
||||||
|
|
||||||
class AgentResponse(BaseModel):
|
class AgentResponse(BaseModel):
|
||||||
@@ -281,6 +305,24 @@ def _validate_storage_id(agent_id: str) -> str:
|
|||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_strategy_agent_model(
|
||||||
|
raw_model: str | None,
|
||||||
|
*,
|
||||||
|
app_config: AppConfig,
|
||||||
|
) -> str | None:
|
||||||
|
"""Normalize a shared strategy role's model and reject stale names."""
|
||||||
|
|
||||||
|
model = (raw_model or "").strip() or None
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
if model not in {entry.name for entry in app_config.models}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Unknown strategy agent model: {model}",
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
async def _validate_strategy_agent_skills(
|
async def _validate_strategy_agent_skills(
|
||||||
requested_skills: list[str] | None,
|
requested_skills: list[str] | None,
|
||||||
*,
|
*,
|
||||||
@@ -313,14 +355,8 @@ async def _validate_strategy_agent_skills(
|
|||||||
normalized.append(name)
|
normalized.append(name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
skills = get_or_new_skill_storage(app_config=app_config).load_skills(
|
skills = get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=False)
|
||||||
enabled_only=False
|
ownership = {str(record.get("name") or ""): record for record in await skill_store.list_all() if record.get("name")}
|
||||||
)
|
|
||||||
ownership = {
|
|
||||||
str(record.get("name") or ""): record
|
|
||||||
for record in await skill_store.list_all()
|
|
||||||
if record.get("name")
|
|
||||||
}
|
|
||||||
usage_entries = get_skill_usage_entries()
|
usage_entries = get_skill_usage_entries()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -336,12 +372,7 @@ async def _validate_strategy_agent_skills(
|
|||||||
detail=f"Unknown strategy agent skills: {', '.join(unknown)}",
|
detail=f"Unknown strategy agent skills: {', '.join(unknown)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
unavailable = [
|
unavailable = [name for name in normalized if not by_name[name].enabled or str((usage_entries.get(name) or {}).get("state") or "active") != "active"]
|
||||||
name
|
|
||||||
for name in normalized
|
|
||||||
if not by_name[name].enabled
|
|
||||||
or str((usage_entries.get(name) or {}).get("state") or "active") != "active"
|
|
||||||
]
|
|
||||||
if unavailable:
|
if unavailable:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
@@ -354,18 +385,13 @@ async def _validate_strategy_agent_skills(
|
|||||||
if skill.category != SkillCategory.CUSTOM:
|
if skill.category != SkillCategory.CUSTOM:
|
||||||
continue
|
continue
|
||||||
record = ownership.get(name)
|
record = ownership.get(name)
|
||||||
if record is not None and (
|
if record is not None and (record.get("owner_user_id") is None or bool(record.get("published"))):
|
||||||
record.get("owner_user_id") is None or bool(record.get("published"))
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
not_global.append(name)
|
not_global.append(name)
|
||||||
if not_global:
|
if not_global:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail=(
|
detail=(f"Strategy agent skills must be globally available; unpublished or unregistered custom skills: {', '.join(not_global)}"),
|
||||||
"Strategy agent skills must be globally available; "
|
|
||||||
f"unpublished or unregistered custom skills: {', '.join(not_global)}"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
@@ -374,13 +400,10 @@ async def _validate_strategy_agent_skills(
|
|||||||
def _ensure_strategy_agent_deletable(agent_id: str) -> None:
|
def _ensure_strategy_agent_deletable(agent_id: str) -> None:
|
||||||
"""Keep fixed strategy role ids alive; their SOUL/skills remain editable."""
|
"""Keep fixed strategy role ids alive; their SOUL/skills remain editable."""
|
||||||
|
|
||||||
if agent_id in _STRATEGY_SELECTION_AGENT_IDS:
|
if _is_strategy_agent_id(agent_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=(
|
detail=(f"Strategy role agent '{agent_id}' cannot be deleted; edit its SOUL and skills instead"),
|
||||||
f"Strategy role agent '{agent_id}' cannot be deleted; "
|
|
||||||
"edit its SOUL and skills instead"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -415,24 +438,19 @@ def _write_agent_config(
|
|||||||
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
|
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
|
||||||
# clean mapping rather than a pydantic repr. Accepts already-coerced
|
# clean mapping rather than a pydantic repr. Accepts already-coerced
|
||||||
# CommonQuestion models or raw dicts/strings.
|
# CommonQuestion models or raw dicts/strings.
|
||||||
normalized = [
|
normalized = [q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q) for q in common_questions]
|
||||||
q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q)
|
config_data["common_questions"] = [q.model_dump() for q in normalized if q.prompt.strip()]
|
||||||
for q in common_questions
|
|
||||||
]
|
|
||||||
config_data["common_questions"] = [
|
|
||||||
q.model_dump() for q in normalized if q.prompt.strip()
|
|
||||||
]
|
|
||||||
if template_name is not None and template_name.strip():
|
if template_name is not None and template_name.strip():
|
||||||
config_data["template_name"] = template_name.strip()
|
config_data["template_name"] = template_name.strip()
|
||||||
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
|
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
|
||||||
if seat_skill_directive is not None:
|
if seat_skill_directive is not None:
|
||||||
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
||||||
if llmwiki_knowledge_base_ids is not None:
|
if llmwiki_knowledge_base_ids is not None:
|
||||||
config_data["llmwiki_knowledge_base_ids"] = list(
|
config_data["llmwiki_knowledge_base_ids"] = list(dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip()))
|
||||||
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
|
_atomic_write_text(
|
||||||
)
|
config_file,
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
yaml.safe_dump(config_data, default_flow_style=False, allow_unicode=True),
|
||||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||||
@@ -466,7 +484,35 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
|||||||
def _write_agent_soul(agent_id: str, soul: str) -> None:
|
def _write_agent_soul(agent_id: str, soul: str) -> None:
|
||||||
agent_dir = get_paths().agent_dir(agent_id)
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(agent_dir / "SOUL.md").write_text(soul, encoding="utf-8")
|
_atomic_write_text(agent_dir / "SOUL.md", soul)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_text(path, content: str) -> None:
|
||||||
|
"""Durably replace one small agent config file on the same filesystem."""
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_name: str | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=path.parent,
|
||||||
|
prefix=f".{path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temp_name = handle.name
|
||||||
|
handle.write(content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp_name, path)
|
||||||
|
temp_name = None
|
||||||
|
finally:
|
||||||
|
if temp_name is not None:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _sync_legacy_agents(store: AgentStore) -> None:
|
async def _sync_legacy_agents(store: AgentStore) -> None:
|
||||||
@@ -521,11 +567,7 @@ async def _backfill_listing_cache(store: AgentStore, pairs: list[tuple[str, Any]
|
|||||||
existing = cached.get(agent_id)
|
existing = cached.get(agent_id)
|
||||||
if existing is None or existing.get("skills") != want_skills:
|
if existing is None or existing.get("skills") != want_skills:
|
||||||
await store.replace_skills(agent_id, want_skills)
|
await store.replace_skills(agent_id, want_skills)
|
||||||
if (
|
if existing is None or existing.get("tool_groups") != want_tool_groups or existing.get("model") != want_model:
|
||||||
existing is None
|
|
||||||
or existing.get("tool_groups") != want_tool_groups
|
|
||||||
or existing.get("model") != want_model
|
|
||||||
):
|
|
||||||
await store.set_extras(agent_id, tool_groups=want_tool_groups, model=want_model)
|
await store.set_extras(agent_id, tool_groups=want_tool_groups, model=want_model)
|
||||||
|
|
||||||
|
|
||||||
@@ -739,9 +781,7 @@ async def _build_list_responses(
|
|||||||
SOUL.md is skipped (only the edit dialog reads it). An agent absent from
|
SOUL.md is skipped (only the edit dialog reads it). An agent absent from
|
||||||
the cache (never mirrored) keeps its ``None`` extras until the next sync.
|
the cache (never mirrored) keeps its ``None`` extras until the next sync.
|
||||||
"""
|
"""
|
||||||
responses = [
|
responses = [_record_to_response(a, include_soul=False, include_extras=False) for a in records]
|
||||||
_record_to_response(a, include_soul=False, include_extras=False) for a in records
|
|
||||||
]
|
|
||||||
ids = [r.id for r in responses]
|
ids = [r.id for r in responses]
|
||||||
extras = await store.get_extras_for(ids)
|
extras = await store.get_extras_for(ids)
|
||||||
assignments = await tag_store.list_assignments("agent", ids)
|
assignments = await tag_store.list_assignments("agent", ids)
|
||||||
@@ -794,11 +834,7 @@ async def list_agents(
|
|||||||
),
|
),
|
||||||
admin: bool = Query(
|
admin: bool = Query(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=("Admin-only override. When ``true`` and the caller is a system admin, every agent is returned (built-in + every user's agents + private-square entries) regardless of the usual ownership/visibility filter."),
|
||||||
"Admin-only override. When ``true`` and the caller is a system admin, "
|
|
||||||
"every agent is returned (built-in + every user's agents + private-square "
|
|
||||||
"entries) regardless of the usual ownership/visibility filter."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
) -> AgentsListResponse:
|
) -> AgentsListResponse:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
@@ -852,11 +888,7 @@ async def list_agents(
|
|||||||
if owner and owner.strip():
|
if owner and owner.strip():
|
||||||
responses = [r for r in responses if r.user_id == owner.strip()]
|
responses = [r for r in responses if r.user_id == owner.strip()]
|
||||||
if square_id:
|
if square_id:
|
||||||
responses = [
|
responses = [r for r in responses if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))]
|
||||||
r
|
|
||||||
for r in responses
|
|
||||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
|
||||||
]
|
|
||||||
responses = _sort_agent_responses(responses, sort)
|
responses = _sort_agent_responses(responses, sort)
|
||||||
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -881,11 +913,7 @@ async def check_agent_name(name: str) -> dict[str, Any]:
|
|||||||
"/agents/selector",
|
"/agents/selector",
|
||||||
response_model=AgentSelectorResponse,
|
response_model=AgentSelectorResponse,
|
||||||
summary="Agent Homepage Selector",
|
summary="Agent Homepage Selector",
|
||||||
description=(
|
description=("Return the admin-curated agent list shown on the new-chat homepage. Sorted by ``featured_order`` ascending. Visibility-filtered, so an agent the caller can't see (author un-published) is silently omitted."),
|
||||||
"Return the admin-curated agent list shown on the new-chat homepage. "
|
|
||||||
"Sorted by ``featured_order`` ascending. Visibility-filtered, so an "
|
|
||||||
"agent the caller can't see (author un-published) is silently omitted."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
@@ -897,12 +925,7 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
|||||||
records = await store.list_featured(user_id)
|
records = await store.list_featured(user_id)
|
||||||
# Selector dropdown only renders name/description/featured_order — same
|
# Selector dropdown only renders name/description/featured_order — same
|
||||||
# rationale as list_agents: skip soul + extras + self-heal.
|
# rationale as list_agents: skip soul + extras + self-heal.
|
||||||
return AgentSelectorResponse(
|
return AgentSelectorResponse(agents=[_record_to_response(r, include_soul=False, include_extras=False) for r in records])
|
||||||
agents=[
|
|
||||||
_record_to_response(r, include_soul=False, include_extras=False)
|
|
||||||
for r in records
|
|
||||||
]
|
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -961,6 +984,11 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
|||||||
|
|
||||||
if request.id:
|
if request.id:
|
||||||
agent_id = _validate_storage_id(request.id)
|
agent_id = _validate_storage_id(request.id)
|
||||||
|
if _is_strategy_agent_id(agent_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=("The 'strategy-selection-' id namespace is reserved; create strategy angle agents through /api/strategy-agents/angles"),
|
||||||
|
)
|
||||||
if await store.get_any(agent_id) is not None:
|
if await store.get_any(agent_id) is not None:
|
||||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||||
else:
|
else:
|
||||||
@@ -1026,14 +1054,18 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
|||||||
async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: Request) -> AgentResponse:
|
async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: Request) -> AgentResponse:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
agent_id = _validate_storage_id(agent_id)
|
agent_id = _validate_storage_id(agent_id)
|
||||||
|
if _is_strategy_agent_id(agent_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=("Strategy agent configuration must be changed through /api/strategy-agents/{agent_id}"),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(http_request)
|
store = get_agent_store(http_request)
|
||||||
user_id = _current_user_id(http_request)
|
user_id = _current_user_id(http_request)
|
||||||
is_admin = _is_admin_user(http_request)
|
is_admin = _is_admin_user(http_request)
|
||||||
current, edit_mode = await _get_editable_or_raise(
|
current, edit_mode = await _get_editable_or_raise(store, agent_id, user_id, is_admin=is_admin)
|
||||||
store, agent_id, user_id, is_admin=is_admin
|
_require_canonical_record_id(agent_id, current)
|
||||||
)
|
|
||||||
|
|
||||||
fields_set = request.model_fields_set
|
fields_set = request.model_fields_set
|
||||||
update_data: dict[str, Any] = {}
|
update_data: dict[str, Any] = {}
|
||||||
@@ -1061,7 +1093,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
next_description = update_data.get("description", current.get("description") or "")
|
next_description = update_data.get("description", current.get("description") or "")
|
||||||
|
|
||||||
existing_extras = _load_config_extras(agent_id)
|
existing_extras = _load_config_extras(agent_id)
|
||||||
if "skills" in fields_set and agent_id in _STRATEGY_SELECTION_AGENT_IDS:
|
if "skills" in fields_set and _is_strategy_agent_id(agent_id):
|
||||||
next_skills = await _validate_strategy_agent_skills(
|
next_skills = await _validate_strategy_agent_skills(
|
||||||
request.skills,
|
request.skills,
|
||||||
app_config=get_config(http_request),
|
app_config=get_config(http_request),
|
||||||
@@ -1069,16 +1101,18 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
||||||
next_model = request.model if "model" in fields_set else existing_extras["model"]
|
if "model" in fields_set and _is_strategy_agent_id(agent_id):
|
||||||
|
next_model = _validate_strategy_agent_model(
|
||||||
|
request.model,
|
||||||
|
app_config=get_config(http_request),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
next_model = request.model if "model" in fields_set else existing_extras["model"]
|
||||||
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
||||||
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
||||||
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
|
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
|
||||||
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
||||||
next_llmwiki_ids = (
|
next_llmwiki_ids = await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids) if "llmwiki_knowledge_base_ids" in fields_set else existing_extras["llmwiki_knowledge_base_ids"]
|
||||||
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
|
|
||||||
if "llmwiki_knowledge_base_ids" in fields_set
|
|
||||||
else existing_extras["llmwiki_knowledge_base_ids"]
|
|
||||||
)
|
|
||||||
|
|
||||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
|
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
|
||||||
if config_dirty:
|
if config_dirty:
|
||||||
@@ -1186,14 +1220,18 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
|
|||||||
async def delete_agent(agent_id: str, request: Request) -> None:
|
async def delete_agent(agent_id: str, request: Request) -> None:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
agent_id = _validate_storage_id(agent_id)
|
agent_id = _validate_storage_id(agent_id)
|
||||||
|
if _is_strategy_agent_id(agent_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=("Strategy agents cannot be deleted through the generic API; use /api/strategy-agents/{agent_id} to disable an angle"),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
is_admin = _is_admin_user(request)
|
is_admin = _is_admin_user(request)
|
||||||
_, edit_mode = await _get_editable_or_raise(
|
record, edit_mode = await _get_editable_or_raise(store, agent_id, user_id, is_admin=is_admin)
|
||||||
store, agent_id, user_id, is_admin=is_admin
|
_require_canonical_record_id(agent_id, record)
|
||||||
)
|
|
||||||
_ensure_strategy_agent_deletable(agent_id)
|
_ensure_strategy_agent_deletable(agent_id)
|
||||||
|
|
||||||
if edit_mode == "admin":
|
if edit_mode == "admin":
|
||||||
@@ -1498,11 +1536,7 @@ class AgentFeaturedReorderRequest(BaseModel):
|
|||||||
|
|
||||||
agent_ids: list[str] = Field(
|
agent_ids: list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description=(
|
description=("Featured agent ids in the desired top→bottom display order. Ids not currently featured are silently skipped, so the client can send the current visible order without first reconciling with the server."),
|
||||||
"Featured agent ids in the desired top→bottom display order. Ids not "
|
|
||||||
"currently featured are silently skipped, so the client can send the "
|
|
||||||
"current visible order without first reconciling with the server."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1510,11 +1544,7 @@ class AgentFeaturedReorderRequest(BaseModel):
|
|||||||
"/agents/featured/order",
|
"/agents/featured/order",
|
||||||
status_code=204,
|
status_code=204,
|
||||||
summary="Reorder Featured Agents (admin)",
|
summary="Reorder Featured Agents (admin)",
|
||||||
description=(
|
description=("Admin-only. Bulk-assign ``featured_order`` to each id in the order received (``0`` is topmost). After this, the homepage selector renders agents in exactly the supplied order. Ids not already featured are ignored."),
|
||||||
"Admin-only. Bulk-assign ``featured_order`` to each id in the order received "
|
|
||||||
"(``0`` is topmost). After this, the homepage selector renders agents in "
|
|
||||||
"exactly the supplied order. Ids not already featured are ignored."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
async def reorder_featured_agents(
|
async def reorder_featured_agents(
|
||||||
body: AgentFeaturedReorderRequest,
|
body: AgentFeaturedReorderRequest,
|
||||||
|
|||||||
@@ -68,10 +68,14 @@ class TaskUpdateRequest(BaseModel):
|
|||||||
brief: str | None = None
|
brief: str | None = None
|
||||||
agents: list[TaskAgentSchema] | None = None
|
agents: list[TaskAgentSchema] | None = None
|
||||||
messages: list[dict[str, Any]] | None = None
|
messages: list[dict[str, Any]] | None = None
|
||||||
|
# Optional optimistic-concurrency token. Legacy callers may omit it; the
|
||||||
|
# strategy-project workbench always sends the revision it loaded/saved.
|
||||||
|
expected_updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskEnsureRequest(BaseModel):
|
class TaskEnsureRequest(BaseModel):
|
||||||
"""get-or-create:宿主传的 taskId 不固定,新 id 直接**初始化**一条空记录返回(不 404)。"""
|
"""get-or-create:宿主传的 taskId 不固定,新 id 直接**初始化**一条空记录返回(不 404)。"""
|
||||||
|
|
||||||
id: str = Field(min_length=1, max_length=64)
|
id: str = Field(min_length=1, max_length=64)
|
||||||
title: str = Field(default="", max_length=512)
|
title: str = Field(default="", max_length=512)
|
||||||
brief: str | None = None
|
brief: str | None = None
|
||||||
@@ -162,12 +166,24 @@ async def update_task(request: Request, task_id: str, body: TaskUpdateRequest) -
|
|||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
expected_updated_at = fields.pop("expected_updated_at", None)
|
||||||
# Normalize agents (pydantic models) to plain dicts for JSON storage.
|
# Normalize agents (pydantic models) to plain dicts for JSON storage.
|
||||||
if "agents" in fields and fields["agents"] is not None:
|
if "agents" in fields and fields["agents"] is not None:
|
||||||
fields["agents"] = [a if isinstance(a, dict) else a.model_dump() for a in body.agents or []]
|
fields["agents"] = [a if isinstance(a, dict) else a.model_dump() for a in body.agents or []]
|
||||||
row = await store.update_task(task_id, user_id, **fields)
|
row = await store.update_task(
|
||||||
|
task_id,
|
||||||
|
user_id,
|
||||||
|
expected_updated_at=expected_updated_at,
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
existing = await store.get_task(task_id, user_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="项目已在其他页面更新,请选择恢复本地草稿或使用服务器版本",
|
||||||
|
)
|
||||||
return TaskResponse(**row)
|
return TaskResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ from fastapi.responses import StreamingResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.roundtable_model_fallback import is_llm_error_message, reason_text
|
from app.gateway.roundtable_model_fallback import is_llm_error_message, reason_text
|
||||||
|
from app.gateway.routers import agents as agents_router
|
||||||
|
from app.gateway.routers._strategy_selection_seed import STRATEGY_AGENT_ID_PREFIX
|
||||||
from app.gateway.routers.multi_agent import (
|
from app.gateway.routers.multi_agent import (
|
||||||
_ROUNDTABLE_THREAD_METADATA,
|
_ROUNDTABLE_THREAD_METADATA,
|
||||||
_auth_headers,
|
_auth_headers,
|
||||||
_create_thread,
|
_create_thread,
|
||||||
_flatten_content,
|
|
||||||
_loopback_base,
|
_loopback_base,
|
||||||
_make_loopback_client,
|
_make_loopback_client,
|
||||||
_parse_last_messages,
|
_parse_last_messages,
|
||||||
@@ -54,6 +55,7 @@ from app.gateway.routers.multi_agent import (
|
|||||||
)
|
)
|
||||||
from deerflow.agents.roundtable_orchestrator.delivery import visible_seat_text
|
from deerflow.agents.roundtable_orchestrator.delivery import visible_seat_text
|
||||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||||
|
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
|
router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
|
||||||
@@ -72,6 +74,7 @@ def _has_substantive_content(content: str) -> bool:
|
|||||||
visible = visible_seat_text(content)
|
visible = visible_seat_text(content)
|
||||||
return sum(character.isalnum() for character in visible) >= MIN_MEANINGFUL_CONTENT_CHARS
|
return sum(character.isalnum() for character in visible) >= MIN_MEANINGFUL_CONTENT_CHARS
|
||||||
|
|
||||||
|
|
||||||
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
|
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
|
||||||
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
|
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
|
||||||
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
|
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
|
||||||
@@ -80,10 +83,10 @@ def _has_substantive_content(content: str) -> bool:
|
|||||||
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
|
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
|
||||||
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
|
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
|
||||||
_EXCLUDED_TOOLS: list[str] = [
|
_EXCLUDED_TOOLS: list[str] = [
|
||||||
"write_file", # 并行不互相写文件打架
|
"write_file", # 并行不互相写文件打架
|
||||||
"str_replace",
|
"str_replace",
|
||||||
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
|
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
|
||||||
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
|
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -119,25 +122,126 @@ async def issue_token(body: _TokenRequest) -> _TokenResponse:
|
|||||||
# ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
|
# ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _extract_ai_text(chunk: dict[str, Any]) -> str:
|
def _extract_ai_stream_parts(chunk: dict[str, Any]) -> tuple[str, str]:
|
||||||
"""AI text delta from a ``messages-tuple`` chunk obj; "" when not AI text."""
|
"""Return ``(answer, reasoning)`` deltas from one AI message chunk.
|
||||||
|
|
||||||
|
OpenAI-compatible providers do not agree on where displayable reasoning
|
||||||
|
lives. Most put it in ``additional_kwargs.reasoning_content`` (or the
|
||||||
|
legacy ``reasoning`` key), while a few return typed content blocks. Keep
|
||||||
|
that channel separate from the answer so the strategy workbench can show
|
||||||
|
progress without mixing internal trace text into the generated strategy.
|
||||||
|
Literal ``<think>`` blocks in the normal text channel are handled by the
|
||||||
|
per-agent :class:`InlineThinkTagFilter` in ``_consume_agent`` because tags
|
||||||
|
may be split across arbitrary SSE chunks.
|
||||||
|
"""
|
||||||
type_ = str(chunk.get("type") or "").lower()
|
type_ = str(chunk.get("type") or "").lower()
|
||||||
if type_ not in ("ai", "aimessagechunk", "aimessage"):
|
if type_ not in ("ai", "aimessagechunk", "aimessage"):
|
||||||
return ""
|
return "", ""
|
||||||
content = chunk.get("content")
|
content = chunk.get("content")
|
||||||
|
answer_parts: list[str] = []
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return content
|
answer_parts.append(content)
|
||||||
if isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
out: list[str] = []
|
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
out.append(item)
|
answer_parts.append(item)
|
||||||
elif isinstance(item, dict):
|
elif isinstance(item, dict):
|
||||||
t = item.get("text")
|
block_type = str(item.get("type") or "").lower()
|
||||||
if isinstance(t, str):
|
value = item.get("thinking")
|
||||||
out.append(t)
|
if not isinstance(value, str):
|
||||||
return "".join(out)
|
value = item.get("text")
|
||||||
return ""
|
if not isinstance(value, str):
|
||||||
|
value = item.get("content")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
if block_type in {
|
||||||
|
"thinking",
|
||||||
|
"thinking_delta",
|
||||||
|
"reasoning",
|
||||||
|
"reasoning_delta",
|
||||||
|
}:
|
||||||
|
reasoning_parts.append(value)
|
||||||
|
else:
|
||||||
|
answer_parts.append(value)
|
||||||
|
|
||||||
|
additional_kwargs = chunk.get("additional_kwargs")
|
||||||
|
direct_reasoning = chunk.get("reasoning_content") or chunk.get("reasoning")
|
||||||
|
nested_reasoning: object = None
|
||||||
|
if isinstance(additional_kwargs, dict):
|
||||||
|
nested_reasoning = additional_kwargs.get("reasoning_content") or additional_kwargs.get("reasoning")
|
||||||
|
raw_reasoning = nested_reasoning or direct_reasoning
|
||||||
|
if isinstance(raw_reasoning, str):
|
||||||
|
reasoning_parts.append(raw_reasoning)
|
||||||
|
|
||||||
|
return "".join(answer_parts), "".join(reasoning_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ai_text(chunk: dict[str, Any]) -> str:
|
||||||
|
"""Compatibility helper returning only the ordinary AI text delta."""
|
||||||
|
return _extract_ai_stream_parts(chunk)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_final_inline_reasoning(content: str) -> tuple[str, str]:
|
||||||
|
"""Split complete inline thinking, including template-injected close tags.
|
||||||
|
|
||||||
|
Some reasoning chat templates insert ``<think>`` before generation, so the
|
||||||
|
provider returns only ``reasoning</think>answer``. The streaming filter
|
||||||
|
cannot classify that prefix before the closing marker arrives, but the
|
||||||
|
terminal values frame can. Returning the recovered trace here also lets
|
||||||
|
the client correct any transient streaming classification.
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
working = content
|
||||||
|
prefix_reasoning = ""
|
||||||
|
lowered = working.lower()
|
||||||
|
first_open = lowered.find("<think>")
|
||||||
|
first_close = lowered.find("</think>")
|
||||||
|
if first_close >= 0 and (first_open < 0 or first_close < first_open):
|
||||||
|
prefix_reasoning = working[:first_close]
|
||||||
|
working = working[first_close + len("</think>") :]
|
||||||
|
|
||||||
|
text_filter = InlineThinkTagFilter()
|
||||||
|
visible, reasoning = text_filter.push_parts(working)
|
||||||
|
visible_tail, reasoning_tail = text_filter.finish_parts()
|
||||||
|
final_reasoning = "".join(part for part in (prefix_reasoning, reasoning, reasoning_tail) if part)
|
||||||
|
# ``visible_seat_text`` remains the canonical complete-output cleanup for
|
||||||
|
# malformed/multiple tags; the filter-derived visible value is only a
|
||||||
|
# fallback for an otherwise empty canonical result.
|
||||||
|
final_visible = visible_seat_text(content) or f"{visible}{visible_tail}".strip()
|
||||||
|
return final_visible, final_reasoning.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _should_probe_implicit_reasoning(
|
||||||
|
request: Request,
|
||||||
|
agent_id: str,
|
||||||
|
requested_model: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Enable close-only probing only for a thinking-capable effective model.
|
||||||
|
|
||||||
|
Ordinary models must stream untagged answer chunks immediately. The
|
||||||
|
stricter prelude buffer is reserved for providers that advertise thinking
|
||||||
|
support and may use templates where ``<think>`` is injected outside the
|
||||||
|
returned content while ``</think>`` remains in-band.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config = getattr(request.app.state, "config", None)
|
||||||
|
models = list(getattr(config, "models", None) or [])
|
||||||
|
effective_name = requested_model.strip()
|
||||||
|
if not effective_name:
|
||||||
|
try:
|
||||||
|
effective_name = str(agents_router._load_config_extras(agent_id).get("model") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
effective_name = ""
|
||||||
|
if not effective_name and models:
|
||||||
|
effective_name = str(getattr(models[0], "name", "") or "")
|
||||||
|
selected = next(
|
||||||
|
(entry for entry in models if str(getattr(entry, "name", "") or "") == effective_name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return bool(selected and getattr(selected, "supports_thinking", False))
|
||||||
|
|
||||||
|
|
||||||
def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None:
|
def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None:
|
||||||
@@ -248,7 +352,9 @@ async def _run_one_agent(
|
|||||||
failure_reason = is_llm_error_message(last_ai)
|
failure_reason = is_llm_error_message(last_ai)
|
||||||
if failure_reason:
|
if failure_reason:
|
||||||
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
|
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
|
||||||
content = _flatten_content(last_ai.get("content", ""))
|
answer_channel, structured_reasoning = _extract_ai_stream_parts(last_ai)
|
||||||
|
content, inline_reasoning = _split_final_inline_reasoning(answer_channel)
|
||||||
|
reasoning = "\n\n".join(part.strip() for part in (structured_reasoning, inline_reasoning) if part.strip())
|
||||||
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
|
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
|
||||||
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
|
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
|
||||||
final_tool_calls: list[dict[str, Any]] = []
|
final_tool_calls: list[dict[str, Any]] = []
|
||||||
@@ -263,6 +369,7 @@ async def _run_one_agent(
|
|||||||
yield {
|
yield {
|
||||||
"type": "clarification",
|
"type": "clarification",
|
||||||
"content": content,
|
"content": content,
|
||||||
|
"reasoning": reasoning,
|
||||||
"clarification": clarification,
|
"clarification": clarification,
|
||||||
"tool_calls": final_tool_calls,
|
"tool_calls": final_tool_calls,
|
||||||
}
|
}
|
||||||
@@ -272,6 +379,7 @@ async def _run_one_agent(
|
|||||||
yield {
|
yield {
|
||||||
"status": f"子智能体 {agent_id} 完成",
|
"status": f"子智能体 {agent_id} 完成",
|
||||||
"content": content,
|
"content": content,
|
||||||
|
"reasoning": reasoning,
|
||||||
"agent_name": agent_id,
|
"agent_name": agent_id,
|
||||||
"tool_calls": final_tool_calls,
|
"tool_calls": final_tool_calls,
|
||||||
}
|
}
|
||||||
@@ -283,10 +391,7 @@ def _build_task_message(task_name: str, brief: str) -> str:
|
|||||||
parts.append(f"任务名称:{task_name.strip()}")
|
parts.append(f"任务名称:{task_name.strip()}")
|
||||||
if brief.strip():
|
if brief.strip():
|
||||||
parts.append(f"任务简介:\n{brief.strip()}")
|
parts.append(f"任务简介:\n{brief.strip()}")
|
||||||
parts.append(
|
parts.append("请你从你的专业职责出发,针对以上任务给出你的分析与交付。本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。")
|
||||||
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
|
|
||||||
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。"
|
|
||||||
)
|
|
||||||
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
|
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
|
||||||
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
|
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
|
||||||
parts.append(
|
parts.append(
|
||||||
@@ -329,6 +434,41 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"并行智能体数量上限 {MAX_PARALLEL_AGENTS}(当前 {len(agent_ids)})",
|
detail=f"并行智能体数量上限 {MAX_PARALLEL_AGENTS}(当前 {len(agent_ids)})",
|
||||||
)
|
)
|
||||||
|
role_store = getattr(request.app.state, "strategy_agent_role_store", None)
|
||||||
|
agent_store = getattr(request.app.state, "agent_store", None)
|
||||||
|
strategy_ids = [agent_id for agent_id in agent_ids if agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold())]
|
||||||
|
if strategy_ids and role_store is not None and agent_store is not None:
|
||||||
|
# Every worker owns an independent memory store. Synchronize from
|
||||||
|
# durable folders/sidecars immediately before execution so a request
|
||||||
|
# routed to another worker cannot see random 404s or revived angles.
|
||||||
|
from app.gateway.routers.strategy_agents import _ensure_registry
|
||||||
|
|
||||||
|
await _ensure_registry(agent_store, role_store)
|
||||||
|
for agent_id in agent_ids:
|
||||||
|
if not agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold()):
|
||||||
|
continue
|
||||||
|
if role_store is None or agent_store is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="策略智能体分类或配置服务不可用",
|
||||||
|
)
|
||||||
|
role = await role_store.get_role(agent_id)
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"未注册的策略智能体:{agent_id}",
|
||||||
|
)
|
||||||
|
if not bool(role.get("enabled")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"策略智能体已停用:{agent_id}",
|
||||||
|
)
|
||||||
|
record = await agent_store.get_any(agent_id)
|
||||||
|
if record is None or str(record.get("id") or "") != agent_id or record.get("user_id") is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"策略智能体必须由系统全局持有:{agent_id}",
|
||||||
|
)
|
||||||
if not task_name and not brief:
|
if not task_name and not brief:
|
||||||
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
|
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
|
||||||
|
|
||||||
@@ -346,8 +486,54 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重)
|
emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重)
|
||||||
tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积
|
tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积
|
||||||
buffer: list[str] = []
|
buffer: list[str] = []
|
||||||
|
raw_answer_buffer: list[str] = []
|
||||||
|
probe_implicit_close = await asyncio.to_thread(
|
||||||
|
_should_probe_implicit_reasoning,
|
||||||
|
request,
|
||||||
|
agent_id,
|
||||||
|
model_name,
|
||||||
|
)
|
||||||
|
inline_thinking = InlineThinkTagFilter(allow_implicit_close=probe_implicit_close)
|
||||||
|
reasoning_emitted = False
|
||||||
done_sent = False
|
done_sent = False
|
||||||
|
|
||||||
|
async def _emit_text_parts(text: str, provider_reasoning: str = "") -> None:
|
||||||
|
nonlocal reasoning_emitted
|
||||||
|
if text:
|
||||||
|
raw_answer_buffer.append(text)
|
||||||
|
visible_prelude = inline_thinking.confirm_visible_channel() if provider_reasoning else ""
|
||||||
|
visible, inline_reasoning = inline_thinking.push_parts(text)
|
||||||
|
reasoning = f"{provider_reasoning}{inline_reasoning}"
|
||||||
|
if reasoning:
|
||||||
|
reasoning_emitted = True
|
||||||
|
await queue.put(
|
||||||
|
{
|
||||||
|
"type": "reasoning_delta",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"text": reasoning,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
confirmed_visible = f"{visible_prelude}{visible}"
|
||||||
|
if confirmed_visible:
|
||||||
|
buffer.append(confirmed_visible)
|
||||||
|
await queue.put({"type": "delta", "agent_id": agent_id, "text": confirmed_visible})
|
||||||
|
|
||||||
|
async def _flush_text_filter() -> None:
|
||||||
|
nonlocal reasoning_emitted
|
||||||
|
visible, reasoning = inline_thinking.finish_parts()
|
||||||
|
if reasoning:
|
||||||
|
reasoning_emitted = True
|
||||||
|
await queue.put(
|
||||||
|
{
|
||||||
|
"type": "reasoning_delta",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"text": reasoning,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if visible:
|
||||||
|
buffer.append(visible)
|
||||||
|
await queue.put({"type": "delta", "agent_id": agent_id, "text": visible})
|
||||||
|
|
||||||
async def _emit_tool(tc: Any, allow_empty: bool = False) -> None:
|
async def _emit_tool(tc: Any, allow_empty: bool = False) -> None:
|
||||||
"""一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。
|
"""一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。
|
||||||
|
|
||||||
@@ -369,9 +555,7 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
if key_id in emitted_tools:
|
if key_id in emitted_tools:
|
||||||
return
|
return
|
||||||
emitted_tools.add(key_id)
|
emitted_tools.add(key_id)
|
||||||
await queue.put(
|
await queue.put({"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args})
|
||||||
{"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||||
@@ -381,22 +565,32 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
async for chunk in gen:
|
async for chunk in gen:
|
||||||
if isinstance(chunk, dict):
|
if isinstance(chunk, dict):
|
||||||
# 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
|
# 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
|
||||||
|
await _flush_text_filter()
|
||||||
for tc in chunk.get("tool_calls") or []:
|
for tc in chunk.get("tool_calls") or []:
|
||||||
await _emit_tool(tc, allow_empty=True)
|
await _emit_tool(tc, allow_empty=True)
|
||||||
|
final_content = visible_seat_text(str(chunk.get("content") or ""))
|
||||||
|
final_reasoning = str(chunk.get("reasoning") or "")
|
||||||
|
if final_reasoning and not reasoning_emitted:
|
||||||
|
reasoning_emitted = True
|
||||||
|
await queue.put(
|
||||||
|
{
|
||||||
|
"type": "reasoning_delta",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"text": final_reasoning,
|
||||||
|
}
|
||||||
|
)
|
||||||
if chunk.get("type") == "clarification":
|
if chunk.get("type") == "clarification":
|
||||||
clar = chunk.get("clarification") or {}
|
clar = chunk.get("clarification") or {}
|
||||||
await queue.put(
|
await queue.put(
|
||||||
{
|
{
|
||||||
"type": "agent_clarification",
|
"type": "agent_clarification",
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"content": str(chunk.get("content") or ""),
|
"content": final_content,
|
||||||
**clar,
|
**clar,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await queue.put(
|
await queue.put({"type": "agent_done", "agent_id": agent_id, "content": final_content})
|
||||||
{"type": "agent_done", "agent_id": agent_id, "content": str(chunk.get("content") or "")}
|
|
||||||
)
|
|
||||||
done_sent = True
|
done_sent = True
|
||||||
continue
|
continue
|
||||||
line = chunk.strip() if isinstance(chunk, str) else ""
|
line = chunk.strip() if isinstance(chunk, str) else ""
|
||||||
@@ -415,10 +609,9 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
if last_event == "messages" and isinstance(parsed, list) and parsed:
|
if last_event == "messages" and isinstance(parsed, list) and parsed:
|
||||||
obj = parsed[0]
|
obj = parsed[0]
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
text = _extract_ai_text(obj)
|
text, reasoning = _extract_ai_stream_parts(obj)
|
||||||
if text:
|
if text or reasoning:
|
||||||
buffer.append(text)
|
await _emit_text_parts(text, reasoning)
|
||||||
await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
|
|
||||||
# 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。
|
# 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。
|
||||||
for tc in obj.get("tool_calls") or []:
|
for tc in obj.get("tool_calls") or []:
|
||||||
await _emit_tool(tc)
|
await _emit_tool(tc)
|
||||||
@@ -427,18 +620,24 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
|||||||
await _emit_tool(tc)
|
await _emit_tool(tc)
|
||||||
if not done_sent:
|
if not done_sent:
|
||||||
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
|
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
|
||||||
buffered_content = "".join(buffer)
|
await _flush_text_filter()
|
||||||
failure_reason = is_llm_error_message(
|
buffered_content, recovered_reasoning = _split_final_inline_reasoning("".join(raw_answer_buffer))
|
||||||
{"type": "ai", "content": buffered_content}
|
if not buffered_content:
|
||||||
)
|
buffered_content = "".join(buffer)
|
||||||
|
if recovered_reasoning and not reasoning_emitted:
|
||||||
|
reasoning_emitted = True
|
||||||
|
await queue.put(
|
||||||
|
{
|
||||||
|
"type": "reasoning_delta",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"text": recovered_reasoning,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
failure_reason = is_llm_error_message({"type": "ai", "content": buffered_content})
|
||||||
if failure_reason:
|
if failure_reason:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
|
||||||
f"模型调用失败:{reason_text(failure_reason)}"
|
|
||||||
)
|
|
||||||
if not _has_substantive_content(buffered_content):
|
if not _has_substantive_content(buffered_content):
|
||||||
raise RuntimeError(
|
raise RuntimeError("智能体未生成有效正文,请重试或更换全局模型")
|
||||||
"智能体未生成有效正文,请重试或更换全局模型"
|
|
||||||
)
|
|
||||||
await queue.put(
|
await queue.put(
|
||||||
{
|
{
|
||||||
"type": "agent_done",
|
"type": "agent_done",
|
||||||
|
|||||||
@@ -0,0 +1,802 @@
|
|||||||
|
"""Global strategy-selection agent role management.
|
||||||
|
|
||||||
|
The strategy workbench owns three fixed roles (intent, judge and solution)
|
||||||
|
plus an administrator-managed set of angle roles. Role metadata is stored in
|
||||||
|
``strategy_agent_roles`` while model/SOUL/skills remain in the normal
|
||||||
|
folder-backed agent configuration, so the same configuration is shared by
|
||||||
|
every project and every user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import weakref
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||||
|
from app.gateway.deps import (
|
||||||
|
get_agent_store,
|
||||||
|
get_config,
|
||||||
|
get_current_user_from_request,
|
||||||
|
get_skill_store,
|
||||||
|
get_strategy_agent_role_store,
|
||||||
|
)
|
||||||
|
from app.gateway.routers import agents as agents_router
|
||||||
|
from app.gateway.routers._strategy_selection_seed import (
|
||||||
|
STRATEGY_ANGLE_AGENT_PREFIX,
|
||||||
|
default_strategy_agent_roles,
|
||||||
|
ensure_strategy_selection_agents,
|
||||||
|
)
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.persistence.agents import AgentStore
|
||||||
|
from deerflow.persistence.strategy_agent_roles import (
|
||||||
|
MemoryStrategyAgentRoleStore,
|
||||||
|
StrategyAgentRoleStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/strategy-agents", tags=["strategy-agents"])
|
||||||
|
|
||||||
|
StrategyAgentCategory = Literal["intent", "angle", "judge", "solution"]
|
||||||
|
_DYNAMIC_ANGLE_TOOL_GROUPS = ["web", "file:read"]
|
||||||
|
_ROLE_SIDECAR = ".strategy-role.yaml"
|
||||||
|
_PROCESS_LOCKS: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
|
||||||
|
|
||||||
|
|
||||||
|
def _process_lock(agent_id: str) -> asyncio.Lock:
|
||||||
|
key = agent_id.casefold()
|
||||||
|
lock = _PROCESS_LOCKS.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_PROCESS_LOCKS[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _open_cross_process_lock(agent_id: str):
|
||||||
|
lock_dir = get_paths().agents_dir / ".strategy-locks"
|
||||||
|
lock_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = lock_dir / f"{agent_id}.lock"
|
||||||
|
handle = open(path, "a+b") # noqa: SIM115 - owned by async context below
|
||||||
|
if path.stat().st_size == 0:
|
||||||
|
handle.write(b"\0")
|
||||||
|
handle.flush()
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def _try_cross_process_lock(handle) -> bool:
|
||||||
|
handle.seek(0)
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_cross_process_lock(handle) -> None:
|
||||||
|
try:
|
||||||
|
handle.seek(0)
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _strategy_mutation_guard(agent_id: str):
|
||||||
|
"""Serialize one strategy agent across coroutines and worker processes.
|
||||||
|
|
||||||
|
The zero-byte lock-file namespace is durable, but OS locks are tied to the
|
||||||
|
open handle and are automatically released if a worker dies. The local
|
||||||
|
lock table uses weak values so one-off dynamic ids do not leak forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with _process_lock(agent_id):
|
||||||
|
handle = _open_cross_process_lock(agent_id)
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
while not acquired:
|
||||||
|
acquired = _try_cross_process_lock(handle)
|
||||||
|
if not acquired:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
# Unlock synchronously: this is a tiny OS call and must not be
|
||||||
|
# skipped if the request is already being cancelled.
|
||||||
|
_release_cross_process_lock(handle)
|
||||||
|
else:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_role_sidecar(agent_id: str, role: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": bool(role.get("enabled", True)),
|
||||||
|
"sort_order": int(role.get("sort_order", 0)),
|
||||||
|
}
|
||||||
|
agents_router._atomic_write_text(
|
||||||
|
get_paths().agent_dir(agent_id) / _ROLE_SIDECAR,
|
||||||
|
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_optional_text(path) -> str | None:
|
||||||
|
return path.read_text(encoding="utf-8") if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_optional_text(path, content: str | None) -> None:
|
||||||
|
if content is None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
agents_router._atomic_write_text(path, content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _rollback_strategy_update(
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
agent_store: AgentStore,
|
||||||
|
role_store: StrategyAgentRoleStore,
|
||||||
|
record: dict,
|
||||||
|
role: dict,
|
||||||
|
extras: dict,
|
||||||
|
file_snapshot: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort compensation for the mixed DB/filesystem update."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_restore_optional_text,
|
||||||
|
agent_dir / "config.yaml",
|
||||||
|
file_snapshot["config"],
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_restore_optional_text,
|
||||||
|
agent_dir / "SOUL.md",
|
||||||
|
file_snapshot["soul"],
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_restore_optional_text,
|
||||||
|
agent_dir / _ROLE_SIDECAR,
|
||||||
|
file_snapshot["sidecar"],
|
||||||
|
)
|
||||||
|
await agent_store.update_any(
|
||||||
|
agent_id,
|
||||||
|
{
|
||||||
|
"name": record["name"],
|
||||||
|
"description": record.get("description") or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await agent_store.replace_skills(agent_id, extras["skills"])
|
||||||
|
await agent_store.set_extras(
|
||||||
|
agent_id,
|
||||||
|
tool_groups=extras["tool_groups"],
|
||||||
|
model=extras["model"],
|
||||||
|
)
|
||||||
|
await role_store.update_role(
|
||||||
|
agent_id,
|
||||||
|
enabled=bool(role["enabled"]),
|
||||||
|
sort_order=int(role["sort_order"]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to compensate strategy-agent update for '%s'", agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _recover_memory_angle_roles(role_store: StrategyAgentRoleStore) -> None:
|
||||||
|
"""Recover dynamic role metadata after a memory-mode process restart."""
|
||||||
|
|
||||||
|
if not isinstance(role_store, MemoryStrategyAgentRoleStore):
|
||||||
|
return
|
||||||
|
agents_dir = get_paths().agents_dir
|
||||||
|
if not agents_dir.is_dir():
|
||||||
|
return
|
||||||
|
for agent_dir in agents_dir.iterdir():
|
||||||
|
sidecar = agent_dir / _ROLE_SIDECAR
|
||||||
|
if not sidecar.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(sidecar.read_text(encoding="utf-8")) or {}
|
||||||
|
agent_id = str(raw.get("agent_id") or "")
|
||||||
|
if agent_id != agent_dir.name or not agent_id.startswith(STRATEGY_ANGLE_AGENT_PREFIX) or raw.get("category") != "angle" or bool(raw.get("fixed", False)):
|
||||||
|
raise ValueError("invalid dynamic angle sidecar")
|
||||||
|
existing = await role_store.get_role(agent_id)
|
||||||
|
if existing is None:
|
||||||
|
await role_store.create_role(
|
||||||
|
{
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": bool(raw.get("enabled", True)),
|
||||||
|
"sort_order": int(raw.get("sort_order", 0)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif not bool(existing.get("fixed")) and existing.get("category") == "angle":
|
||||||
|
# Memory stores are rebuilt with code defaults first. The
|
||||||
|
# sidecar is the durable authority for every non-fixed angle,
|
||||||
|
# including the six shipped angles, so disabled defaults do
|
||||||
|
# not silently revive after a worker restart.
|
||||||
|
await role_store.update_role(
|
||||||
|
agent_id,
|
||||||
|
enabled=bool(raw.get("enabled", True)),
|
||||||
|
sort_order=int(raw.get("sort_order", 0)),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Ignoring invalid strategy role sidecar at %s", sidecar)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_angle_soul(name: str, description: str) -> str:
|
||||||
|
focus = description.strip() or "围绕任务目标,从本角度给出独立、可执行的策略建议"
|
||||||
|
return (
|
||||||
|
f"# 角色\n\n你是“{name}”,负责{focus}。\n\n"
|
||||||
|
"# 工作要求\n\n"
|
||||||
|
"1. 先核对任务目标、约束、前置条件和时间范围;信息不足时明确假设。\n"
|
||||||
|
"2. 只从自己的专业角度分析,不替代其他角度,也不冒充评判或方案整理角色。\n"
|
||||||
|
"3. 输出具体策略、实施步骤、预期收益、主要风险、触发条件和退出条件。\n"
|
||||||
|
"4. 对时效性事实优先检索核验并标注依据;不得编造来源。\n"
|
||||||
|
"5. 遵守法律、安全与非升级边界,最终交付使用清晰的 Markdown。\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAgentItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
category: StrategyAgentCategory
|
||||||
|
fixed: bool
|
||||||
|
enabled: bool
|
||||||
|
sort_order: int
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
model: str | None = None
|
||||||
|
soul: str | None = None
|
||||||
|
skills: list[str] | None = None
|
||||||
|
created_at: datetime | str | None = None
|
||||||
|
updated_at: datetime | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAgentListResponse(BaseModel):
|
||||||
|
agents: list[StrategyAgentItem]
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAngleCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=2000)
|
||||||
|
soul: str = Field(default="", max_length=200_000)
|
||||||
|
model: str | None = Field(default=None, max_length=200)
|
||||||
|
skills: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAgentUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, max_length=100)
|
||||||
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
|
soul: str | None = Field(default=None, max_length=200_000)
|
||||||
|
model: str | None = Field(default=None, max_length=200)
|
||||||
|
skills: list[str] | None = Field(default=None, max_length=100)
|
||||||
|
enabled: bool | None = None
|
||||||
|
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_admin(request: Request) -> None:
|
||||||
|
"""Require an administrator, except in explicit auth-disabled mode."""
|
||||||
|
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is None:
|
||||||
|
if is_auth_disabled():
|
||||||
|
return
|
||||||
|
user = await get_current_user_from_request(request)
|
||||||
|
if getattr(user, "system_role", None) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="策略智能体管理仅限管理员")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_model(request: Request, raw_model: str | None) -> str | None:
|
||||||
|
return agents_router._validate_strategy_agent_model(
|
||||||
|
raw_model,
|
||||||
|
app_config=get_config(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_registry(
|
||||||
|
agent_store: AgentStore,
|
||||||
|
role_store: StrategyAgentRoleStore,
|
||||||
|
) -> None:
|
||||||
|
"""Seed missing role metadata and repair missing default agent rows."""
|
||||||
|
|
||||||
|
defaults = default_strategy_agent_roles()
|
||||||
|
await role_store.ensure_defaults(defaults)
|
||||||
|
await _recover_memory_angle_roles(role_store)
|
||||||
|
default_ids = [str(row["agent_id"]) for row in defaults]
|
||||||
|
for agent_id in default_ids:
|
||||||
|
existing = await agent_store.get_any(agent_id)
|
||||||
|
if existing is not None and existing.get("user_id") is not None:
|
||||||
|
# Never let legacy sync's ensure_builtin semantics silently treat a
|
||||||
|
# user-owned collision as one of the fixed global roles.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Reserved strategy agent '{agent_id}' is owned by a user",
|
||||||
|
)
|
||||||
|
|
||||||
|
roles = await role_store.list_roles(include_disabled=True)
|
||||||
|
missing = [str(role["agent_id"]) for role in roles if await agent_store.get_any(str(role["agent_id"])) is None]
|
||||||
|
if missing:
|
||||||
|
# This path is normally handled by lifespan. It also rebuilds the
|
||||||
|
# memory-mode agent table from durable dynamic-angle folders.
|
||||||
|
await asyncio.to_thread(ensure_strategy_selection_agents)
|
||||||
|
await agents_router._sync_legacy_agents(agent_store)
|
||||||
|
|
||||||
|
for role in roles:
|
||||||
|
agent_id = str(role["agent_id"])
|
||||||
|
record = await agent_store.get_any(agent_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"Strategy agent '{agent_id}' configuration is unavailable",
|
||||||
|
)
|
||||||
|
if str(record.get("id") or "") != agent_id or record.get("user_id") is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Strategy agent '{agent_id}' must be a canonical global agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_strategy_agent_registry(app) -> None:
|
||||||
|
"""Synchronize one worker's registry, including memory-mode sidecars."""
|
||||||
|
|
||||||
|
agent_store = getattr(app.state, "agent_store", None)
|
||||||
|
role_store = getattr(app.state, "strategy_agent_role_store", None)
|
||||||
|
if agent_store is None or role_store is None:
|
||||||
|
raise RuntimeError("Strategy agent persistence is unavailable")
|
||||||
|
await _ensure_registry(agent_store, role_store)
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_item(
|
||||||
|
role: dict,
|
||||||
|
record: dict,
|
||||||
|
*,
|
||||||
|
include_config: bool,
|
||||||
|
) -> StrategyAgentItem:
|
||||||
|
if include_config:
|
||||||
|
agent = await agents_router._record_to_response_async(
|
||||||
|
record,
|
||||||
|
include_soul=True,
|
||||||
|
include_extras=True,
|
||||||
|
)
|
||||||
|
return StrategyAgentItem(
|
||||||
|
id=agent.id,
|
||||||
|
category=role["category"],
|
||||||
|
fixed=bool(role["fixed"]),
|
||||||
|
enabled=bool(role["enabled"]),
|
||||||
|
sort_order=int(role["sort_order"]),
|
||||||
|
name=agent.name,
|
||||||
|
description=agent.description,
|
||||||
|
model=agent.model,
|
||||||
|
soul=agent.soul,
|
||||||
|
skills=agent.skills,
|
||||||
|
created_at=agent.created_at,
|
||||||
|
updated_at=agent.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Public consumers need only identity and lifecycle metadata. Avoid
|
||||||
|
# leaking prompts/model/skills and avoid synchronous filesystem reads.
|
||||||
|
return StrategyAgentItem(
|
||||||
|
id=str(record["id"]),
|
||||||
|
category=role["category"],
|
||||||
|
fixed=bool(role["fixed"]),
|
||||||
|
enabled=bool(role["enabled"]),
|
||||||
|
sort_order=int(role["sort_order"]),
|
||||||
|
name=str(record["name"]),
|
||||||
|
description=str(record.get("description") or ""),
|
||||||
|
created_at=record.get("created_at"),
|
||||||
|
updated_at=record.get("updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_role_and_agent(
|
||||||
|
request: Request,
|
||||||
|
agent_id: str,
|
||||||
|
) -> tuple[dict, dict]:
|
||||||
|
role_store = get_strategy_agent_role_store(request)
|
||||||
|
agent_store = get_agent_store(request)
|
||||||
|
await _ensure_registry(agent_store, role_store)
|
||||||
|
role = await role_store.get_role(agent_id)
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
|
||||||
|
record = await agent_store.get_any(agent_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Strategy agent '{agent_id}' has no backing agent configuration",
|
||||||
|
)
|
||||||
|
if record.get("user_id") is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Strategy agent '{agent_id}' must be globally owned",
|
||||||
|
)
|
||||||
|
canonical_role_id = str(role.get("agent_id") or "")
|
||||||
|
if canonical_role_id != agent_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(f"Strategy agent id casing mismatch; use canonical id '{canonical_role_id}'"),
|
||||||
|
)
|
||||||
|
agents_router._require_canonical_record_id(agent_id, record)
|
||||||
|
return role, record
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=StrategyAgentListResponse)
|
||||||
|
async def list_strategy_agents(
|
||||||
|
request: Request,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
include_config: bool = False,
|
||||||
|
) -> StrategyAgentListResponse:
|
||||||
|
"""List the shared role catalog; sensitive configuration is admin-only."""
|
||||||
|
|
||||||
|
if include_disabled or include_config:
|
||||||
|
await _require_admin(request)
|
||||||
|
role_store = get_strategy_agent_role_store(request)
|
||||||
|
agent_store = get_agent_store(request)
|
||||||
|
await _ensure_registry(agent_store, role_store)
|
||||||
|
roles = await role_store.list_roles(include_disabled=include_disabled)
|
||||||
|
items: list[StrategyAgentItem] = []
|
||||||
|
for role in roles:
|
||||||
|
record = await agent_store.get_any(str(role["agent_id"]))
|
||||||
|
if record is None:
|
||||||
|
if bool(role["enabled"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(f"Strategy agent '{role['agent_id']}' configuration is unavailable"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if str(record.get("id") or "") != str(role["agent_id"]) or record.get("user_id") is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Strategy agent '{role['agent_id']}' must be a canonical global agent",
|
||||||
|
)
|
||||||
|
items.append(await _to_item(role, record, include_config=include_config))
|
||||||
|
return StrategyAgentListResponse(agents=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/angles",
|
||||||
|
response_model=StrategyAgentItem,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_strategy_angle(
|
||||||
|
payload: StrategyAngleCreateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> StrategyAgentItem:
|
||||||
|
"""Create one global angle agent. Fixed role categories are not creatable."""
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
name = agents_router._validate_display_name(payload.name)
|
||||||
|
model = _normalize_model(request, payload.model)
|
||||||
|
skills = await agents_router._validate_strategy_agent_skills(
|
||||||
|
payload.skills,
|
||||||
|
app_config=get_config(request),
|
||||||
|
skill_store=get_skill_store(request),
|
||||||
|
)
|
||||||
|
agent_store = get_agent_store(request)
|
||||||
|
role_store = get_strategy_agent_role_store(request)
|
||||||
|
await _ensure_registry(agent_store, role_store)
|
||||||
|
|
||||||
|
agent_id = f"{STRATEGY_ANGLE_AGENT_PREFIX}{uuid4().hex}"
|
||||||
|
# The generated id has already passed the fixed prefix/storage contract;
|
||||||
|
# all filesystem and persistence mutations happen under the cross-worker
|
||||||
|
# guard below.
|
||||||
|
agent_id = agents_router._validate_storage_id(agent_id)
|
||||||
|
async with _strategy_mutation_guard(agent_id):
|
||||||
|
return await _create_strategy_angle_locked(
|
||||||
|
agent_id=agent_id,
|
||||||
|
payload=payload,
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
skills=skills,
|
||||||
|
agent_store=agent_store,
|
||||||
|
role_store=role_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_failed_create(
|
||||||
|
agent_id: str,
|
||||||
|
agent_store: AgentStore,
|
||||||
|
role_store: StrategyAgentRoleStore,
|
||||||
|
) -> None:
|
||||||
|
async def attempt(label: str, operation) -> None:
|
||||||
|
try:
|
||||||
|
result = operation()
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except BaseException: # cleanup must continue through cancellation/failures
|
||||||
|
logger.exception("Failed create cleanup step %s for '%s'", label, agent_id)
|
||||||
|
|
||||||
|
await attempt("role", lambda: role_store.delete_role(agent_id))
|
||||||
|
await attempt("agent", lambda: agent_store.delete_builtin(agent_id))
|
||||||
|
await attempt("extras", lambda: agent_store.delete_extras_for_agent(agent_id))
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
|
if agent_dir.exists():
|
||||||
|
await attempt("folder", lambda: asyncio.to_thread(shutil.rmtree, agent_dir))
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_strategy_angle_locked(
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
payload: StrategyAngleCreateRequest,
|
||||||
|
name: str,
|
||||||
|
model: str | None,
|
||||||
|
skills: list[str] | None,
|
||||||
|
agent_store: AgentStore,
|
||||||
|
role_store: StrategyAgentRoleStore,
|
||||||
|
) -> StrategyAgentItem:
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
|
if await agent_store.get_any(agent_id) is not None or agent_dir.exists():
|
||||||
|
# Cryptographically improbable, but never overwrite a folder if the
|
||||||
|
# deployment restored a row without its registry metadata.
|
||||||
|
raise HTTPException(status_code=409, detail="Generated strategy agent id already exists")
|
||||||
|
|
||||||
|
sort_order = payload.sort_order
|
||||||
|
if sort_order is None:
|
||||||
|
current = await role_store.list_roles(include_disabled=True)
|
||||||
|
angle_orders = [int(row["sort_order"]) for row in current if row.get("category") == "angle"]
|
||||||
|
sort_order = (max(angle_orders) + 1) if angle_orders else 100
|
||||||
|
soul = (
|
||||||
|
payload.soul
|
||||||
|
if payload.soul.strip()
|
||||||
|
else _default_angle_soul(
|
||||||
|
name,
|
||||||
|
payload.description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = False
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
agents_router._write_agent_config,
|
||||||
|
agent_id,
|
||||||
|
name=name,
|
||||||
|
description=payload.description,
|
||||||
|
skills=skills,
|
||||||
|
model=model,
|
||||||
|
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(agents_router._write_agent_soul, agent_id, soul)
|
||||||
|
record = await agent_store.ensure_builtin(
|
||||||
|
{
|
||||||
|
"id": agent_id,
|
||||||
|
"name": name,
|
||||||
|
"description": payload.description,
|
||||||
|
"published": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await agent_store.replace_skills(agent_id, skills)
|
||||||
|
await agent_store.set_extras(
|
||||||
|
agent_id,
|
||||||
|
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
role = await role_store.create_role(
|
||||||
|
{
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": sort_order,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(_write_role_sidecar, agent_id, role)
|
||||||
|
completed = True
|
||||||
|
return await _to_item(role, record, include_config=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create strategy angle agent '%s'", agent_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to create strategy angle agent",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
if not completed:
|
||||||
|
cleanup = asyncio.create_task(_cleanup_failed_create(agent_id, agent_store, role_store))
|
||||||
|
try:
|
||||||
|
await asyncio.shield(cleanup)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# A second cancellation must not leave a half-created global
|
||||||
|
# agent behind while the file lock is released.
|
||||||
|
await cleanup
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{agent_id}", response_model=StrategyAgentItem)
|
||||||
|
async def update_strategy_agent(
|
||||||
|
agent_id: str,
|
||||||
|
payload: StrategyAgentUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> StrategyAgentItem:
|
||||||
|
"""Update configuration; fixed roles cannot change lifecycle or category."""
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
agent_id = agents_router._validate_storage_id(agent_id)
|
||||||
|
async with _strategy_mutation_guard(agent_id):
|
||||||
|
return await _update_strategy_agent_locked(agent_id, payload, request)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_strategy_agent_locked(
|
||||||
|
agent_id: str,
|
||||||
|
payload: StrategyAgentUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> StrategyAgentItem:
|
||||||
|
role, record = await _get_role_and_agent(request, agent_id)
|
||||||
|
agent_store = get_agent_store(request)
|
||||||
|
fields = payload.model_fields_set
|
||||||
|
|
||||||
|
if bool(role["fixed"]):
|
||||||
|
if "enabled" in fields and payload.enabled is not True:
|
||||||
|
raise HTTPException(status_code=409, detail="Fixed strategy roles cannot be disabled")
|
||||||
|
if "sort_order" in fields and payload.sort_order != int(role["sort_order"]):
|
||||||
|
raise HTTPException(status_code=409, detail="Fixed strategy role order cannot be changed")
|
||||||
|
|
||||||
|
next_name = agents_router._validate_display_name(payload.name or "") if "name" in fields else str(record["name"])
|
||||||
|
next_description = payload.description or "" if "description" in fields else str(record.get("description") or "")
|
||||||
|
extras = await asyncio.to_thread(agents_router._load_config_extras, agent_id)
|
||||||
|
next_model = _normalize_model(request, payload.model) if "model" in fields else extras["model"]
|
||||||
|
next_skills = (
|
||||||
|
await agents_router._validate_strategy_agent_skills(
|
||||||
|
payload.skills,
|
||||||
|
app_config=get_config(request),
|
||||||
|
skill_store=get_skill_store(request),
|
||||||
|
)
|
||||||
|
if "skills" in fields
|
||||||
|
else extras["skills"]
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
|
file_snapshot = await asyncio.to_thread(
|
||||||
|
lambda: {
|
||||||
|
"config": _read_optional_text(agent_dir / "config.yaml"),
|
||||||
|
"soul": _read_optional_text(agent_dir / "SOUL.md"),
|
||||||
|
"sidecar": _read_optional_text(agent_dir / _ROLE_SIDECAR),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
original_record = dict(record)
|
||||||
|
original_role = dict(role)
|
||||||
|
role_store = get_strategy_agent_role_store(request)
|
||||||
|
try:
|
||||||
|
config_fields = {"name", "description", "model", "skills"}
|
||||||
|
if fields & config_fields:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
agents_router._write_agent_config,
|
||||||
|
agent_id,
|
||||||
|
name=next_name,
|
||||||
|
description=next_description,
|
||||||
|
skills=next_skills,
|
||||||
|
model=next_model,
|
||||||
|
tool_groups=extras["tool_groups"],
|
||||||
|
common_questions=extras["common_questions"],
|
||||||
|
template_name=extras["template_name"],
|
||||||
|
seat_skill_directive=extras["seat_skill_directive"],
|
||||||
|
llmwiki_knowledge_base_ids=extras["llmwiki_knowledge_base_ids"],
|
||||||
|
)
|
||||||
|
await agent_store.replace_skills(agent_id, next_skills)
|
||||||
|
await agent_store.set_extras(
|
||||||
|
agent_id,
|
||||||
|
tool_groups=extras["tool_groups"],
|
||||||
|
model=next_model,
|
||||||
|
)
|
||||||
|
if "soul" in fields:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
agents_router._write_agent_soul,
|
||||||
|
agent_id,
|
||||||
|
payload.soul or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if {"name", "description"} & fields:
|
||||||
|
updated = await agent_store.update_any(
|
||||||
|
agent_id,
|
||||||
|
{"name": next_name, "description": next_description},
|
||||||
|
)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
record = updated
|
||||||
|
|
||||||
|
enabled = payload.enabled if "enabled" in fields and not bool(role["fixed"]) else None
|
||||||
|
sort_order = payload.sort_order if "sort_order" in fields and not bool(role["fixed"]) else None
|
||||||
|
if enabled is not None or sort_order is not None:
|
||||||
|
updated_role = await role_store.update_role(
|
||||||
|
agent_id,
|
||||||
|
enabled=enabled,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
if updated_role is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
|
||||||
|
role = updated_role
|
||||||
|
|
||||||
|
if not bool(role["fixed"]):
|
||||||
|
await asyncio.to_thread(_write_role_sidecar, agent_id, role)
|
||||||
|
except Exception:
|
||||||
|
await _rollback_strategy_update(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_store=agent_store,
|
||||||
|
role_store=role_store,
|
||||||
|
record=original_record,
|
||||||
|
role=original_role,
|
||||||
|
extras=extras,
|
||||||
|
file_snapshot=file_snapshot,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await _to_item(role, record, include_config=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{agent_id}", response_model=StrategyAgentItem)
|
||||||
|
async def disable_strategy_angle(agent_id: str, request: Request) -> StrategyAgentItem:
|
||||||
|
"""Soft-disable an angle while retaining its agent and historical links."""
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
agent_id = agents_router._validate_storage_id(agent_id)
|
||||||
|
async with _strategy_mutation_guard(agent_id):
|
||||||
|
return await _disable_strategy_angle_locked(agent_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
async def _disable_strategy_angle_locked(
|
||||||
|
agent_id: str,
|
||||||
|
request: Request,
|
||||||
|
) -> StrategyAgentItem:
|
||||||
|
role, record = await _get_role_and_agent(request, agent_id)
|
||||||
|
if bool(role["fixed"]):
|
||||||
|
raise HTTPException(status_code=409, detail="Fixed strategy roles cannot be deleted")
|
||||||
|
if role.get("category") != "angle":
|
||||||
|
raise HTTPException(status_code=409, detail="Only strategy angle roles can be disabled")
|
||||||
|
updated = await get_strategy_agent_role_store(request).update_role(
|
||||||
|
agent_id,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_write_role_sidecar, agent_id, updated)
|
||||||
|
except Exception:
|
||||||
|
await get_strategy_agent_role_store(request).update_role(
|
||||||
|
agent_id,
|
||||||
|
enabled=bool(role["enabled"]),
|
||||||
|
sort_order=int(role["sort_order"]),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return await _to_item(updated, record, include_config=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StrategyAgentItem",
|
||||||
|
"StrategyAgentListResponse",
|
||||||
|
"StrategyAgentUpdateRequest",
|
||||||
|
"StrategyAngleCreateRequest",
|
||||||
|
"create_strategy_angle",
|
||||||
|
"disable_strategy_angle",
|
||||||
|
"ensure_strategy_agent_registry",
|
||||||
|
"list_strategy_agents",
|
||||||
|
"router",
|
||||||
|
"update_strategy_agent",
|
||||||
|
]
|
||||||
@@ -26,6 +26,7 @@ from deerflow.config.system_settings import (
|
|||||||
PublishSquare,
|
PublishSquare,
|
||||||
PublishSquaresSettings,
|
PublishSquaresSettings,
|
||||||
SkillCompressionSettings,
|
SkillCompressionSettings,
|
||||||
|
StrategyProjectSettings,
|
||||||
get_module_tag_ids,
|
get_module_tag_ids,
|
||||||
list_publish_squares,
|
list_publish_squares,
|
||||||
load_system_settings,
|
load_system_settings,
|
||||||
@@ -60,6 +61,14 @@ class OrdinaryKnowledgeLanding(BaseModel):
|
|||||||
mode: Literal["wiki", "weknora"] = "wiki"
|
mode: Literal["wiki", "weknora"] = "wiki"
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyProjectSettingsResponse(BaseModel):
|
||||||
|
taiwan_strait_demo_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyProjectSettingsUpdateRequest(BaseModel):
|
||||||
|
taiwan_strait_demo_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||||
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
|
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
|
||||||
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
|
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
|
||||||
@@ -72,6 +81,35 @@ async def update_ordinary_knowledge_landing(body: OrdinaryKnowledgeLanding, requ
|
|||||||
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
|
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_project_settings_response(
|
||||||
|
settings: StrategyProjectSettings,
|
||||||
|
) -> StrategyProjectSettingsResponse:
|
||||||
|
return StrategyProjectSettingsResponse(
|
||||||
|
taiwan_strait_demo_enabled=settings.taiwan_strait_demo_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/strategy-projects", response_model=StrategyProjectSettingsResponse)
|
||||||
|
async def get_strategy_project_settings() -> StrategyProjectSettingsResponse:
|
||||||
|
"""Return project-demo visibility. Any logged-in user may read it."""
|
||||||
|
return _strategy_project_settings_response(load_system_settings().strategy_projects)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/strategy-projects", response_model=StrategyProjectSettingsResponse)
|
||||||
|
async def update_strategy_project_settings(
|
||||||
|
body: StrategyProjectSettingsUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> StrategyProjectSettingsResponse:
|
||||||
|
"""Update project-demo visibility. Admin only."""
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
fields = body.model_dump(exclude_unset=True, exclude_none=True)
|
||||||
|
updated = current.strategy_projects.model_copy(update=fields)
|
||||||
|
saved = save_system_settings(current.model_copy(update={"strategy_projects": updated}))
|
||||||
|
return _strategy_project_settings_response(saved.strategy_projects)
|
||||||
|
|
||||||
|
|
||||||
class PromptPrefixUpdateRequest(BaseModel):
|
class PromptPrefixUpdateRequest(BaseModel):
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
switch_label: str | None = None
|
switch_label: str | None = None
|
||||||
|
|||||||
@@ -383,7 +383,9 @@ sandbox:
|
|||||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||||
allow_host_bash: true
|
allow_host_bash: true
|
||||||
mounts:
|
mounts:
|
||||||
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
|
# Relative paths are resolved against this config file, so the knowledge
|
||||||
|
# mount works after cloning or moving the offline deployment.
|
||||||
|
- host_path: knowledge/knowledge_base
|
||||||
container_path: /mnt/knowledge
|
container_path: /mnt/knowledge
|
||||||
read_only: true
|
read_only: true
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ class AppConfig(BaseModel):
|
|||||||
cls._check_config_version(config_data, resolved_path)
|
cls._check_config_version(config_data, resolved_path)
|
||||||
|
|
||||||
config_data = cls.resolve_env_variables(config_data)
|
config_data = cls.resolve_env_variables(config_data)
|
||||||
|
cls._resolve_relative_mount_paths(config_data, resolved_path.parent)
|
||||||
cls._apply_database_defaults(config_data)
|
cls._apply_database_defaults(config_data)
|
||||||
|
|
||||||
# Load title config if present
|
# Load title config if present
|
||||||
@@ -272,6 +273,36 @@ class AppConfig(BaseModel):
|
|||||||
result = cls.model_validate(config_data)
|
result = cls.model_validate(config_data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_relative_mount_paths(
|
||||||
|
cls,
|
||||||
|
config_data: dict[str, Any],
|
||||||
|
config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Resolve portable sandbox mount paths beside ``config.yaml``.
|
||||||
|
|
||||||
|
Deployment copies frequently live at different absolute paths. A
|
||||||
|
relative ``sandbox.mounts[*].host_path`` therefore means "relative to
|
||||||
|
the config file", never relative to the process working directory.
|
||||||
|
Providers continue to receive absolute paths, which keeps both the
|
||||||
|
local path mapper and Docker bind mounts deterministic.
|
||||||
|
"""
|
||||||
|
sandbox = config_data.get("sandbox")
|
||||||
|
if not isinstance(sandbox, dict):
|
||||||
|
return
|
||||||
|
mounts = sandbox.get("mounts")
|
||||||
|
if not isinstance(mounts, list):
|
||||||
|
return
|
||||||
|
for mount in mounts:
|
||||||
|
if not isinstance(mount, dict):
|
||||||
|
continue
|
||||||
|
raw_host_path = mount.get("host_path")
|
||||||
|
if not isinstance(raw_host_path, str) or not raw_host_path.strip():
|
||||||
|
continue
|
||||||
|
host_path = Path(raw_host_path)
|
||||||
|
if not host_path.is_absolute():
|
||||||
|
mount["host_path"] = str((config_dir / host_path).resolve())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _apply_database_defaults(cls, config_data: dict[str, Any]) -> None:
|
def _apply_database_defaults(cls, config_data: dict[str, Any]) -> None:
|
||||||
"""Apply config.yaml defaults for persistence when the section is absent."""
|
"""Apply config.yaml defaults for persistence when the section is absent."""
|
||||||
|
|||||||
@@ -516,6 +516,16 @@ class LocalWikiEncoderSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyProjectSettings(BaseModel):
|
||||||
|
"""Global visibility controls for the strategy-project experience."""
|
||||||
|
|
||||||
|
taiwan_strait_demo_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether Taiwan Strait demo entry points are visible in My Projects.",
|
||||||
|
)
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
class SystemSettings(BaseModel):
|
class SystemSettings(BaseModel):
|
||||||
"""Top-level system settings document."""
|
"""Top-level system settings document."""
|
||||||
|
|
||||||
@@ -532,6 +542,7 @@ class SystemSettings(BaseModel):
|
|||||||
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
|
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
|
||||||
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
|
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
|
||||||
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
|
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
|
||||||
|
strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings)
|
||||||
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
|
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
|
||||||
skill_knowledge_auto_sync: SkillKnowledgeAutoSyncSettings = Field(default_factory=SkillKnowledgeAutoSyncSettings)
|
skill_knowledge_auto_sync: SkillKnowledgeAutoSyncSettings = Field(default_factory=SkillKnowledgeAutoSyncSettings)
|
||||||
knowledge_base_product_version: Literal["v1", "v2"] = Field(
|
knowledge_base_product_version: Literal["v1", "v2"] = Field(
|
||||||
|
|||||||
+2
@@ -125,6 +125,7 @@ from deerflow.persistence.skill_knowledge.model import (
|
|||||||
SkillKnowledgeSyncJobRow,
|
SkillKnowledgeSyncJobRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
|
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
|
||||||
|
from deerflow.persistence.strategy_agent_roles.model import StrategyAgentRoleRow
|
||||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||||
from deerflow.persistence.task_buttons.model import TaskButtonRow
|
from deerflow.persistence.task_buttons.model import TaskButtonRow
|
||||||
from deerflow.persistence.task_reports.model import TaskReportRow
|
from deerflow.persistence.task_reports.model import TaskReportRow
|
||||||
@@ -254,6 +255,7 @@ __all__ = [
|
|||||||
"SkillKnowledgeSnapshotRow",
|
"SkillKnowledgeSnapshotRow",
|
||||||
"SkillKnowledgeSyncItemRow",
|
"SkillKnowledgeSyncItemRow",
|
||||||
"SkillKnowledgeSyncJobRow",
|
"SkillKnowledgeSyncJobRow",
|
||||||
|
"StrategyAgentRoleRow",
|
||||||
"TagAssignmentRow",
|
"TagAssignmentRow",
|
||||||
"TagRow",
|
"TagRow",
|
||||||
"TaskButtonRow",
|
"TaskButtonRow",
|
||||||
|
|||||||
+33
-20
@@ -14,7 +14,7 @@ import json
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||||
@@ -59,12 +59,7 @@ class ParallelAgentTaskRepository:
|
|||||||
|
|
||||||
async def list_tasks(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
async def list_tasks(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
"""列出当前用户的任务(轻量元信息,不含 messages,供历史列表)。"""
|
"""列出当前用户的任务(轻量元信息,不含 messages,供历史列表)。"""
|
||||||
stmt = (
|
stmt = select(ParallelAgentTaskRow).where(ParallelAgentTaskRow.user_id == user_id).order_by(ParallelAgentTaskRow.updated_at.desc()).limit(limit)
|
||||||
select(ParallelAgentTaskRow)
|
|
||||||
.where(ParallelAgentTaskRow.user_id == user_id)
|
|
||||||
.order_by(ParallelAgentTaskRow.updated_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [self._to_dict(row, with_messages=False) for row in result.scalars()]
|
return [self._to_dict(row, with_messages=False) for row in result.scalars()]
|
||||||
@@ -103,22 +98,40 @@ class ParallelAgentTaskRepository:
|
|||||||
brief=_UNSET,
|
brief=_UNSET,
|
||||||
agents=_UNSET,
|
agents=_UNSET,
|
||||||
messages=_UNSET,
|
messages=_UNSET,
|
||||||
|
expected_updated_at: datetime | str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
expected_revision = expected_updated_at
|
||||||
row = await session.get(ParallelAgentTaskRow, task_id)
|
if isinstance(expected_revision, str):
|
||||||
if row is None or row.user_id != user_id:
|
try:
|
||||||
|
expected_revision = datetime.fromisoformat(expected_revision.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
values: dict[str, Any] = {"updated_at": datetime.now(UTC)}
|
||||||
|
if title is not _UNSET and title is not None:
|
||||||
|
values["title"] = str(title).strip()
|
||||||
|
if brief is not _UNSET:
|
||||||
|
values["brief"] = brief or None
|
||||||
|
if agents is not _UNSET:
|
||||||
|
values["agents"] = _dumps(agents)
|
||||||
|
if messages is not _UNSET:
|
||||||
|
values["messages"] = _dumps(messages)
|
||||||
|
|
||||||
|
async with self._sf() as session:
|
||||||
|
statement = update(ParallelAgentTaskRow).where(
|
||||||
|
ParallelAgentTaskRow.id == task_id,
|
||||||
|
ParallelAgentTaskRow.user_id == user_id,
|
||||||
|
)
|
||||||
|
if expected_revision is not None:
|
||||||
|
statement = statement.where(ParallelAgentTaskRow.updated_at == expected_revision)
|
||||||
|
result = await session.execute(statement.values(**values))
|
||||||
|
if result.rowcount != 1:
|
||||||
|
await session.rollback()
|
||||||
return None
|
return None
|
||||||
if title is not _UNSET and title is not None:
|
|
||||||
row.title = str(title).strip()
|
|
||||||
if brief is not _UNSET:
|
|
||||||
row.brief = brief or None
|
|
||||||
if agents is not _UNSET:
|
|
||||||
row.agents = _dumps(agents)
|
|
||||||
if messages is not _UNSET:
|
|
||||||
row.messages = _dumps(messages)
|
|
||||||
row.updated_at = datetime.now(UTC)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
row = await session.get(ParallelAgentTaskRow, task_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
return self._to_dict(row)
|
return self._to_dict(row)
|
||||||
|
|
||||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
"""Global strategy-selection agent role registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.strategy_agent_roles.base import StrategyAgentRoleStore
|
||||||
|
from deerflow.persistence.strategy_agent_roles.memory import (
|
||||||
|
MemoryStrategyAgentRoleStore,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.strategy_agent_roles.model import StrategyAgentRoleRow
|
||||||
|
from deerflow.persistence.strategy_agent_roles.sql import StrategyAgentRoleRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MemoryStrategyAgentRoleStore",
|
||||||
|
"StrategyAgentRoleRepository",
|
||||||
|
"StrategyAgentRoleRow",
|
||||||
|
"StrategyAgentRoleStore",
|
||||||
|
"make_strategy_agent_role_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_strategy_agent_role_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryStrategyAgentRoleStore()
|
||||||
|
return StrategyAgentRoleRepository(session_factory)
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
"""Abstract store for the global strategy-selection agent registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAgentRoleStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def ensure_defaults(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
"""Insert missing default rows without re-enabling disabled angles."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_roles(self, *, include_disabled: bool) -> list[dict[str, Any]]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_role(self, agent_id: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_role(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_role(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
sort_order: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_role(self, agent_id: str) -> bool:
|
||||||
|
"""Hard-delete a registry row for create rollback only."""
|
||||||
|
raise NotImplementedError
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
"""In-memory strategy-agent registry used by memory persistence and tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.persistence.strategy_agent_roles.base import StrategyAgentRoleStore
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _copy(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryStrategyAgentRoleStore(StrategyAgentRoleStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rows: dict[str, dict[str, Any]] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def ensure_defaults(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
inserted = 0
|
||||||
|
async with self._lock:
|
||||||
|
for source in rows:
|
||||||
|
agent_id = str(source["agent_id"])
|
||||||
|
existing = self._rows.get(agent_id)
|
||||||
|
if existing is not None:
|
||||||
|
# Canonical fixed roles are executable infrastructure, not
|
||||||
|
# administrator-managed lifecycle rows. Repair stale or
|
||||||
|
# manually-corrupted metadata on every bootstrap. Angle
|
||||||
|
# defaults deliberately retain their enabled/order state.
|
||||||
|
if bool(source.get("fixed", False)):
|
||||||
|
canonical = {
|
||||||
|
"category": str(source["category"]),
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": bool(source.get("enabled", True)),
|
||||||
|
"sort_order": int(source.get("sort_order", 0)),
|
||||||
|
}
|
||||||
|
if any(existing.get(key) != value for key, value in canonical.items()):
|
||||||
|
existing.update(canonical)
|
||||||
|
existing["updated_at"] = _now()
|
||||||
|
continue
|
||||||
|
now = _now()
|
||||||
|
self._rows[agent_id] = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": str(source["category"]),
|
||||||
|
"fixed": bool(source.get("fixed", False)),
|
||||||
|
"enabled": bool(source.get("enabled", True)),
|
||||||
|
"sort_order": int(source.get("sort_order", 0)),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
inserted += 1
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
async def list_roles(self, *, include_disabled: bool) -> list[dict[str, Any]]:
|
||||||
|
async with self._lock:
|
||||||
|
rows = [_copy(row) for row in self._rows.values() if include_disabled or bool(row.get("enabled"))]
|
||||||
|
rows.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
int(row.get("sort_order", 0)),
|
||||||
|
str(row.get("agent_id", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def get_role(self, agent_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._lock:
|
||||||
|
row = self._rows.get(agent_id)
|
||||||
|
return _copy(row) if row is not None else None
|
||||||
|
|
||||||
|
async def create_role(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
agent_id = str(data["agent_id"])
|
||||||
|
async with self._lock:
|
||||||
|
if agent_id in self._rows:
|
||||||
|
raise ValueError(f"Strategy agent role '{agent_id}' already exists")
|
||||||
|
now = _now()
|
||||||
|
row = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"category": str(data["category"]),
|
||||||
|
"fixed": bool(data.get("fixed", False)),
|
||||||
|
"enabled": bool(data.get("enabled", True)),
|
||||||
|
"sort_order": int(data.get("sort_order", 0)),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
self._rows[agent_id] = row
|
||||||
|
return _copy(row)
|
||||||
|
|
||||||
|
async def update_role(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
sort_order: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
async with self._lock:
|
||||||
|
row = self._rows.get(agent_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if enabled is not None:
|
||||||
|
row["enabled"] = bool(enabled)
|
||||||
|
if sort_order is not None:
|
||||||
|
row["sort_order"] = int(sort_order)
|
||||||
|
row["updated_at"] = _now()
|
||||||
|
return _copy(row)
|
||||||
|
|
||||||
|
async def delete_role(self, agent_id: str) -> bool:
|
||||||
|
async with self._lock:
|
||||||
|
return self._rows.pop(agent_id, None) is not None
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
"""Persistence model for the global strategy-selection agent registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAgentRoleRow(Base):
|
||||||
|
"""Classify folder-backed agents used by the strategy-selection workflow.
|
||||||
|
|
||||||
|
The agent's model, skills and SOUL remain authoritative in its normal
|
||||||
|
``config.yaml`` / ``SOUL.md`` files. This small global table owns only the
|
||||||
|
workflow classification and lifecycle flags. Keeping a disabled row (and
|
||||||
|
the underlying agent) protects saved projects that still reference it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "strategy_agent_roles"
|
||||||
|
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||||
|
fixed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1", index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
"""SQLAlchemy-backed global strategy-selection agent registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.strategy_agent_roles.base import StrategyAgentRoleStore
|
||||||
|
from deerflow.persistence.strategy_agent_roles.model import StrategyAgentRoleRow
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict(row: StrategyAgentRoleRow) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"agent_id": row.agent_id,
|
||||||
|
"category": row.category,
|
||||||
|
"fixed": row.fixed,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"sort_order": row.sort_order,
|
||||||
|
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||||
|
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyAgentRoleRepository(StrategyAgentRoleStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def ensure_defaults(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
inserted = 0
|
||||||
|
for source in rows:
|
||||||
|
# Commit each of the small number of defaults independently. If
|
||||||
|
# two workers race on one primary key, rolling that save back must
|
||||||
|
# not also discard unrelated defaults inserted by this worker.
|
||||||
|
async with self._sf() as session:
|
||||||
|
agent_id = str(source["agent_id"])
|
||||||
|
existing = await session.get(StrategyAgentRoleRow, agent_id)
|
||||||
|
if existing is not None:
|
||||||
|
if bool(source.get("fixed", False)):
|
||||||
|
changed = False
|
||||||
|
canonical = {
|
||||||
|
"category": str(source["category"]),
|
||||||
|
"fixed": True,
|
||||||
|
"enabled": bool(source.get("enabled", True)),
|
||||||
|
"sort_order": int(source.get("sort_order", 0)),
|
||||||
|
}
|
||||||
|
for key, value in canonical.items():
|
||||||
|
if getattr(existing, key) != value:
|
||||||
|
setattr(existing, key, value)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
existing.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
continue
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
session.add(
|
||||||
|
StrategyAgentRoleRow(
|
||||||
|
agent_id=agent_id,
|
||||||
|
category=str(source["category"]),
|
||||||
|
fixed=bool(source.get("fixed", False)),
|
||||||
|
enabled=bool(source.get("enabled", True)),
|
||||||
|
sort_order=int(source.get("sort_order", 0)),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# Another startup worker won this primary-key race.
|
||||||
|
await session.rollback()
|
||||||
|
continue
|
||||||
|
inserted += 1
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
async def list_roles(self, *, include_disabled: bool) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(StrategyAgentRoleRow)
|
||||||
|
if not include_disabled:
|
||||||
|
stmt = stmt.where(StrategyAgentRoleRow.enabled.is_(True))
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
StrategyAgentRoleRow.sort_order.asc(),
|
||||||
|
StrategyAgentRoleRow.agent_id.asc(),
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [_to_dict(row) for row in result.scalars()]
|
||||||
|
|
||||||
|
async def get_role(self, agent_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(StrategyAgentRoleRow, agent_id)
|
||||||
|
return _to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def create_role(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
row = StrategyAgentRoleRow(
|
||||||
|
agent_id=str(data["agent_id"]),
|
||||||
|
category=str(data["category"]),
|
||||||
|
fixed=bool(data.get("fixed", False)),
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
sort_order=int(data.get("sort_order", 0)),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
session.add(row)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise ValueError(f"Strategy agent role '{row.agent_id}' already exists") from exc
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def update_role(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
sort_order: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(StrategyAgentRoleRow, agent_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = bool(enabled)
|
||||||
|
if sort_order is not None:
|
||||||
|
row.sort_order = int(sort_order)
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def delete_role(self, agent_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(delete(StrategyAgentRoleRow).where(StrategyAgentRoleRow.agent_id == agent_id))
|
||||||
|
await session.commit()
|
||||||
|
return (result.rowcount or 0) > 0
|
||||||
@@ -21,9 +21,10 @@ class InlineThinkTagFilter:
|
|||||||
_OPEN_TAG = "<think>"
|
_OPEN_TAG = "<think>"
|
||||||
_CLOSE_TAG = "</think>"
|
_CLOSE_TAG = "</think>"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, allow_implicit_close: bool = False) -> None:
|
||||||
self._inside_think = False
|
self._inside_think = False
|
||||||
self._pending = ""
|
self._pending = ""
|
||||||
|
self._channel_decided = not allow_implicit_close
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _possible_tag_prefix_size(content: str, tag: str) -> int:
|
def _possible_tag_prefix_size(content: str, tag: str) -> int:
|
||||||
@@ -44,6 +45,23 @@ class InlineThinkTagFilter:
|
|||||||
visible: list[str] = []
|
visible: list[str] = []
|
||||||
thinking: list[str] = []
|
thinking: list[str] = []
|
||||||
|
|
||||||
|
if not self._channel_decided:
|
||||||
|
lowered = remaining.lower()
|
||||||
|
open_at = lowered.find(self._OPEN_TAG)
|
||||||
|
close_at = lowered.find(self._CLOSE_TAG)
|
||||||
|
if close_at >= 0 and (open_at < 0 or close_at < open_at):
|
||||||
|
# Some templates inject the opening tag outside returned
|
||||||
|
# content. Keep the ambiguous prefix private until its close
|
||||||
|
# marker arrives, even when that marker spans chunks.
|
||||||
|
thinking.append(remaining[:close_at])
|
||||||
|
remaining = remaining[close_at + len(self._CLOSE_TAG) :]
|
||||||
|
self._channel_decided = True
|
||||||
|
elif open_at >= 0:
|
||||||
|
self._channel_decided = True
|
||||||
|
else:
|
||||||
|
self._pending = remaining
|
||||||
|
return "", ""
|
||||||
|
|
||||||
while remaining:
|
while remaining:
|
||||||
tag = self._CLOSE_TAG if self._inside_think else self._OPEN_TAG
|
tag = self._CLOSE_TAG if self._inside_think else self._OPEN_TAG
|
||||||
marker_at = remaining.lower().find(tag)
|
marker_at = remaining.lower().find(tag)
|
||||||
@@ -65,8 +83,27 @@ class InlineThinkTagFilter:
|
|||||||
"""Compatibility helper returning only confirmed visible text."""
|
"""Compatibility helper returning only confirmed visible text."""
|
||||||
return self.push_parts(chunk)[0]
|
return self.push_parts(chunk)[0]
|
||||||
|
|
||||||
|
def confirm_visible_channel(self) -> str:
|
||||||
|
"""Stop close-only probing and return any buffered visible prelude.
|
||||||
|
|
||||||
|
A provider that emits a dedicated reasoning field has already told us
|
||||||
|
how to classify its trace. Its ordinary ``content`` channel can then
|
||||||
|
stream immediately even when the model supports thinking.
|
||||||
|
"""
|
||||||
|
if self._channel_decided:
|
||||||
|
return ""
|
||||||
|
self._channel_decided = True
|
||||||
|
pending = self._pending
|
||||||
|
self._pending = ""
|
||||||
|
return pending
|
||||||
|
|
||||||
def finish_parts(self) -> tuple[str, str]:
|
def finish_parts(self) -> tuple[str, str]:
|
||||||
"""Flush the remaining visible or thinking text at end of stream."""
|
"""Flush the remaining visible or thinking text at end of stream."""
|
||||||
|
if not self._channel_decided:
|
||||||
|
tail = self._pending
|
||||||
|
self._pending = ""
|
||||||
|
self._channel_decided = True
|
||||||
|
return tail, ""
|
||||||
if self._inside_think:
|
if self._inside_think:
|
||||||
thinking = self._pending
|
thinking = self._pending
|
||||||
self._pending = ""
|
self._pending = ""
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from app.gateway.roundtable_model_fallback import (
|
|||||||
is_llm_error_message,
|
is_llm_error_message,
|
||||||
reason_text,
|
reason_text,
|
||||||
)
|
)
|
||||||
|
from app.gateway.routers._strategy_selection_seed import default_strategy_agent_roles
|
||||||
from deerflow.agents.middlewares.llm_error_handling_middleware import (
|
from deerflow.agents.middlewares.llm_error_handling_middleware import (
|
||||||
LLM_ERROR_MARKER,
|
LLM_ERROR_MARKER,
|
||||||
LLMErrorHandlingMiddleware,
|
LLMErrorHandlingMiddleware,
|
||||||
@@ -143,9 +144,7 @@ def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> N
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def fake_stream_upstream(*_args, **_kwargs):
|
async def fake_stream_upstream(*_args, **_kwargs):
|
||||||
yield None, [
|
yield None, [f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"]
|
||||||
f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||||
|
|
||||||
@@ -173,14 +172,193 @@ def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> N
|
|||||||
assert event["clarification"]["question"] == "请确认策略时间范围。"
|
assert event["clarification"]["question"] == "请确认策略时间范围。"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_final_values_keep_typed_reasoning_out_of_content(monkeypatch) -> None:
|
||||||
|
final_message = {
|
||||||
|
"type": "ai",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "text": "核对约束与证据"},
|
||||||
|
{"type": "text", "text": "这是最终可见策略正文,包含目标、路径和验收标准。"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_stream_upstream(*_args, **_kwargs):
|
||||||
|
yield None, [f"data: {json.dumps({'messages': [final_message]}, ensure_ascii=False)}"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||||
|
|
||||||
|
async def collect() -> list[object]:
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
async for item in parallel_agents._run_one_agent(
|
||||||
|
None, # type: ignore[arg-type]
|
||||||
|
"http://gateway.test",
|
||||||
|
{"Authorization": "Bearer test"},
|
||||||
|
"strategy-selection-angle-security",
|
||||||
|
"thread-id",
|
||||||
|
"test task",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert isinstance(event, dict)
|
||||||
|
assert event["content"] == "这是最终可见策略正文,包含目标、路径和验收标准。"
|
||||||
|
assert event["reasoning"] == "核对约束与证据"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_final_values_recovers_template_injected_reasoning(monkeypatch) -> None:
|
||||||
|
final_message = {
|
||||||
|
"type": "ai",
|
||||||
|
"content": "模板注入的内部推理</think>这是最终可见策略正文,包含目标、路径和验收标准。",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_stream_upstream(*_args, **_kwargs):
|
||||||
|
yield None, [f"data: {json.dumps({'messages': [final_message]}, ensure_ascii=False)}"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||||
|
|
||||||
|
async def collect() -> list[object]:
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
async for item in parallel_agents._run_one_agent(
|
||||||
|
None, # type: ignore[arg-type]
|
||||||
|
"http://gateway.test",
|
||||||
|
{"Authorization": "Bearer test"},
|
||||||
|
"strategy-selection-angle-security",
|
||||||
|
"thread-id",
|
||||||
|
"test task",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
|
||||||
|
event = events[0]
|
||||||
|
assert isinstance(event, dict)
|
||||||
|
assert event["content"] == "这是最终可见策略正文,包含目标、路径和验收标准。"
|
||||||
|
assert event["reasoning"] == "模板注入的内部推理"
|
||||||
|
|
||||||
|
|
||||||
def test_parallel_agent_substantive_content_accepts_chinese_and_english() -> None:
|
def test_parallel_agent_substantive_content_accepts_chinese_and_english() -> None:
|
||||||
assert parallel_agents._has_substantive_content("这是一份包含目标、前置条件、阶段路径和验收标准的完整策略。")
|
assert parallel_agents._has_substantive_content("这是一份包含目标、前置条件、阶段路径和验收标准的完整策略。")
|
||||||
assert parallel_agents._has_substantive_content(
|
assert parallel_agents._has_substantive_content("A complete strategy with objectives, prerequisites, milestones, and measurable outcomes.")
|
||||||
"A complete strategy with objectives, prerequisites, milestones, and measurable outcomes."
|
|
||||||
)
|
|
||||||
assert not parallel_agents._has_substantive_content("<think>internal reasoning only</think>!!!")
|
assert not parallel_agents._has_substantive_content("<think>internal reasoning only</think>!!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_extracts_provider_reasoning_without_mixing_answer() -> None:
|
||||||
|
answer, reasoning = parallel_agents._extract_ai_stream_parts(
|
||||||
|
{
|
||||||
|
"type": "AIMessageChunk",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking_delta", "text": "先核对边界"},
|
||||||
|
{"type": "text", "text": "可见正文"},
|
||||||
|
],
|
||||||
|
"additional_kwargs": {"reasoning_content": ",再比较路径"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert answer == "可见正文"
|
||||||
|
assert reasoning == "先核对边界,再比较路径"
|
||||||
|
|
||||||
|
answer, reasoning = parallel_agents._extract_ai_stream_parts(
|
||||||
|
{
|
||||||
|
"type": "AIMessageChunk",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "结构化思考字段"},
|
||||||
|
{"type": "text", "text": "最终正文"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert answer == "最终正文"
|
||||||
|
assert reasoning == "结构化思考字段"
|
||||||
|
|
||||||
|
|
||||||
|
class _EnabledStrategyRoleStore:
|
||||||
|
async def ensure_defaults(self, _rows) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def list_roles(self, *, include_disabled: bool) -> list[dict[str, object]]:
|
||||||
|
del include_disabled
|
||||||
|
return [dict(row) for row in default_strategy_agent_roles()]
|
||||||
|
|
||||||
|
async def get_role(self, agent_id: str) -> dict[str, object]:
|
||||||
|
return {"id": agent_id, "enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
class _GlobalStrategyAgentStore:
|
||||||
|
async def get_any(self, agent_id: str) -> dict[str, object]:
|
||||||
|
return {"id": agent_id, "user_id": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _parallel_request() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
app=SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
strategy_agent_role_store=_EnabledStrategyRoleStore(),
|
||||||
|
agent_store=_GlobalStrategyAgentStore(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_stream_separates_inline_and_provider_reasoning(monkeypatch) -> None:
|
||||||
|
async def fake_run_one_agent(*_args, **_kwargs):
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': '<thi'}], ensure_ascii=False)}"
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': 'nk>内部分析</think>方案正文'}], ensure_ascii=False)}"
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': '', 'additional_kwargs': {'reasoning_content': '补充推理'}}], ensure_ascii=False)}"
|
||||||
|
yield {
|
||||||
|
"status": "done",
|
||||||
|
"content": "<think>内部分析</think>方案正文",
|
||||||
|
"agent_name": "strategy-selection-angle-security",
|
||||||
|
"tool_calls": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_client(*_args, **_kwargs):
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
async def fake_create_thread(*_args, **_kwargs):
|
||||||
|
return "thread-id"
|
||||||
|
|
||||||
|
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||||
|
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||||
|
|
||||||
|
async def collect() -> list[dict[str, object]]:
|
||||||
|
response = await parallel_agents.run_parallel_agents_stream(
|
||||||
|
_parallel_request(),
|
||||||
|
{
|
||||||
|
"task_name": "test task",
|
||||||
|
"brief": "test brief",
|
||||||
|
"agent_ids": ["strategy-selection-angle-security"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
events: list[dict[str, object]] = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
events.append(json.loads(line[5:].strip()))
|
||||||
|
return events
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
visible = "".join(str(event.get("text") or "") for event in events if event.get("type") == "delta")
|
||||||
|
thinking = "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta")
|
||||||
|
done = next(event for event in events if event.get("type") == "agent_done")
|
||||||
|
|
||||||
|
assert visible == "方案正文"
|
||||||
|
assert thinking == "内部分析补充推理"
|
||||||
|
assert done["content"] == "方案正文"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("buffered_content", "expected_error"),
|
("buffered_content", "expected_error"),
|
||||||
[
|
[
|
||||||
@@ -214,7 +392,7 @@ def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
|
|||||||
|
|
||||||
async def collect() -> list[dict[str, object]]:
|
async def collect() -> list[dict[str, object]]:
|
||||||
response = await parallel_agents.run_parallel_agents_stream(
|
response = await parallel_agents.run_parallel_agents_stream(
|
||||||
SimpleNamespace(),
|
_parallel_request(),
|
||||||
{
|
{
|
||||||
"task_name": "test task",
|
"task_name": "test task",
|
||||||
"brief": "test brief",
|
"brief": "test brief",
|
||||||
@@ -236,6 +414,168 @@ def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
|
|||||||
assert expected_error in str(errors[0].get("message"))
|
assert expected_error in str(errors[0].get("message"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_plain_text_streams_each_chunk_without_probe_delay(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
chunks = [
|
||||||
|
"This visible strategy ",
|
||||||
|
"includes objectives and execution paths, ",
|
||||||
|
"plus measurable acceptance criteria.",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_run_one_agent(*_args, **_kwargs):
|
||||||
|
for content in chunks:
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': content}], ensure_ascii=False)}"
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_client(*_args, **_kwargs):
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
async def fake_create_thread(*_args, **_kwargs):
|
||||||
|
return "thread-id"
|
||||||
|
|
||||||
|
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
parallel_agents,
|
||||||
|
"_loopback_base",
|
||||||
|
lambda _request: "http://gateway.test",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
parallel_agents,
|
||||||
|
"_should_probe_implicit_reasoning",
|
||||||
|
lambda *_args: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def collect() -> list[dict[str, object]]:
|
||||||
|
response = await parallel_agents.run_parallel_agents_stream(
|
||||||
|
_parallel_request(),
|
||||||
|
{
|
||||||
|
"task_name": "plain stream",
|
||||||
|
"brief": "plain answer chunks must stay live",
|
||||||
|
"agent_ids": ["strategy-selection-intent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
events: list[dict[str, object]] = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
events.append(json.loads(line[5:].strip()))
|
||||||
|
return events
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
assert [event["text"] for event in events if event.get("type") == "delta"] == chunks
|
||||||
|
done = next(event for event in events if event.get("type") == "agent_done")
|
||||||
|
assert done["content"] == "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_typed_reasoning_unlocks_live_answer_channel(monkeypatch) -> None:
|
||||||
|
chunks = [
|
||||||
|
"This visible strategy ",
|
||||||
|
"streams after typed reasoning, ",
|
||||||
|
"with objectives and acceptance criteria.",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_run_one_agent(*_args, **_kwargs):
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': '', 'additional_kwargs': {'reasoning_content': 'live reasoning'}}])}"
|
||||||
|
for content in chunks:
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': content}])}"
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_client(*_args, **_kwargs):
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
async def fake_create_thread(*_args, **_kwargs):
|
||||||
|
return "thread-id"
|
||||||
|
|
||||||
|
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||||
|
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||||
|
monkeypatch.setattr(parallel_agents, "_should_probe_implicit_reasoning", lambda *_args: True)
|
||||||
|
|
||||||
|
async def collect() -> list[dict[str, object]]:
|
||||||
|
response = await parallel_agents.run_parallel_agents_stream(
|
||||||
|
_parallel_request(),
|
||||||
|
{
|
||||||
|
"task_name": "typed reasoning stream",
|
||||||
|
"brief": "answer chunks must stay live",
|
||||||
|
"agent_ids": ["strategy-selection-intent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
events: list[dict[str, object]] = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
events.append(json.loads(line[5:].strip()))
|
||||||
|
return events
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
assert [event["text"] for event in events if event.get("type") == "delta"] == chunks
|
||||||
|
assert "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta") == "live reasoning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_agent_truncated_close_only_stream_separates_reasoning(monkeypatch) -> None:
|
||||||
|
async def fake_run_one_agent(*_args, **_kwargs):
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': 'internal '}], ensure_ascii=False)}"
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': 'reasoning</thi'}], ensure_ascii=False)}"
|
||||||
|
yield "event: messages"
|
||||||
|
yield f"data: {json.dumps([{'type': 'ai', 'content': 'nk>This visible strategy includes objectives, execution paths, and acceptance criteria.'}], ensure_ascii=False)}"
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_client(*_args, **_kwargs):
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
async def fake_create_thread(*_args, **_kwargs):
|
||||||
|
return "thread-id"
|
||||||
|
|
||||||
|
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||||
|
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||||
|
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
parallel_agents,
|
||||||
|
"_should_probe_implicit_reasoning",
|
||||||
|
lambda *_args: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def collect() -> list[dict[str, object]]:
|
||||||
|
response = await parallel_agents.run_parallel_agents_stream(
|
||||||
|
_parallel_request(),
|
||||||
|
{
|
||||||
|
"task_name": "test task",
|
||||||
|
"brief": "test brief",
|
||||||
|
"agent_ids": ["strategy-selection-intent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
events: list[dict[str, object]] = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
events.append(json.loads(line[5:].strip()))
|
||||||
|
return events
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
done = next(event for event in events if event.get("type") == "agent_done")
|
||||||
|
reasoning = "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta")
|
||||||
|
visible = "".join(str(event.get("text") or "") for event in events if event.get("type") == "delta")
|
||||||
|
assert done["content"] == ("This visible strategy includes objectives, execution paths, and acceptance criteria.")
|
||||||
|
assert visible == done["content"]
|
||||||
|
assert reasoning == "internal reasoning"
|
||||||
|
|
||||||
|
|
||||||
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
|
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_sandbox_mount_is_resolved_from_config_directory(tmp_path: Path) -> None:
|
||||||
|
knowledge_dir = tmp_path / "knowledge" / "knowledge_base"
|
||||||
|
knowledge_dir.mkdir(parents=True)
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"""
|
||||||
|
sandbox:
|
||||||
|
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||||
|
mounts:
|
||||||
|
- host_path: knowledge/knowledge_base
|
||||||
|
container_path: /mnt/knowledge
|
||||||
|
read_only: true
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = AppConfig.from_file(str(config_path))
|
||||||
|
|
||||||
|
assert config.sandbox.mounts[0].host_path == str(knowledge_dir.resolve())
|
||||||
|
assert Path(config.sandbox.mounts[0].host_path).is_absolute()
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_sandbox_mount_is_preserved(tmp_path: Path) -> None:
|
||||||
|
absolute_mount = (tmp_path / "shared").resolve()
|
||||||
|
config_data = {
|
||||||
|
"sandbox": {
|
||||||
|
"use": "deerflow.sandbox.local:LocalSandboxProvider",
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"host_path": str(absolute_mount),
|
||||||
|
"container_path": "/mnt/shared",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig._resolve_relative_mount_paths(config_data, tmp_path / "elsewhere")
|
||||||
|
|
||||||
|
assert config_data["sandbox"]["mounts"][0]["host_path"] == str(absolute_mount)
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
"""Global strategy-agent role CRUD, permissions and visibility tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.gateway.routers import _strategy_selection_seed as seed
|
||||||
|
from app.gateway.routers import agents as agents_router
|
||||||
|
from app.gateway.routers import parallel_agents, strategy_agents
|
||||||
|
from deerflow.config import agents_config
|
||||||
|
from deerflow.config.paths import Paths
|
||||||
|
from deerflow.persistence.agents import MemoryAgentStore
|
||||||
|
from deerflow.persistence.skills.memory import MemorySkillStore
|
||||||
|
from deerflow.persistence.strategy_agent_roles import MemoryStrategyAgentRoleStore, StrategyAgentRoleRepository
|
||||||
|
from deerflow.persistence.strategy_agent_roles.model import StrategyAgentRoleRow
|
||||||
|
from deerflow.skills.types import Skill, SkillCategory
|
||||||
|
|
||||||
|
|
||||||
|
class _SkillStorage:
|
||||||
|
def __init__(self, skills: list[Skill]) -> None:
|
||||||
|
self._skills = skills
|
||||||
|
|
||||||
|
def load_skills(self, *, enabled_only: bool) -> list[Skill]:
|
||||||
|
if enabled_only:
|
||||||
|
return [skill for skill in self._skills if skill.enabled]
|
||||||
|
return list(self._skills)
|
||||||
|
|
||||||
|
|
||||||
|
def _skill(name: str, category: SkillCategory = SkillCategory.PUBLIC) -> Skill:
|
||||||
|
directory = Path("skills") / category.value / name
|
||||||
|
return Skill(
|
||||||
|
name=name,
|
||||||
|
description=name,
|
||||||
|
license=None,
|
||||||
|
skill_dir=directory,
|
||||||
|
skill_file=directory / "SKILL.md",
|
||||||
|
relative_path=Path(name),
|
||||||
|
category=category,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_agent_namespace_guard_is_case_insensitive_and_canonical() -> None:
|
||||||
|
assert agents_router._is_strategy_agent_id("STRATEGY-SELECTION-INTENT")
|
||||||
|
assert strategy_agents._process_lock("STRATEGY-SELECTION-INTENT") is strategy_agents._process_lock("strategy-selection-intent")
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
agents_router._require_canonical_record_id(
|
||||||
|
"STRATEGY-SELECTION-INTENT",
|
||||||
|
{"id": "strategy-selection-intent"},
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generic_mutations_reject_strategy_namespace_before_storage(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agents_router,
|
||||||
|
"get_agents_api_config",
|
||||||
|
lambda: SimpleNamespace(enabled=True),
|
||||||
|
)
|
||||||
|
request = SimpleNamespace()
|
||||||
|
with pytest.raises(HTTPException) as update_error:
|
||||||
|
await agents_router.update_agent(
|
||||||
|
seed.STRATEGY_INTENT_AGENT_ID.upper(),
|
||||||
|
agents_router.AgentUpdateRequest(name="bypass"),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert update_error.value.status_code == 409
|
||||||
|
assert "/api/strategy-agents/" in str(update_error.value.detail)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as delete_error:
|
||||||
|
await agents_router.delete_agent(
|
||||||
|
seed.STRATEGY_INTENT_AGENT_ID.upper(),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert delete_error.value.status_code == 409
|
||||||
|
assert "generic API" in str(delete_error.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strategy_mutation_rejects_case_insensitive_database_alias(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
canonical_id = seed.STRATEGY_INTENT_AGENT_ID
|
||||||
|
await role_store.ensure_defaults(seed.default_strategy_agent_roles())
|
||||||
|
role = await role_store.get_role(canonical_id)
|
||||||
|
record = await agent_store.get_any(canonical_id)
|
||||||
|
assert role is not None and record is not None
|
||||||
|
|
||||||
|
monkeypatch.setattr(strategy_agents, "_ensure_registry", lambda *_: _async_none())
|
||||||
|
monkeypatch.setattr(role_store, "get_role", lambda _: _async_value(role))
|
||||||
|
monkeypatch.setattr(agent_store, "get_any", lambda _: _async_value(record))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await strategy_agents._get_role_and_agent(request, canonical_id.upper())
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_none() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_value(value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
*,
|
||||||
|
role: str = "admin",
|
||||||
|
) -> tuple[SimpleNamespace, MemoryAgentStore, MemoryStrategyAgentRoleStore]:
|
||||||
|
paths = Paths(tmp_path)
|
||||||
|
monkeypatch.setattr(seed, "get_paths", lambda: paths)
|
||||||
|
monkeypatch.setattr(agents_router, "get_paths", lambda: paths)
|
||||||
|
monkeypatch.setattr(strategy_agents, "get_paths", lambda: paths)
|
||||||
|
monkeypatch.setattr(agents_config, "get_paths", lambda: paths)
|
||||||
|
seed.ensure_strategy_selection_agents()
|
||||||
|
|
||||||
|
agent_store = MemoryAgentStore()
|
||||||
|
for agent_id in seed.required_agent_ids():
|
||||||
|
config = yaml.safe_load((paths.agent_dir(agent_id) / "config.yaml").read_text(encoding="utf-8"))
|
||||||
|
await agent_store.ensure_builtin(
|
||||||
|
{
|
||||||
|
"id": agent_id,
|
||||||
|
"name": config["name"],
|
||||||
|
"description": config.get("description") or "",
|
||||||
|
"published": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
role_store = MemoryStrategyAgentRoleStore()
|
||||||
|
skill_store = MemorySkillStore()
|
||||||
|
storage = _SkillStorage(
|
||||||
|
[
|
||||||
|
_skill("public-skill"),
|
||||||
|
_skill("published-custom", SkillCategory.CUSTOM),
|
||||||
|
_skill("private-custom", SkillCategory.CUSTOM),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await skill_store.create(
|
||||||
|
{
|
||||||
|
"name": "published-custom",
|
||||||
|
"owner_user_id": "publisher",
|
||||||
|
"published": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await skill_store.create(
|
||||||
|
{
|
||||||
|
"name": "private-custom",
|
||||||
|
"owner_user_id": "owner",
|
||||||
|
"published": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(agents_router, "get_or_new_skill_storage", lambda **_: storage)
|
||||||
|
monkeypatch.setattr(agents_router, "get_skill_usage_entries", lambda: {})
|
||||||
|
|
||||||
|
state = SimpleNamespace(
|
||||||
|
agent_store=agent_store,
|
||||||
|
strategy_agent_role_store=role_store,
|
||||||
|
skill_store=skill_store,
|
||||||
|
config=SimpleNamespace(models=[SimpleNamespace(name="model-a")]),
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
app=SimpleNamespace(state=state),
|
||||||
|
state=SimpleNamespace(
|
||||||
|
user=SimpleNamespace(id="actor", system_role=role),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return request, agent_store, role_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_has_fixed_categories_and_redacts_public_config(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
public = await strategy_agents.list_strategy_agents(request)
|
||||||
|
|
||||||
|
assert len(public.agents) == 9
|
||||||
|
assert [(item.category, item.fixed) for item in public.agents if item.fixed] == [
|
||||||
|
("intent", True),
|
||||||
|
("judge", True),
|
||||||
|
("solution", True),
|
||||||
|
]
|
||||||
|
assert all(item.model is None and item.soul is None and item.skills is None for item in public.agents)
|
||||||
|
|
||||||
|
await role_store.update_role(seed.STRATEGY_SECURITY_AGENT_ID, enabled=False)
|
||||||
|
active = await strategy_agents.list_strategy_agents(request)
|
||||||
|
assert seed.STRATEGY_SECURITY_AGENT_ID not in {item.id for item in active.agents}
|
||||||
|
|
||||||
|
admin = await strategy_agents.list_strategy_agents(
|
||||||
|
request,
|
||||||
|
include_disabled=True,
|
||||||
|
include_config=True,
|
||||||
|
)
|
||||||
|
disabled = next(item for item in admin.agents if item.id == seed.STRATEGY_SECURITY_AGENT_ID)
|
||||||
|
assert disabled.enabled is False
|
||||||
|
assert disabled.soul
|
||||||
|
assert disabled.skills == ["policy-knowledge"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_rejects_user_owned_reserved_default(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, agent_store, _ = await _request(tmp_path, monkeypatch)
|
||||||
|
await agent_store.delete_builtin(seed.STRATEGY_INTENT_AGENT_ID)
|
||||||
|
await agent_store.create(
|
||||||
|
{
|
||||||
|
"id": seed.STRATEGY_INTENT_AGENT_ID,
|
||||||
|
"name": "collision",
|
||||||
|
"user_id": "attacker",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await strategy_agents.list_strategy_agents(request)
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
assert "owned by a user" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sensitive_catalog_and_writes_are_admin_only(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, _ = await _request(tmp_path, monkeypatch, role="user")
|
||||||
|
|
||||||
|
# The active catalog is intentionally readable by the workbench.
|
||||||
|
assert len((await strategy_agents.list_strategy_agents(request)).agents) == 9
|
||||||
|
with pytest.raises(HTTPException) as list_error:
|
||||||
|
await strategy_agents.list_strategy_agents(
|
||||||
|
request,
|
||||||
|
include_disabled=True,
|
||||||
|
include_config=True,
|
||||||
|
)
|
||||||
|
assert list_error.value.status_code == 403
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as create_error:
|
||||||
|
await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(name="非法新增"),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert create_error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_angle_crud_is_global_and_delete_is_soft(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
created = await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(
|
||||||
|
name="科技产业视角",
|
||||||
|
description="评估关键技术和产业链韧性",
|
||||||
|
soul="你是科技产业策略分析智能体。",
|
||||||
|
model="model-a",
|
||||||
|
skills=["public-skill", "published-custom"],
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.category == "angle"
|
||||||
|
assert created.fixed is False
|
||||||
|
assert created.enabled is True
|
||||||
|
assert created.id.startswith(seed.STRATEGY_ANGLE_AGENT_PREFIX)
|
||||||
|
assert created.model == "model-a"
|
||||||
|
assert created.skills == ["public-skill", "published-custom"]
|
||||||
|
|
||||||
|
updated = await strategy_agents.update_strategy_agent(
|
||||||
|
created.id,
|
||||||
|
strategy_agents.StrategyAgentUpdateRequest(
|
||||||
|
name="科技与产业链视角",
|
||||||
|
enabled=False,
|
||||||
|
sort_order=123,
|
||||||
|
skills=["public-skill"],
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert updated.name == "科技与产业链视角"
|
||||||
|
assert updated.enabled is False
|
||||||
|
assert updated.sort_order == 123
|
||||||
|
assert updated.skills == ["public-skill"]
|
||||||
|
|
||||||
|
deleted = await strategy_agents.disable_strategy_angle(created.id, request)
|
||||||
|
assert deleted.enabled is False
|
||||||
|
# Soft delete protects saved projects and permits an administrator to
|
||||||
|
# restore the angle without recreating its ID or files.
|
||||||
|
assert await agent_store.get_any(created.id) is not None
|
||||||
|
assert await role_store.get_role(created.id) is not None
|
||||||
|
restored = await strategy_agents.update_strategy_agent(
|
||||||
|
created.id,
|
||||||
|
strategy_agents.StrategyAgentUpdateRequest(enabled=True),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert restored.enabled is True
|
||||||
|
assert (tmp_path / "agents" / created.id / ".strategy-role.yaml").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_memory_mode_recovers_dynamic_angle_from_folder_sidecar(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, _ = await _request(tmp_path, monkeypatch)
|
||||||
|
created = await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(
|
||||||
|
name="恢复测试角度",
|
||||||
|
sort_order=177,
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
await strategy_agents.disable_strategy_angle(created.id, request)
|
||||||
|
|
||||||
|
request.app.state.agent_store = MemoryAgentStore()
|
||||||
|
request.app.state.strategy_agent_role_store = MemoryStrategyAgentRoleStore()
|
||||||
|
recovered = await strategy_agents.list_strategy_agents(
|
||||||
|
request,
|
||||||
|
include_disabled=True,
|
||||||
|
include_config=True,
|
||||||
|
)
|
||||||
|
item = next(entry for entry in recovered.agents if entry.id == created.id)
|
||||||
|
assert item.name == "恢复测试角度"
|
||||||
|
assert item.enabled is False
|
||||||
|
assert item.sort_order == 177
|
||||||
|
assert (await request.app.state.agent_store.get_any(created.id))["user_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_memory_mode_sidecar_keeps_default_angle_disabled_after_restart(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, _ = await _request(tmp_path, monkeypatch)
|
||||||
|
await strategy_agents.update_strategy_agent(
|
||||||
|
seed.STRATEGY_SECURITY_AGENT_ID,
|
||||||
|
strategy_agents.StrategyAgentUpdateRequest(enabled=False, sort_order=188),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
request.app.state.agent_store = MemoryAgentStore()
|
||||||
|
request.app.state.strategy_agent_role_store = MemoryStrategyAgentRoleStore()
|
||||||
|
with pytest.raises(HTTPException) as run_error:
|
||||||
|
await parallel_agents.run_parallel_agents_stream(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"task_name": "restart gate",
|
||||||
|
"brief": "disabled default must stay disabled",
|
||||||
|
"agent_ids": [seed.STRATEGY_SECURITY_AGENT_ID],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert run_error.value.status_code == 409
|
||||||
|
|
||||||
|
recovered = await strategy_agents.list_strategy_agents(
|
||||||
|
request,
|
||||||
|
include_disabled=True,
|
||||||
|
include_config=True,
|
||||||
|
)
|
||||||
|
item = next(entry for entry in recovered.agents if entry.id == seed.STRATEGY_SECURITY_AGENT_ID)
|
||||||
|
assert item.enabled is False
|
||||||
|
assert item.sort_order == 188
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancelled_create_compensates_every_persistence_layer(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
generated_id = f"{seed.STRATEGY_ANGLE_AGENT_PREFIX}cancelled"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
strategy_agents,
|
||||||
|
"uuid4",
|
||||||
|
lambda: SimpleNamespace(hex="cancelled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cancel_set_extras(*_args, **_kwargs):
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_store, "set_extras", cancel_set_extras)
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(name="cancelled create"),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await agent_store.get_any(generated_id) is None
|
||||||
|
assert await role_store.get_role(generated_id) is None
|
||||||
|
assert not (tmp_path / "agents" / generated_id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cross_process_file_lock_excludes_second_handle(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
paths = Paths(tmp_path)
|
||||||
|
monkeypatch.setattr(strategy_agents, "get_paths", lambda: paths)
|
||||||
|
first = strategy_agents._open_cross_process_lock(seed.STRATEGY_INTENT_AGENT_ID)
|
||||||
|
second = strategy_agents._open_cross_process_lock(seed.STRATEGY_INTENT_AGENT_ID)
|
||||||
|
try:
|
||||||
|
assert strategy_agents._try_cross_process_lock(first) is True
|
||||||
|
assert strategy_agents._try_cross_process_lock(second) is False
|
||||||
|
strategy_agents._release_cross_process_lock(first)
|
||||||
|
first = None
|
||||||
|
assert strategy_agents._try_cross_process_lock(second) is True
|
||||||
|
finally:
|
||||||
|
if first is not None:
|
||||||
|
strategy_agents._release_cross_process_lock(first)
|
||||||
|
if not second.closed:
|
||||||
|
strategy_agents._release_cross_process_lock(second)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_failure_restores_files_cache_and_role(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
created = await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(
|
||||||
|
name="事务前名称",
|
||||||
|
model="model-a",
|
||||||
|
skills=["public-skill"],
|
||||||
|
sort_order=166,
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
agent_dir = tmp_path / "agents" / created.id
|
||||||
|
before_files = {name: (agent_dir / name).read_text(encoding="utf-8") for name in ("config.yaml", "SOUL.md", ".strategy-role.yaml")}
|
||||||
|
real_update = role_store.update_role
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
async def fail_once(agent_id: str, **kwargs):
|
||||||
|
nonlocal failed
|
||||||
|
if kwargs.get("sort_order") == 188 and not failed:
|
||||||
|
failed = True
|
||||||
|
raise RuntimeError("injected role-store failure")
|
||||||
|
return await real_update(agent_id, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(role_store, "update_role", fail_once)
|
||||||
|
with pytest.raises(RuntimeError, match="injected role-store failure"):
|
||||||
|
await strategy_agents.update_strategy_agent(
|
||||||
|
created.id,
|
||||||
|
strategy_agents.StrategyAgentUpdateRequest(
|
||||||
|
name="不应留下的名称",
|
||||||
|
soul="不应留下的 SOUL",
|
||||||
|
skills=[],
|
||||||
|
sort_order=188,
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
record = await agent_store.get_any(created.id)
|
||||||
|
role = await role_store.get_role(created.id)
|
||||||
|
assert record is not None and record["name"] == "事务前名称"
|
||||||
|
assert role is not None and role["sort_order"] == 166
|
||||||
|
assert (await agent_store.get_extras_for([created.id]))[created.id]["skills"] == ["public-skill"]
|
||||||
|
assert {name: (agent_dir / name).read_text(encoding="utf-8") for name in ("config.yaml", "SOUL.md", ".strategy-role.yaml")} == before_files
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fixed_roles_cannot_be_disabled_or_deleted(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, _ = await _request(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as update_error:
|
||||||
|
await strategy_agents.update_strategy_agent(
|
||||||
|
seed.STRATEGY_INTENT_AGENT_ID,
|
||||||
|
strategy_agents.StrategyAgentUpdateRequest(enabled=False),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert update_error.value.status_code == 409
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as delete_error:
|
||||||
|
await strategy_agents.disable_strategy_angle(
|
||||||
|
seed.STRATEGY_JUDGE_AGENT_ID,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert delete_error.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_and_global_skill_visibility_are_validated(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, _ = await _request(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as model_error:
|
||||||
|
await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(
|
||||||
|
name="未知模型",
|
||||||
|
model="not-configured",
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert model_error.value.status_code == 422
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as skill_error:
|
||||||
|
await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(
|
||||||
|
name="私有技能",
|
||||||
|
skills=["private-custom"],
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert skill_error.value.status_code == 422
|
||||||
|
assert "globally available" in str(skill_error.value.detail)
|
||||||
|
|
||||||
|
defaulted = await strategy_agents.create_strategy_angle(
|
||||||
|
strategy_agents.StrategyAngleCreateRequest(
|
||||||
|
name="默认角色模板",
|
||||||
|
description="关注跨领域风险",
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
assert defaulted.soul
|
||||||
|
assert "默认角色模板" in defaulted.soul
|
||||||
|
assert "不替代其他角度" in defaulted.soul
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_strategy_agent_cannot_be_started_via_parallel_api(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, _, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
await role_store.ensure_defaults(seed.default_strategy_agent_roles())
|
||||||
|
await role_store.update_role(seed.STRATEGY_SECURITY_AGENT_ID, enabled=False)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await parallel_agents.run_parallel_agents_stream(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"task_name": "测试",
|
||||||
|
"brief": "验证停用闸门",
|
||||||
|
"agent_ids": [seed.STRATEGY_SECURITY_AGENT_ID],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_api_rejects_user_owned_strategy_backing_agent(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
|
||||||
|
await role_store.ensure_defaults(seed.default_strategy_agent_roles())
|
||||||
|
await agent_store.delete_builtin(seed.STRATEGY_SECURITY_AGENT_ID)
|
||||||
|
await agent_store.create(
|
||||||
|
{
|
||||||
|
"id": seed.STRATEGY_SECURITY_AGENT_ID,
|
||||||
|
"name": "collision",
|
||||||
|
"user_id": "attacker",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await parallel_agents.run_parallel_agents_stream(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"task_name": "测试",
|
||||||
|
"brief": "验证归属闸门",
|
||||||
|
"agent_ids": [seed.STRATEGY_SECURITY_AGENT_ID],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
assert "owned by a user" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_memory_registry_repairs_corrupted_fixed_role_metadata() -> None:
|
||||||
|
store = MemoryStrategyAgentRoleStore()
|
||||||
|
await store.create_role(
|
||||||
|
{
|
||||||
|
"agent_id": seed.STRATEGY_INTENT_AGENT_ID,
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": False,
|
||||||
|
"sort_order": 999,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await store.ensure_defaults(seed.default_strategy_agent_roles())
|
||||||
|
repaired = await store.get_role(seed.STRATEGY_INTENT_AGENT_ID)
|
||||||
|
assert repaired is not None
|
||||||
|
assert repaired["category"] == "intent"
|
||||||
|
assert repaired["fixed"] is True
|
||||||
|
assert repaired["enabled"] is True
|
||||||
|
assert repaired["sort_order"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sql_registry_preserves_disabled_defaults_and_soft_records(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'strategy-agent-roles.db'}")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(StrategyAgentRoleRow.__table__.create)
|
||||||
|
store = StrategyAgentRoleRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||||
|
try:
|
||||||
|
defaults = seed.default_strategy_agent_roles()
|
||||||
|
assert await store.ensure_defaults(defaults) == 9
|
||||||
|
assert await store.ensure_defaults(defaults) == 0
|
||||||
|
await store.update_role(seed.STRATEGY_SECURITY_AGENT_ID, enabled=False)
|
||||||
|
|
||||||
|
# Re-seeding must never undo an administrator's soft delete.
|
||||||
|
assert await store.ensure_defaults(defaults) == 0
|
||||||
|
disabled = await store.get_role(seed.STRATEGY_SECURITY_AGENT_ID)
|
||||||
|
assert disabled is not None
|
||||||
|
assert disabled["enabled"] is False
|
||||||
|
|
||||||
|
# Canonical fixed-role invariants are repaired, unlike angle lifecycle
|
||||||
|
# state which intentionally remains administrator-controlled.
|
||||||
|
async with store._sf() as session:
|
||||||
|
row = await session.get(
|
||||||
|
StrategyAgentRoleRow,
|
||||||
|
seed.STRATEGY_INTENT_AGENT_ID,
|
||||||
|
)
|
||||||
|
row.category = "angle"
|
||||||
|
row.fixed = False
|
||||||
|
row.enabled = False
|
||||||
|
row.sort_order = 999
|
||||||
|
await session.commit()
|
||||||
|
assert await store.ensure_defaults(defaults) == 0
|
||||||
|
repaired = await store.get_role(seed.STRATEGY_INTENT_AGENT_ID)
|
||||||
|
assert repaired is not None
|
||||||
|
assert repaired["category"] == "intent"
|
||||||
|
assert repaired["fixed"] is True
|
||||||
|
assert repaired["enabled"] is True
|
||||||
|
assert repaired["sort_order"] == 0
|
||||||
|
|
||||||
|
dynamic = await store.create_role(
|
||||||
|
{
|
||||||
|
"agent_id": "strategy-selection-angle-sql-test",
|
||||||
|
"category": "angle",
|
||||||
|
"fixed": False,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 150,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert dynamic["category"] == "angle"
|
||||||
|
assert await store.update_role(dynamic["agent_id"], enabled=False)
|
||||||
|
assert await store.delete_role(dynamic["agent_id"]) is True
|
||||||
|
assert await store.get_role(dynamic["agent_id"]) is None
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.gateway.routers import parallel_agent_tasks
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.parallel_agent_tasks import ParallelAgentTaskRepository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_task_repository_rejects_stale_revision(tmp_path) -> None:
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'strategy-project-cas.db'}")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
store = ParallelAgentTaskRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||||
|
try:
|
||||||
|
original = await store.create_task(
|
||||||
|
"user-1",
|
||||||
|
{
|
||||||
|
"id": "strategy-selection-cas",
|
||||||
|
"title": "original",
|
||||||
|
"messages": [{"snapshot": "original"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.002)
|
||||||
|
newer = await store.update_task(
|
||||||
|
"strategy-selection-cas",
|
||||||
|
"user-1",
|
||||||
|
title="newer",
|
||||||
|
messages=[{"snapshot": "newer"}],
|
||||||
|
expected_updated_at=original["updated_at"],
|
||||||
|
)
|
||||||
|
assert newer is not None
|
||||||
|
|
||||||
|
stale = await store.update_task(
|
||||||
|
"strategy-selection-cas",
|
||||||
|
"user-1",
|
||||||
|
title="stale",
|
||||||
|
messages=[{"snapshot": "stale"}],
|
||||||
|
expected_updated_at=original["updated_at"],
|
||||||
|
)
|
||||||
|
assert stale is None
|
||||||
|
final = await store.get_task("strategy-selection-cas", "user-1")
|
||||||
|
assert final is not None
|
||||||
|
assert final["title"] == "newer"
|
||||||
|
assert final["messages"] == [{"snapshot": "newer"}]
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_task_route_reports_revision_conflict() -> None:
|
||||||
|
class ConflictStore:
|
||||||
|
async def update_task(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_task(self, *_args, **_kwargs):
|
||||||
|
return {"id": "strategy-selection-cas"}
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
state=SimpleNamespace(user=SimpleNamespace(id="user-1")),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(parallel_agent_task_store=ConflictStore())),
|
||||||
|
)
|
||||||
|
body = parallel_agent_tasks.TaskUpdateRequest(
|
||||||
|
title="stale",
|
||||||
|
expected_updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await parallel_agent_tasks.update_task(
|
||||||
|
request,
|
||||||
|
"strategy-selection-cas",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
assert "其他页面更新" in str(error.value.detail)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.gateway.routers import system_settings as system_settings_router
|
||||||
|
from deerflow.config.system_settings import SystemSettings
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_project_demo_is_hidden_by_default() -> None:
|
||||||
|
assert SystemSettings().strategy_projects.taiwan_strait_demo_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_admin_cannot_update_strategy_project_settings(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
async def ordinary_user(_request):
|
||||||
|
return SimpleNamespace(system_role="user")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_settings_router,
|
||||||
|
"get_optional_user_from_request",
|
||||||
|
ordinary_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await system_settings_router.update_strategy_project_settings(
|
||||||
|
system_settings_router.StrategyProjectSettingsUpdateRequest(
|
||||||
|
taiwan_strait_demo_enabled=True,
|
||||||
|
),
|
||||||
|
SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_update_strategy_project_settings(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
current = SystemSettings()
|
||||||
|
saved: list[SystemSettings] = []
|
||||||
|
|
||||||
|
async def admin_user(_request):
|
||||||
|
return SimpleNamespace(system_role="admin")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_settings_router,
|
||||||
|
"get_optional_user_from_request",
|
||||||
|
admin_user,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_settings_router,
|
||||||
|
"load_system_settings",
|
||||||
|
lambda: current,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
system_settings_router,
|
||||||
|
"save_system_settings",
|
||||||
|
lambda settings: saved.append(settings) or settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await system_settings_router.update_strategy_project_settings(
|
||||||
|
system_settings_router.StrategyProjectSettingsUpdateRequest(
|
||||||
|
taiwan_strait_demo_enabled=True,
|
||||||
|
),
|
||||||
|
SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.taiwan_strait_demo_enabled is True
|
||||||
|
assert saved[0].strategy_projects.taiwan_strait_demo_enabled is True
|
||||||
@@ -131,6 +131,34 @@ def test_seed_creates_once_and_never_overwrites_admin_changes(tmp_paths: Paths)
|
|||||||
assert customized_soul.read_text(encoding="utf-8") == "管理员现场修改"
|
assert customized_soul.read_text(encoding="utf-8") == "管理员现场修改"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_upgrades_only_an_exact_known_legacy_default(
|
||||||
|
tmp_paths: Paths,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
assert seed.ensure_strategy_selection_agents()
|
||||||
|
intent_soul = tmp_paths.agent_dir(seed.STRATEGY_INTENT_AGENT_ID) / "SOUL.md"
|
||||||
|
legacy_content = b"legacy bundled intent soul\r\n"
|
||||||
|
intent_soul.write_bytes(legacy_content)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
seed._LEGACY_DEFAULT_SOUL_HASHES,
|
||||||
|
seed.STRATEGY_INTENT_AGENT_ID,
|
||||||
|
seed._normalized_text_hash(legacy_content),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert seed.ensure_strategy_selection_agents() == []
|
||||||
|
assert (
|
||||||
|
intent_soul.read_bytes()
|
||||||
|
== _asset(
|
||||||
|
seed.STRATEGY_INTENT_AGENT_ID,
|
||||||
|
"SOUL.md",
|
||||||
|
).read_bytes()
|
||||||
|
)
|
||||||
|
|
||||||
|
intent_soul.write_text("管理员现场修改", encoding="utf-8")
|
||||||
|
assert seed.ensure_strategy_selection_agents() == []
|
||||||
|
assert intent_soul.read_text(encoding="utf-8") == "管理员现场修改"
|
||||||
|
|
||||||
|
|
||||||
class _SkillStorage:
|
class _SkillStorage:
|
||||||
def __init__(self, skills: list[Skill]) -> None:
|
def __init__(self, skills: list[Skill]) -> None:
|
||||||
self._skills = skills
|
self._skills = skills
|
||||||
|
|||||||
Reference in new issue
Block a user