337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { apiFetch } from "@/core/api/fetch-client";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
import { createClientId } from "@/lib/client-id";
|
||
|
||
import {
|
||
EMPTY_TASK_DRAFT,
|
||
INITIAL_INTENT_TURNS,
|
||
STRATEGY_ANGLES,
|
||
TAIWAN_STRAIT_DEMO_EVALUATIONS,
|
||
TAIWAN_STRAIT_DEMO_OUTPUTS,
|
||
TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||
TAIWAN_STRAIT_DEMO_TASK,
|
||
TAIWAN_STRAIT_DEMO_TURNS,
|
||
} from "./constants";
|
||
import type {
|
||
AgentOutputState,
|
||
IntentTurn,
|
||
StrategyEvaluation,
|
||
StrategyStage,
|
||
StrategyTaskDraft,
|
||
} from "./types";
|
||
|
||
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
|
||
const SNAPSHOT_KIND = "strategy-selection-project";
|
||
|
||
const DEMO_JUDGE_CONTENT = `### 评判结论
|
||
|
||
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
|
||
|
||
**组合提醒:** 对外沟通节奏、市场稳定措施和风险等级调整必须使用同一事实基线;任何高风险政策动作均需设置冷静期、法律复核与退出条件。`;
|
||
|
||
export interface StrategyProjectSnapshot {
|
||
version: 1;
|
||
stage: StrategyStage;
|
||
taskDraft: StrategyTaskDraft;
|
||
turns: IntentTurn[];
|
||
outputs: Record<string, AgentOutputState>;
|
||
judgeStatus: AgentOutputState["status"];
|
||
judgeContent: string;
|
||
evaluations: StrategyEvaluation[];
|
||
selectedStrategyIds: string[];
|
||
synthesis: string;
|
||
synthesisStatus: AgentOutputState["status"];
|
||
isDemo: boolean;
|
||
workflowStale: boolean;
|
||
}
|
||
|
||
export interface StrategyProjectSummary {
|
||
id: string;
|
||
title: string;
|
||
brief: string;
|
||
createdAt: string | null;
|
||
updatedAt: string | null;
|
||
stage: StrategyStage;
|
||
questionCount: number;
|
||
angleCount: number;
|
||
completedStrategyCount: number;
|
||
hasEvaluation: boolean;
|
||
hasSolution: boolean;
|
||
isDemo: boolean;
|
||
workflowStale: boolean;
|
||
}
|
||
|
||
export interface StrategyProject extends StrategyProjectSummary {
|
||
snapshot: StrategyProjectSnapshot;
|
||
}
|
||
|
||
interface ParallelTaskResponse {
|
||
id: string;
|
||
title?: string;
|
||
brief?: string | null;
|
||
agents?: Array<{ agent_id: string; name?: string; avatarType?: string | null }>;
|
||
messages?: Array<Record<string, unknown>> | null;
|
||
created_at?: string | null;
|
||
updated_at?: string | null;
|
||
}
|
||
|
||
interface ParallelTaskListResponse {
|
||
tasks?: ParallelTaskResponse[];
|
||
}
|
||
|
||
interface SnapshotEnvelope extends Record<string, unknown> {
|
||
kind: typeof SNAPSHOT_KIND;
|
||
version: 1;
|
||
snapshot: StrategyProjectSnapshot;
|
||
}
|
||
|
||
const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
|
||
...task,
|
||
selectedAngleIds: [...task.selectedAngleIds],
|
||
});
|
||
|
||
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
|
||
turns.map((turn) => ({ ...turn, streaming: false }));
|
||
|
||
const cloneOutputs = (
|
||
outputs: Record<string, AgentOutputState>,
|
||
): Record<string, AgentOutputState> =>
|
||
Object.fromEntries(
|
||
Object.entries(outputs).map(([id, output]) => [
|
||
id,
|
||
{ ...output, steps: [...(output.steps ?? [])] },
|
||
]),
|
||
);
|
||
|
||
const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
|
||
evaluations.map((item) => ({
|
||
...item,
|
||
strengths: [...item.strengths],
|
||
risks: [...item.risks],
|
||
}));
|
||
|
||
export function createEmptyStrategySnapshot(): StrategyProjectSnapshot {
|
||
return {
|
||
version: 1,
|
||
stage: "intent",
|
||
taskDraft: cloneTask(EMPTY_TASK_DRAFT),
|
||
turns: cloneTurns(INITIAL_INTENT_TURNS),
|
||
outputs: {},
|
||
judgeStatus: "idle",
|
||
judgeContent: "",
|
||
evaluations: [],
|
||
selectedStrategyIds: [],
|
||
synthesis: "",
|
||
synthesisStatus: "idle",
|
||
isDemo: false,
|
||
workflowStale: false,
|
||
};
|
||
}
|
||
|
||
export function createDemoStrategySnapshot(): StrategyProjectSnapshot {
|
||
return {
|
||
version: 1,
|
||
stage: "workspace",
|
||
taskDraft: cloneTask(TAIWAN_STRAIT_DEMO_TASK),
|
||
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
|
||
outputs: cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS),
|
||
judgeStatus: "done",
|
||
judgeContent: DEMO_JUDGE_CONTENT,
|
||
evaluations: cloneEvaluations(TAIWAN_STRAIT_DEMO_EVALUATIONS),
|
||
selectedStrategyIds: TAIWAN_STRAIT_DEMO_EVALUATIONS.filter(
|
||
(item) => item.score >= 88,
|
||
).map((item) => item.agentId),
|
||
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||
synthesisStatus: "done",
|
||
isDemo: true,
|
||
workflowStale: false,
|
||
};
|
||
}
|
||
|
||
function isObject(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||
}
|
||
|
||
function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | null {
|
||
const envelope = task.messages?.find(
|
||
(message) => message.kind === SNAPSHOT_KIND && message.version === 1,
|
||
);
|
||
if (!isObject(envelope) || !isObject(envelope.snapshot)) return null;
|
||
const snapshot = envelope.snapshot as unknown as StrategyProjectSnapshot;
|
||
if (
|
||
snapshot.version !== 1 ||
|
||
(snapshot.stage !== "intent" && snapshot.stage !== "workspace") ||
|
||
!isObject(snapshot.taskDraft) ||
|
||
!Array.isArray(snapshot.taskDraft.selectedAngleIds) ||
|
||
!Array.isArray(snapshot.turns) ||
|
||
!isObject(snapshot.outputs) ||
|
||
!Array.isArray(snapshot.evaluations) ||
|
||
!Array.isArray(snapshot.selectedStrategyIds)
|
||
) {
|
||
return null;
|
||
}
|
||
return {
|
||
...snapshot,
|
||
workflowStale: snapshot.workflowStale === true,
|
||
taskDraft: cloneTask(snapshot.taskDraft),
|
||
turns: cloneTurns(snapshot.turns),
|
||
outputs: cloneOutputs(snapshot.outputs),
|
||
evaluations: cloneEvaluations(snapshot.evaluations),
|
||
selectedStrategyIds: [...snapshot.selectedStrategyIds],
|
||
};
|
||
}
|
||
|
||
function toProject(task: ParallelTaskResponse): StrategyProject | null {
|
||
const snapshot = readSnapshot(task);
|
||
if (!snapshot) return null;
|
||
const completedStrategyCount = Object.values(snapshot.outputs).filter(
|
||
(output) => output.status === "done" && output.content.trim(),
|
||
).length;
|
||
return {
|
||
id: task.id,
|
||
title: snapshot.taskDraft.name.trim() || task.title?.trim() || "未命名策略项目",
|
||
brief: snapshot.taskDraft.detail.trim() || task.brief?.trim() || "尚未补充项目说明",
|
||
createdAt: task.created_at ?? null,
|
||
updatedAt: task.updated_at ?? null,
|
||
stage: snapshot.stage,
|
||
questionCount: snapshot.turns.filter((turn) => turn.role === "user").length,
|
||
angleCount: snapshot.taskDraft.selectedAngleIds.length,
|
||
completedStrategyCount,
|
||
hasEvaluation:
|
||
!snapshot.workflowStale &&
|
||
snapshot.judgeStatus === "done" &&
|
||
Boolean(snapshot.judgeContent.trim()),
|
||
hasSolution:
|
||
!snapshot.workflowStale &&
|
||
snapshot.synthesisStatus === "done" &&
|
||
Boolean(snapshot.synthesis.trim()),
|
||
isDemo: snapshot.isDemo,
|
||
workflowStale: snapshot.workflowStale,
|
||
snapshot,
|
||
};
|
||
}
|
||
|
||
async function readError(response: Response): Promise<string> {
|
||
const body = (await response.json().catch(() => null)) as
|
||
| { detail?: string }
|
||
| null;
|
||
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||
}
|
||
|
||
const endpoint = (suffix = "") =>
|
||
`${getBackendBaseURL()}/api/parallel-agent-tasks${suffix}`;
|
||
|
||
async function getParallelTask(id: string): Promise<ParallelTaskResponse> {
|
||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`));
|
||
if (!response.ok) throw new Error(await readError(response));
|
||
return (await response.json()) as ParallelTaskResponse;
|
||
}
|
||
|
||
export async function listStrategyProjects(): Promise<StrategyProjectSummary[]> {
|
||
const response = await apiFetch(endpoint());
|
||
if (!response.ok) throw new Error(await readError(response));
|
||
const body = (await response.json()) as ParallelTaskListResponse;
|
||
const candidates = (body.tasks ?? []).filter((task) =>
|
||
task.id.startsWith(STRATEGY_PROJECT_ID_PREFIX),
|
||
);
|
||
const records = await Promise.allSettled(
|
||
candidates.map((task) => getParallelTask(task.id)),
|
||
);
|
||
return records
|
||
.flatMap((result) => {
|
||
if (result.status !== "fulfilled") return [];
|
||
const project = toProject(result.value);
|
||
return project ? [project] : [];
|
||
})
|
||
.sort((a, b) =>
|
||
String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")),
|
||
);
|
||
}
|
||
|
||
export async function getStrategyProject(id: string): Promise<StrategyProject> {
|
||
if (!id.startsWith(STRATEGY_PROJECT_ID_PREFIX)) {
|
||
throw new Error("这不是一个策略优选项目");
|
||
}
|
||
const task = await getParallelTask(id);
|
||
const project = toProject(task);
|
||
if (!project) throw new Error("项目数据不完整,无法恢复策略工作台");
|
||
return project;
|
||
}
|
||
|
||
function projectAgents(snapshot: StrategyProjectSnapshot) {
|
||
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
|
||
const angleAgents = STRATEGY_ANGLES.filter((angle) => selected.has(angle.id)).map(
|
||
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
|
||
);
|
||
return [
|
||
{ agent_id: "strategy-selection-intent", name: "意图理解智能体" },
|
||
...angleAgents,
|
||
{ agent_id: "strategy-selection-judge", name: "策略评判智能体" },
|
||
{ agent_id: "strategy-selection-solution", name: "方案整理智能体" },
|
||
];
|
||
}
|
||
|
||
function snapshotEnvelope(snapshot: StrategyProjectSnapshot): SnapshotEnvelope {
|
||
return {
|
||
kind: SNAPSHOT_KIND,
|
||
version: 1,
|
||
snapshot,
|
||
};
|
||
}
|
||
|
||
export async function createStrategyProject(
|
||
snapshot: StrategyProjectSnapshot = createEmptyStrategySnapshot(),
|
||
): Promise<StrategyProject> {
|
||
const id = `${STRATEGY_PROJECT_ID_PREFIX}${createClientId()}`;
|
||
const response = await apiFetch(endpoint(), {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id,
|
||
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||
brief: snapshot.taskDraft.detail.trim() || null,
|
||
agents: projectAgents(snapshot),
|
||
messages: [snapshotEnvelope(snapshot)],
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error(await readError(response));
|
||
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||
if (!project) throw new Error("项目已创建,但返回数据无法识别");
|
||
return project;
|
||
}
|
||
|
||
export async function saveStrategyProject(
|
||
id: string,
|
||
snapshot: StrategyProjectSnapshot,
|
||
options: {
|
||
keepalive?: boolean;
|
||
signal?: AbortSignal;
|
||
} = {},
|
||
): Promise<StrategyProject> {
|
||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
keepalive: options.keepalive,
|
||
signal: options.signal,
|
||
body: JSON.stringify({
|
||
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||
brief: snapshot.taskDraft.detail.trim() || null,
|
||
agents: projectAgents(snapshot),
|
||
messages: [snapshotEnvelope(snapshot)],
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error(await readError(response));
|
||
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||
if (!project) throw new Error("项目已保存,但返回数据无法识别");
|
||
return project;
|
||
}
|
||
|
||
export async function deleteStrategyProject(id: string): Promise<void> {
|
||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||
method: "DELETE",
|
||
});
|
||
if (!response.ok && response.status !== 404) {
|
||
throw new Error(await readError(response));
|
||
}
|
||
}
|