feat: add strategy selection workbench

This commit is contained in:
Administrator committed 2026-09-09 01:08:11 +08:00
1 parent 60e2c72514
commit 5fd2e8302c
34 files changed
+3188 -9

No files matched your search

@@ -68,6 +68,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
{
id: "strategy-selection",
label: "策略优选",
icon: Sparkles,
path: "/page/strategy/qa/strategy-selection",
},
// {
// id: "position-roundtable",
// label: "岗位会商",
@@ -78,6 +78,7 @@ import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
@@ -110,6 +111,7 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="qa/general" replace />} />
<Route path="qa/general" element={<GeneralQA />} />
<Route path="qa/strategy-selection" element={<StrategySelectionPage />} />
<Route
path="qa/expert-qa"
element={
@@ -0,0 +1,218 @@
import { useEffect, useMemo, useState } from "react";
import { Bot, Check, LoaderCircle, Save, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { getAgent, updateAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types";
import { cn } from "@/lib/utils";
import { STRATEGY_AGENT_ROLES } from "./constants";
interface AgentConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function parseSkills(value: string): string[] {
return [...new Set(value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean))];
}
export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps) {
const [selectedId, setSelectedId] = useState<string>(STRATEGY_AGENT_ROLES[0].id);
const [agent, setAgent] = useState<Agent | null>(null);
const [soul, setSoul] = useState("");
const [skillsText, setSkillsText] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const selectedRole = useMemo(
() => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!,
[selectedId],
);
useEffect(() => {
if (!open || !selectedId) return;
let active = true;
setLoading(true);
setError("");
setAgent(null);
void getAgent(selectedId)
.then((data) => {
if (!active) return;
setAgent(data);
setSoul(data.soul ?? "");
setSkillsText((data.skills ?? []).join("\n"));
})
.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 () => {
if (!agent) return;
setSaving(true);
try {
const updated = await updateAgent(agent.id, {
soul: soul.trim(),
skills: parseSkills(skillsText),
});
setAgent(updated);
setSoul(updated.soul ?? "");
setSkillsText((updated.skills ?? []).join("\n"));
toast.success(`${updated.name} 配置已保存`);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[min(780px,calc(100vh-48px))] max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1080px]">
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
<DialogTitle className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
策略智能体配置
</DialogTitle>
<DialogDescription>
每个流程角色对应一个默认智能体。管理员可修改 SOUL 和技能;保存后下一次运行即生效。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 grid-cols-[290px_minmax(0,1fr)]">
<div className="min-h-0 overflow-y-auto border-r bg-muted/20 p-3 dark:border-[#173d68] dark:bg-[#031b38]">
<div className="mb-2 px-2 text-xs font-semibold tracking-wide text-muted-foreground">
流程角色 · {STRATEGY_AGENT_ROLES.length}
</div>
<div className="space-y-1.5">
{STRATEGY_AGENT_ROLES.map((role) => {
const selected = role.id === selectedId;
return (
<button
key={role.id}
type="button"
disabled={saving}
onClick={() => setSelectedId(role.id)}
className={cn(
"w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary/45 bg-primary/10 shadow-sm"
: "border-transparent hover:border-border hover:bg-background dark:hover:border-[#28507c] dark:hover:bg-[#08274c]",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{role.name}</span>
{selected && <Check className="size-4 text-primary" />}
</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
{role.role}
</span>
</button>
);
})}
</div>
</div>
<div className="min-h-0 overflow-y-auto px-6 py-5">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold">{selectedRole.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{selectedRole.role}</p>
</div>
<Badge variant="outline" className="font-mono text-[11px]">
{selectedRole.id}
</Badge>
</div>
{loading ? (
<div className="flex h-56 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
正在读取智能体配置…
</div>
) : error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
<p className="font-medium">暂时无法加载该智能体</p>
<p className="mt-1 leading-6 opacity-85">{error}</p>
</div>
) : agent ? (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3 rounded-lg border bg-muted/20 p-3 text-sm dark:border-[#173d68] dark:bg-[#031b38]">
<div>
<span className="text-xs text-muted-foreground">当前名称</span>
<div className="mt-1 font-medium">{agent.name}</div>
</div>
<div>
<span className="text-xs text-muted-foreground">运行模型</span>
<div className="mt-1 font-medium">{agent.model || "使用系统默认模型"}</div>
</div>
</div>
<label className="block">
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-primary" /> SOUL
</span>
<Textarea
value={soul}
disabled={saving}
onChange={(event) => setSoul(event.target.value)}
className="min-h-[300px] resize-y font-mono text-xs leading-6"
placeholder="定义智能体的角色、分析方法、边界和输出要求…"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium">技能</span>
<Textarea
value={skillsText}
disabled={saving}
onChange={(event) => setSkillsText(event.target.value)}
placeholder="多个技能可用逗号分隔"
rows={3}
className="resize-y text-sm"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
可用逗号、中文逗号或换行分隔;留空表示不绑定专用技能。
</p>
</label>
</div>
) : null}
</div>
</div>
<DialogFooter className="border-t px-6 py-4 dark:border-[#173d68]">
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button disabled={!agent || loading || saving} onClick={() => void save()}>
{saving ? <LoaderCircle className="animate-spin" /> : <Save />}
保存当前智能体
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,44 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { parseSseChunk } from "./lib";
import type { ParallelAgentEvent, ParallelAgentPayload } from "./types";
async function readError(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as
| { detail?: string }
| null;
return body?.detail || `请求失败(HTTP ${response.status})`;
}
export async function streamParallelAgents(
payload: ParallelAgentPayload,
onEvent: (event: ParallelAgentEvent) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/parallel-agents/run/stream`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal,
},
);
if (!response.ok) throw new Error(await readError(response));
if (!response.body) throw new Error("服务器未返回流式响应");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const parsed = parseSseChunk(buffer, done);
buffer = parsed.rest;
parsed.events.forEach(onEvent);
if (done) break;
}
}
@@ -0,0 +1,350 @@
import type {
AgentOutputState,
IntentTurn,
StrategyAngle,
StrategyEvaluation,
StrategyTaskDraft,
} from "./types";
export const STRATEGY_AGENT_ROLES = [
{
id: "strategy-selection-intent",
name: "意图理解智能体",
role: "将自然语言需求整理为可执行任务",
},
{
id: "strategy-selection-angle-governance",
name: "治理统筹智能体",
role: "关注目标、机制、跨部门协同与执行闭环",
},
{
id: "strategy-selection-angle-diplomacy",
name: "外交沟通智能体",
role: "关注沟通渠道、危机管控与多边协调",
},
{
id: "strategy-selection-angle-economy",
name: "经济韧性智能体",
role: "关注供应链、产业、贸易与市场预期",
},
{
id: "strategy-selection-angle-security",
name: "安全风险智能体",
role: "关注预警、误判防范与非升级性风险处置",
},
{
id: "strategy-selection-angle-public-opinion",
name: "社会民生智能体",
role: "关注公众沟通、民生连续性与人道保障",
},
{
id: "strategy-selection-angle-law-policy",
name: "法律规则智能体",
role: "关注适用规则、合规边界与证据链",
},
{
id: "strategy-selection-judge",
name: "策略评判智能体",
role: "以统一量表比较策略的收益、风险与可执行性",
},
{
id: "strategy-selection-solution",
name: "方案整理智能体",
role: "将入选策略去重、消冲突并整理为最终方案",
},
] as const;
export const STRATEGY_ANGLES: StrategyAngle[] = [
{
id: "governance",
agentId: "strategy-selection-angle-governance",
name: "治理与统筹",
shortName: "治理",
description: "目标拆解、组织协同、阶段推进和反馈闭环",
accent: "#6d5dfc",
},
{
id: "diplomacy",
agentId: "strategy-selection-angle-diplomacy",
name: "外交与沟通",
shortName: "外交",
description: "危机沟通、多边协调、信号一致性与降温安排",
accent: "#0ea5e9",
},
{
id: "economy",
agentId: "strategy-selection-angle-economy",
name: "经济与产业韧性",
shortName: "经济",
description: "供应链连续性、市场预期、贸易和关键产业保障",
accent: "#10b981",
},
{
id: "security",
agentId: "strategy-selection-angle-security",
name: "安全与风险管控",
shortName: "安全",
description: "态势预警、误判防范、事件分级和非升级性处置",
accent: "#f97316",
},
{
id: "public_opinion",
agentId: "strategy-selection-angle-public-opinion",
name: "社会民生与舆情",
shortName: "民生",
description: "公众沟通、基本服务、社会心理和人道保障",
accent: "#ec4899",
},
{
id: "law_policy",
agentId: "strategy-selection-angle-law-policy",
name: "法律与规则",
shortName: "法律",
description: "适用规则、合规审查、责任边界与证据留存",
accent: "#a855f7",
},
];
export const EMPTY_TASK_DRAFT: StrategyTaskDraft = {
name: "",
detail: "",
objective: "",
prerequisites: "",
constraints: "",
deliverables: "",
acceptanceCriteria: "",
horizon: "",
selectedAngleIds: STRATEGY_ANGLES.slice(0, 4).map((angle) => angle.id),
};
export const INITIAL_INTENT_TURNS: IntentTurn[] = [
{
id: "welcome",
role: "assistant",
content:
"请描述你想解决的问题、希望达成的目标以及已知约束。我会通过对话补齐关键信息,并把需求实时整理到右侧任务草稿。",
},
];
export const TAIWAN_STRAIT_DEMO_TASK: StrategyTaskDraft = {
name: "台海局势风险管控与区域韧性提升策略",
detail:
"面向台海局势高不确定性场景,形成一套不预设立场、以避免误判和升级为核心的综合政策方案。方案聚焦危机沟通、区域经济与民生连续性、规则遵循及信息透明,不包含战术行动、目标选择或武力运用指导。",
objective:
"未来12—24个月内,降低突发事件转化为系统性危机的概率,保护平民福祉与关键经济活动,维持可验证、可持续的沟通和协调机制。",
prerequisites:
"建立跨部门联合研判机制;明确授权与对外沟通口径;掌握航运、能源、金融、通信和公共服务的基础风险数据;预留与各相关方沟通的技术渠道。",
constraints:
"遵守适用的国际法及人道原则;坚持克制、对等、可逆和非升级;不得输出军事战术、具体目标、武器使用或规避监管建议;关键事实须标注时点并交叉核验。",
deliverables:
"一份综合策略;分阶段行动清单;跨部门责任矩阵;风险预警指标;对外沟通要点;每月复盘和动态调整机制。",
acceptanceCriteria:
"措施均有责任主体、触发条件和退出条件;重要建议至少经两个角度交叉验证;高风险措施设置法律审查与升级门槛;方案可在72小时内启动首轮响应。",
horizon: "12—24个月,按0—30天、1—6个月、6—24个月分三阶段推进",
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
};
export const TAIWAN_STRAIT_DEMO_TURNS: IntentTurn[] = [
...INITIAL_INTENT_TURNS,
{
id: "demo-user",
role: "user",
isDemo: true,
content:
"请围绕台海局势做一个综合风险管控策略,重点是减少误判升级,同时兼顾经济、民生和规则约束,时间范围看未来一到两年。",
},
{
id: "demo-assistant",
role: "assistant",
isDemo: true,
content:
"已将需求整理为“风险管控与区域韧性提升”任务:以降级避险为目标,采用治理、外交、经济、安全、民生和法律六个角度并行论证;明确排除战术性军事建议,并补充了阶段周期、验收标准和启动条件。右侧字段仍可直接修改。",
},
];
export const TAIWAN_STRAIT_DEMO_OUTPUTS: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": {
status: "done",
isDemo: true,
steps: ["拆解目标", "形成协同机制", "设置复盘闭环"],
content: `### 治理统筹策略:建立“联合研判—分级响应—复盘校正”闭环
1. 设立常态化跨部门风险研判单元,统一风险词典、数据口径和事件分级。
2. 为不同风险等级预先定义责任主体、会商时限、可逆措施和退出条件,避免临时决策放大误判。
3. 建立月度桌面推演与季度复盘,重点校验交通、能源、金融、通信和公共服务之间的级联风险。
4. 重大调整实行“政策效果—升级风险—民生影响—法律合规”四项并联审查。
**优先动作:** 30天内完成责任矩阵和跨部门值守清单;90天内完成首次全链路压力测试。`,
},
"strategy-selection-angle-diplomacy": {
status: "done",
isDemo: true,
steps: ["识别沟通对象", "设计危机渠道", "校准公开信号"],
content: `### 外交沟通策略:保持多层次、可验证的危机沟通
1. 维护工作层、专业部门和高层三类沟通渠道,约定紧急事件的确认窗口与回执机制。
2. 对外表述采用“事实、原则、下一步安排”三段式,减少模糊威慑和相互误读。
3. 支持以航行安全、灾害救援、公共卫生等低政治敏感议题开展技术性接触,积累最低限度信任。
4. 引入可信第三方作为信息转递与事实核验的备份通道,但不替代直接沟通。
**触发条件:** 当出现信息不一致、渠道中断或舆情快速放大时,立即启动双渠道交叉确认。`,
},
"strategy-selection-angle-economy": {
status: "done",
isDemo: true,
steps: ["识别关键链路", "评估替代能力", "配置韧性措施"],
content: `### 经济韧性策略:先保连续性,再做结构性分散
1. 对航运、半导体、能源、金融结算和通信链路建立关键节点清单与中断情景。
2. 以库存天数、替代供应商覆盖率、运输改道时间和流动性缺口作为月度韧性指标。
3. 对短期冲击优先使用信息协调、应急库存和融资支持,避免仓促、不可逆的全面脱钩措施。
4. 中长期推动来源与路线多元化,同时保留正常经贸交往的政策空间。
**优先动作:** 对关键民生物资开展30/60/90天压力测试,并为中小企业准备定向流动性工具。`,
},
"strategy-selection-angle-security": {
status: "done",
isDemo: true,
steps: ["构建预警指标", "划分风险等级", "设计降级动作"],
content: `### 安全风险策略:以预警和降级机制减少偶发事件外溢
1. 建立覆盖异常活动、通信中断、错误信息扩散、交通受阻和市场波动的综合预警看板。
2. 将风险划分为关注、紧张、严重三级,每级只配置克制、可逆、可解释的非升级性应对动作。
3. 对来源未核实的信息实行“双源确认+时点标注”,避免单一信号直接触发重大政策响应。
4. 所有高风险决策设置冷静期、法律复核和跨部门反方意见席位。
**止损线:** 一旦沟通渠道失效或民生影响超过阈值,优先恢复沟通与公共服务,暂停可能叠加风险的新措施。`,
},
"strategy-selection-angle-public-opinion": {
status: "done",
isDemo: true,
steps: ["分析公众需求", "保障基本服务", "设计信息发布"],
content: `### 社会民生策略:稳定预期并保护基本服务
1. 建立权威、固定频率的信息发布窗口,明确已知、未知和正在核验的事项。
2. 为交通、医疗、能源、通信和教育准备连续性方案,优先保障脆弱群体。
3. 监测谣言与恐慌性行为时,采用事实澄清和服务指引,不扩大未经证实的信息。
4. 设置独立反馈渠道,收集企业、社区和民众的实际困难并纳入每日研判。
**评价指标:** 基本服务可用率、权威信息触达率、谣言纠正时长和重点群体保障覆盖率。`,
},
"strategy-selection-angle-law-policy": {
status: "done",
isDemo: true,
steps: ["识别规则边界", "建立审查清单", "设计留痕机制"],
content: `### 法律规则策略:把合规审查嵌入每个决策节点
1. 为数据共享、经济措施、应急管理和对外沟通分别建立适用规则清单。
2. 重大措施记录事实依据、授权来源、比例性判断、影响评估和退出条件。
3. 涉及平民权益、基本服务和跨境活动的措施优先进行人道影响评估。
4. 对时效性强的事项采用“快速初审—事后完整复核”,但不得跳过最低合规门槛。
**优先动作:** 建立统一法律审查模板和决策证据包,保证后续可解释、可追溯。`,
},
};
export const TAIWAN_STRAIT_DEMO_EVALUATIONS: StrategyEvaluation[] = [
{
agentId: "strategy-selection-angle-governance",
score: 92,
conclusion: "优先推荐",
comment: "跨部门闭环清晰,是其他策略落地的共同底座。",
strengths: ["责任明确", "阶段完整", "便于复盘"],
risks: ["需要稳定的牵头机制"],
},
{
agentId: "strategy-selection-angle-diplomacy",
score: 90,
conclusion: "优先推荐",
comment: "直接作用于误判风险,且措施克制、可逆。",
strengths: ["降级效果直接", "渠道有备份"],
risks: ["依赖相关方持续参与"],
},
{
agentId: "strategy-selection-angle-economy",
score: 86,
conclusion: "建议采用",
comment: "兼顾短期连续性与长期韧性,需控制过度防御成本。",
strengths: ["指标可量化", "避免一刀切"],
risks: ["跨行业数据获取成本较高"],
},
{
agentId: "strategy-selection-angle-security",
score: 91,
conclusion: "优先推荐",
comment: "预警、确认和冷静期设计可显著降低错误触发。",
strengths: ["止损线清楚", "强调双源核验"],
risks: ["阈值需持续校准"],
},
{
agentId: "strategy-selection-angle-public-opinion",
score: 84,
conclusion: "建议采用",
comment: "增强社会承压能力,建议与经济连续性方案合并实施。",
strengths: ["关注脆弱群体", "沟通指标明确"],
risks: ["需避免信息发布碎片化"],
},
{
agentId: "strategy-selection-angle-law-policy",
score: 88,
conclusion: "建议采用",
comment: "为高压情境下的决策提供必要边界和可追溯性。",
strengths: ["审查嵌入流程", "留痕充分"],
risks: ["快速响应与完整审查需平衡"],
},
];
export const TAIWAN_STRAIT_DEMO_SYNTHESIS = `# 台海局势风险管控与区域韧性提升综合方案
> **示例说明:** 本方案为产品功能演示数据,采用中立、降级避险和民生优先视角,不构成现实决策建议;正式使用前应依据最新可靠信息进行专业复核。
## 一、总体目标
在未来12—24个月内,以“减少误判、阻断升级、保护民生、维持连续性”为主线,建立可监测、可启动、可退出、可复盘的风险管控体系。任何措施均应符合适用规则,坚持克制、对等、可逆和可解释。
## 二、四条主策略
### 1. 联合研判与分级响应
- 建立跨部门常态研判单元,统一风险词典、数据口径和三级事件标准。
- 每个级别明确牵头主体、会商时限、启动阈值、允许动作和退出条件。
- 重大建议同时接受政策效果、升级风险、民生影响和法律合规审查。
### 2. 多层危机沟通与事实核验
- 维护工作层、专业部门和高层沟通渠道,并配置可信第三方备份。
- 紧急事项执行“双渠道回执、双来源确认、统一时点标注”。
- 公开沟通采用“事实—原则—下一步安排”结构,降低信号歧义。
### 3. 经济与公共服务连续性
- 围绕航运、能源、金融、通信、医疗和关键供应链建立中断情景清单。
- 用库存天数、替代覆盖率、改道时长、服务可用率等指标开展月度压力测试。
- 短期优先使用应急库存、信息协调和定向融资;中长期有序提升来源与路线多元化。
### 4. 规则嵌入与社会韧性
- 将授权来源、比例性、人道影响、退出条件和证据留存纳入统一审查模板。
- 通过固定频率的权威发布说明已知、未知和核验进度,提供清晰服务指引。
- 优先保障脆弱群体,并建立企业、社区与公众反馈的快速回流机制。
## 三、阶段安排
| 阶段 | 重点动作 | 主要产出 |
| --- | --- | --- |
| 0—30天 | 完成责任矩阵、沟通清单、关键链路盘点与法律审查模板 | 一图一表一清单、首版预警看板 |
| 1—6个月 | 开展压力测试和桌面推演,打通双渠道确认与公众反馈 | 演练报告、阈值校准、改进台账 |
| 6—24个月 | 推进结构性韧性项目并季度复盘,动态退出低效措施 | 韧性提升项目、季度评估报告 |
## 四、核心预警指标
1. 沟通渠道可用率与紧急事项回执时长。
2. 未核实信息传播速度及权威澄清时长。
3. 关键运输、能源、金融、通信和公共服务可用率。
4. 市场异常波动、关键物资库存天数与替代供应覆盖率。
5. 脆弱群体保障覆盖率和公众反馈闭环时长。
## 五、止损与调整机制
当直接沟通失效、基本服务明显下降、信息可信度不足或风险指标跨级跃升时,优先恢复沟通、事实核验和民生服务;暂停新增高风险措施。每月复盘策略组合,每季度由独立评估组检查目标偏移、措施叠加和退出条件执行情况。
`;
@@ -0,0 +1,110 @@
/**
* Run from frontend-web:
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.tmp-tests/strategy-selection.test.mjs
* node --test .tmp-tests/strategy-selection.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
buildJudgeBrief,
createFallbackEvaluations,
extractFencedJson,
parseEvaluationsFromText,
parseSseChunk,
parseTaskDraftFromText,
stripStructuredBlocks,
} from "./lib";
import { EMPTY_TASK_DRAFT } from "./constants";
import type { AgentOutputState } from "./types";
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
const first = parseSseChunk(
'data: {"type":"agent_start","agent_id":"a"}\n\ndata: {"type":"del',
);
assert.equal(first.events.length, 1);
assert.equal(first.events[0].type, "agent_start");
const second = parseSseChunk(`${first.rest}ta","agent_id":"a","text":"你好"}\n\n`);
assert.deepEqual(second.events, [
{ type: "delta", agent_id: "a", text: "你好" },
]);
});
test("stripStructuredBlocks keeps prose and hides machine-readable payloads", () => {
assert.equal(
stripStructuredBlocks('说明文字\n[INTENT_READY]\n```strategy-task-json\n{"name":"x"}\n```'),
"说明文字",
);
});
test("extractFencedJson and parseTaskDraftFromText read the structured task block", () => {
const text = [
"已整理任务。",
"```strategy-task-json",
JSON.stringify({
name: "测试任务",
detail: "详情",
recommended_angles: ["governance", "unknown", "law_policy"],
}),
"```",
].join("\n");
assert.deepEqual(extractFencedJson(text, "strategy-task-json"), {
name: "测试任务",
detail: "详情",
recommended_angles: ["governance", "unknown", "law_policy"],
});
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
assert.equal(task?.name, "测试任务");
assert.deepEqual(task?.selectedAngleIds, ["governance", "law_policy"]);
});
test("task parser accepts backend SOUL field aliases", () => {
const text = `\`\`\`strategy-task-json\n${JSON.stringify({
task_name: "别名任务",
task_detail: "别名详情",
expected_outputs: "综合方案",
recommended_angles: ["public_opinion", "strategy-selection-angle-law-policy"],
})}\n\`\`\``;
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
assert.equal(task?.name, "别名任务");
assert.equal(task?.detail, "别名详情");
assert.equal(task?.deliverables, "综合方案");
assert.deepEqual(task?.selectedAngleIds, ["public_opinion", "law_policy"]);
});
test("evaluation parser normalizes scores and fills missing candidates", () => {
const outputs: Record<string, AgentOutputState> = {
"strategy-selection-angle-governance": {
status: "done",
content: "A",
steps: [],
},
"strategy-selection-angle-law-policy": { status: "done", content: "B", steps: [] },
"strategy-selection-angle-security": {
status: "error",
content: "PARTIAL_FAILED_OUTPUT",
steps: [],
error: "failed",
},
};
const text = `\`\`\`strategy-evaluation-json\n${JSON.stringify({
evaluations: [
{
agentId: "strategy-selection-angle-governance",
score: 101,
conclusion: "优先推荐",
comment: "清晰",
},
],
})}\n\`\`\``;
const evaluations = parseEvaluationsFromText(text, outputs);
assert.equal(evaluations.length, 2);
assert.equal(evaluations[0].score, 100);
assert.ok(
evaluations.some(
(item) => item.agentId === "strategy-selection-angle-law-policy",
),
);
assert.equal(createFallbackEvaluations(outputs).length, 2);
assert.equal(buildJudgeBrief(EMPTY_TASK_DRAFT, outputs).includes("PARTIAL_FAILED_OUTPUT"), false);
});
+266
View File
@@ -0,0 +1,266 @@
import { STRATEGY_ANGLES } from "./constants";
import type {
AgentOutputState,
IntentTurn,
ParallelAgentEvent,
StrategyEvaluation,
StrategyTaskDraft,
} from "./types";
const clampScore = (value: unknown): number => {
const score = Number(value);
if (!Number.isFinite(score)) return 80;
return Math.min(100, Math.max(0, Math.round(score)));
};
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(stringValue).filter(Boolean);
}
export function extractFencedJson<T>(text: string, marker?: string): T | null {
const escaped = marker?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
escaped ? new RegExp("```" + escaped + "\\s*([\\s\\S]*?)```", "i") : null,
/```(?:json)?\s*([\s\S]*?)```/i,
].filter(Boolean) as RegExp[];
for (const pattern of patterns) {
const match = pattern.exec(text);
if (!match?.[1]) continue;
try {
return JSON.parse(match[1].trim()) as T;
} catch {
// The model may have emitted another fenced block first; fall through to
// the balanced-object fallback below.
}
}
const firstObject = text.indexOf("{");
const lastObject = text.lastIndexOf("}");
if (firstObject >= 0 && lastObject > firstObject) {
try {
return JSON.parse(text.slice(firstObject, lastObject + 1)) as T;
} catch {
return null;
}
}
return null;
}
export function stripStructuredBlocks(text: string): string {
return text
.replace(/```(?:strategy-task-json|strategy-evaluation-json)\s*[\s\S]*?```/gi, "")
.replace(/\[(?:INTENT_READY|JUDGEMENT_READY)\]/gi, "")
.trim();
}
export function parseTaskDraftFromText(
text: string,
fallback: StrategyTaskDraft,
): StrategyTaskDraft | null {
const parsed = extractFencedJson<Record<string, unknown>>(text, "strategy-task-json");
if (!parsed || typeof parsed !== "object") return null;
const rawAngles = parsed.selectedAngleIds ?? parsed.recommended_angles;
const angleIds = Array.isArray(rawAngles)
? rawAngles
.map(stringValue)
.map((value) => {
const normalized = value.toLowerCase().replaceAll("-", "_");
return STRATEGY_ANGLES.find(
(angle) =>
angle.id === normalized ||
angle.agentId === value ||
angle.name === value ||
angle.shortName === value,
)?.id;
})
.filter((id): id is string => Boolean(id))
: fallback.selectedAngleIds;
return {
name: stringValue(parsed.name ?? parsed.task_name) || fallback.name,
detail: stringValue(parsed.detail ?? parsed.task_detail) || fallback.detail,
objective: stringValue(parsed.objective) || fallback.objective,
prerequisites:
stringValue(parsed.prerequisites) || fallback.prerequisites,
constraints: stringValue(parsed.constraints) || fallback.constraints,
deliverables:
stringValue(parsed.deliverables ?? parsed.expected_outputs) || fallback.deliverables,
acceptanceCriteria:
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
horizon: stringValue(parsed.horizon) || fallback.horizon,
selectedAngleIds: angleIds.length ? angleIds : fallback.selectedAngleIds,
};
}
export function createFallbackEvaluations(
outputs: Record<string, AgentOutputState>,
): StrategyEvaluation[] {
return STRATEGY_ANGLES.filter(
(angle) =>
outputs[angle.agentId]?.status === "done" &&
outputs[angle.agentId]?.content.trim(),
).map((angle, index) => ({
agentId: angle.agentId,
score: Math.max(76, 88 - index),
conclusion: index < 2 ? "优先推荐" : "建议采用",
comment: `${angle.name}策略已形成完整交付,建议结合其他入选策略做冲突消解与阶段排序。`,
strengths: ["角度明确", "具备行动结构"],
risks: ["关键阈值仍需结合最新信息复核"],
}),
);
}
export function parseEvaluationsFromText(
text: string,
outputs: Record<string, AgentOutputState>,
): StrategyEvaluation[] {
const parsed = extractFencedJson<
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
>(text, "strategy-evaluation-json");
const rows = Array.isArray(parsed) ? parsed : parsed?.evaluations;
if (!Array.isArray(rows)) return createFallbackEvaluations(outputs);
const knownIds = new Set(STRATEGY_ANGLES.map((angle) => angle.agentId));
const evaluations = rows
.map((row): StrategyEvaluation | null => {
const agentId = stringValue(row.agentId ?? row.agent_id);
if (
!knownIds.has(agentId) ||
outputs[agentId]?.status !== "done" ||
!outputs[agentId]?.content.trim()
) return null;
const rawConclusion = stringValue(row.conclusion);
const conclusion: StrategyEvaluation["conclusion"] =
rawConclusion === "优先推荐" ||
rawConclusion === "建议采用" ||
rawConclusion === "保留备选"
? rawConclusion
: clampScore(row.score) >= 88
? "优先推荐"
: clampScore(row.score) >= 78
? "建议采用"
: "保留备选";
return {
agentId,
score: clampScore(row.score),
conclusion,
comment: stringValue(row.comment) || "请结合完整评判正文审阅。",
strengths: stringList(row.strengths),
risks: stringList(row.risks),
};
})
.filter((item): item is StrategyEvaluation => item !== null);
if (!evaluations.length) return createFallbackEvaluations(outputs);
const byAgent = new Map(evaluations.map((item) => [item.agentId, item]));
for (const fallback of createFallbackEvaluations(outputs)) {
if (!byAgent.has(fallback.agentId)) byAgent.set(fallback.agentId, fallback);
}
return [...byAgent.values()].sort((a, b) => b.score - a.score);
}
export function buildIntentBrief(
turns: IntentTurn[],
currentDraft: StrategyTaskDraft,
): string {
const conversation = turns
.filter((turn) => turn.content.trim())
.map((turn) => `${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`)
.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只选择与任务有关的角度。`;
}
export function buildStrategyBrief(
task: StrategyTaskDraft,
selectedAngles = STRATEGY_ANGLES.filter((angle) =>
task.selectedAngleIds.includes(angle.id),
),
): string {
const angles = selectedAngles
.map((angle) => `- ${angle.name}:${angle.description}`)
.join("\n");
return `你是其中一个专业角度的策略生成智能体,只从自己的职责角度输出一套可独立审阅的策略。\n\n任务详情:\n${JSON.stringify(task, null, 2)}\n\n本轮并行角度:\n${angles}\n\n请给出:核心判断、3—6项策略动作、实施阶段、触发/退出条件、指标、主要风险与协同依赖。避免空泛口号;涉及动态事实时说明需复核。对于地缘政治议题,坚持中立、降级避险、民生优先,不输出军事战术、目标选择、武器运用或规避监管建议。`;
}
export function buildJudgeBrief(
task: StrategyTaskDraft,
outputs: Record<string, AgentOutputState>,
): string {
const strategies = STRATEGY_ANGLES.filter(
(angle) =>
outputs[angle.agentId]?.status === "done" &&
outputs[angle.agentId]?.content.trim(),
)
.map(
(angle) =>
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
)
.join("\n\n");
return `你是独立策略评判智能体。请依据任务目标,对候选策略的目标贡献、可执行性、风险可控性、协同性和合规性进行比较。先输出总体评判与冲突提醒,再在末尾输出结构化量表。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n候选策略:\n${strategies}\n\n末尾必须追加:\n\`\`\`strategy-evaluation-json\n{"evaluations":[{"agentId":"候选agentId","score":0,"conclusion":"优先推荐|建议采用|保留备选","comment":"一句评价","strengths":["优势"],"risks":["风险"]}]}\n\`\`\`\n必须覆盖每个候选策略,score为0—100整数。`;
}
export function buildSynthesisBrief(
task: StrategyTaskDraft,
selectedAgentIds: string[],
outputs: Record<string, AgentOutputState>,
evaluations: StrategyEvaluation[],
): string {
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
const selections = selectedAgentIds
.map((agentId) => {
const angle = STRATEGY_ANGLES.find((item) => item.agentId === agentId);
const evaluation = evaluationMap.get(agentId);
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
})
.join("\n\n");
return `你是方案整理智能体。请将用户选中的策略消除重复、处理冲突、明确优先级和依赖关系,整理为一份可直接下载的完整 Markdown 方案。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n入选策略:\n${selections}\n\n只输出最终 Markdown 正文。至少包含:执行摘要、目标与原则、综合策略、阶段路线图、责任与协同、指标与预警、风险及止损机制、待核验信息。不要输出代码围栏。对于地缘政治议题坚持中立、降级避险、民生优先,禁止军事战术和伤害性操作建议。`;
}
export function parseSseChunk(
buffer: string,
flush = false,
): { events: ParallelAgentEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = flush ? "" : (parts.pop() ?? "");
const complete = flush ? parts.concat(parts.length ? [] : [normalized]) : parts;
const events: ParallelAgentEvent[] = [];
for (const frame of complete) {
const payload = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!payload || payload === "[DONE]") continue;
try {
const event = JSON.parse(payload) as ParallelAgentEvent;
if (event && typeof event.type === "string") events.push(event);
} catch {
// Ignore one malformed frame instead of discarding the rest of a stream.
}
}
return { events, rest };
}
export function downloadMarkdown(markdown: string, fileName: string): void {
const cleanName =
fileName.replace(/\.(md|markdown)$/i, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim() ||
"strategy-plan";
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${cleanName}.md`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
@@ -0,0 +1,81 @@
export type StrategyStage = "intent" | "workspace";
export type AgentRunStatus =
| "idle"
| "queued"
| "running"
| "done"
| "error";
export interface StrategyAngle {
id: string;
agentId: string;
name: string;
shortName: string;
description: string;
accent: string;
}
export interface StrategyTaskDraft {
name: string;
detail: string;
objective: string;
prerequisites: string;
constraints: string;
deliverables: string;
acceptanceCriteria: string;
horizon: string;
selectedAngleIds: string[];
}
export interface IntentTurn {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
isDemo?: boolean;
}
export interface AgentOutputState {
status: AgentRunStatus;
content: string;
steps: string[];
error?: string;
isDemo?: boolean;
}
export interface StrategyEvaluation {
agentId: string;
score: number;
conclusion: "优先推荐" | "建议采用" | "保留备选";
comment: string;
strengths: string[];
risks: string[];
}
export interface ParallelAgentPayload {
task_name: string;
brief: string;
agent_ids: string[];
model?: string;
}
export type ParallelAgentEvent =
| { type: "agent_start"; agent_id: string }
| { type: "delta"; agent_id: string; text: string }
| {
type: "step";
agent_id: string;
key?: string;
label?: string;
args?: Record<string, unknown>;
}
| { type: "agent_done"; agent_id: string; content: string }
| { type: "agent_error"; agent_id: string; message: string }
| {
type: "agent_clarification";
agent_id: string;
content?: string;
question?: string;
}
| { type: "all_done" };