Files
deerflow-code/frontend-web/src/roundtable-planning/api/chains.ts
T

298 lines
12 KiB
TypeScript

/**
* Backend-persisted business-chain storage for /page/roundtable/planning.
*
* A business chain is a named, reusable, *ordered* list of agents. Persisted
* server-side (MySQL) bound to the logged-in user, so it follows the account
* across devices/sessions. Backend: `/api/roundtable-chains` (see
* `app/gateway/routers/roundtable_chains.py`).
*
* snake_case ⇄ camelCase mapping is collapsed at this boundary (mirroring
* `api/drafts.ts`) so callers stay clean.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
/** Fields accepted when creating a chain. */
export interface ChainCreatePayload {
/** Optional stable id so the client can keep one id across create + updates. */
id?: string;
title: string;
description?: string;
seats: ChainSeat[];
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
stages?: string[][] | null;
/** Optional per-stage goals aligned to `stages` (dag-only). null = none. */
stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null;
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
seatMode?: SeatMode | null;
/** 研讨前并行取数开关(逐条 opt-in);缺省 false。 */
gatherFirst?: boolean;
}
/** Partial update — send only what changed. */
export interface ChainUpdatePayload {
title?: string;
description?: string;
seats?: ChainSeat[];
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
stages?: string[][] | null;
/** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */
stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null;
/** 默认席位执行模式;send `null` to clear, omit to leave. */
seatMode?: SeatMode | null;
/** 研讨前并行取数开关;send true/false 改,omit 不改。 */
gatherFirst?: boolean;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
/** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */
enabled?: boolean;
}
/**
* 列表筛选范围:
* - `mine` = 我的;
* - `public` = 所有已发布的公共链条;
* - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。
*/
export type ChainScope = "mine" | "public" | "all";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (created_at / updated_at); the rest of the frontend
// reads camelCase. `seats` is already camel-clean ({agent_id, name, description}).
interface ServerChain {
id: string;
title: string;
description: string | null;
seats: ChainSeat[];
stages?: string[][] | null;
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
seat_mode?: string | null;
gather_first?: boolean;
is_public?: boolean;
enabled?: boolean;
is_mine?: boolean;
owner_name?: string | null;
created_at: string | null;
updated_at: string | null;
}
function toChain(s: ServerChain): BusinessChain {
return {
id: s.id,
title: s.title ?? "",
description: s.description ?? undefined,
seats: Array.isArray(s.seats) ? s.seats : [],
stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
gatherFirst: s.gather_first ?? false,
isPublic: s.is_public ?? false,
enabled: s.enabled ?? true,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
}
function fromCreate(p: ChainCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
title: p.title,
...(p.description !== undefined ? { description: p.description } : {}),
seats: p.seats,
...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}),
...(p.gatherFirst !== undefined ? { gather_first: p.gatherFirst } : {}),
};
}
function fromUpdate(p: ChainUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.description !== undefined) out.description = p.description;
if (p.seats !== undefined) out.seats = p.seats;
// Forward stages even when explicitly null (clears layering server-side).
if (p.stages !== undefined) out.stages = p.stages;
// Forward stage goals even when explicitly null (clears goals server-side).
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
if (p.enabled !== undefined) out.enabled = p.enabled;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/**
* List chains, newest first. `scope`:
* - `"mine"`(默认):当前用户自己的链条;
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
*/
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
const url = scope === "mine" ? BASE() : `${BASE()}?scope=${scope}`;
const res = await apiFetch(url, { method: "GET" });
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
const chains = (data.chains ?? []).map(toChain);
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-业务链条-查询",
url,
reqParam: { scope },
returnParam: { count: chains.length },
});
return chains;
}
/** 发布 / 取消发布一条链条(仅 owner 生效)。返回更新后的链条,404 返回 null。 */
export async function setChainPublic(id: string, isPublic: boolean): Promise<BusinessChain | null> {
return updateChain(id, { isPublic });
}
/** 启用 / 停用一条链条(仅 owner / 管理员生效)。返回更新后的链条,404 返回 null。 */
export async function setChainEnabled(id: string, enabled: boolean): Promise<BusinessChain | null> {
return updateChain(id, { enabled });
}
/** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "get chain");
const chain = toChain(data);
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-业务链条-查看",
url: `${BASE()}/${id}`,
reqParam: { id },
returnParam: { id: chain.id, title: chain.title },
});
return chain;
}
export async function createChain(payload: ChainCreatePayload): Promise<BusinessChain> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerChain>(res, "create chain");
const chain = toChain(data);
writeYLog({
optType: OptType.create,
optDetail: "CM助手-全量-业务链条-新建",
url: BASE(),
reqParam: { title: payload.title, seats: payload.seats?.length ?? 0 },
returnParam: { id: chain.id, title: chain.title },
});
return chain;
}
export async function updateChain(id: string, patch: ChainUpdatePayload): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "update chain");
const chain = toChain(data);
const enabledToggle = patch.enabled === true || patch.enabled === false;
writeYLog({
optType: enabledToggle
? patch.enabled
? OptType.enabled
: OptType.disabled
: patch.isPublic === true
? OptType.enabled
: patch.isPublic === false
? OptType.disabled
: OptType.edit,
optDetail: enabledToggle
? patch.enabled
? "CM助手-全量-业务链条-启用"
: "CM助手-全量-业务链条-停用"
: patch.isPublic === true
? "CM助手-全量-业务链条-发布"
: patch.isPublic === false
? "CM助手-全量-业务链条-取消发布"
: "CM助手-全量-业务链条-编辑",
url: `${BASE()}/${id}`,
reqParam: { id, ...patch },
returnParam: { id: chain.id, title: chain.title },
});
return chain;
}
export async function deleteChain(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete chain failed (${res.status}): ${text}`);
}
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-业务链条-删除",
url: `${BASE()}/${id}`,
reqParam: { id },
});
}
// ── 提示词优化 ───────────────────────────────────────────────────────────────
export interface OptimizeChainPromptRequest {
/** 用户当前写的提示词原文(非空)。 */
promptText: string;
/** coordinator = 总控提示词;stage_goal = 某个 Stage 的阶段目标。 */
kind: "coordinator" | "stage_goal";
/** 链条结构上下文(标题 / 各 Stage 席位与目标),帮助模型贴着链条优化。 */
context?: string;
/** 可选模型覆盖,缺省由后端用 config models[0]。 */
modelName?: string;
signal?: AbortSignal;
}
/** 调后端单次 LLM 重写提示词,返回优化后的全文(失败抛错,错误信息可直接 toast)。 */
export async function optimizeChainPrompt(req: OptimizeChainPromptRequest): Promise<string> {
const res = await apiFetch(`${BASE()}/optimize-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_text: req.promptText,
kind: req.kind,
context: req.context ?? "",
...(req.modelName ? { model_name: req.modelName } : {}),
}),
signal: req.signal,
});
const data = await readJson<{ optimized: string }>(res, "optimize chain prompt");
return data.optimized ?? "";
}