Initial commit
This commit is contained in:
362
src/server/agent-view.ts
Normal file
362
src/server/agent-view.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
|
||||
import type { IntakeProgress } from "../types/intake.js";
|
||||
import {
|
||||
buildSkillCatalog,
|
||||
inferLifecycleStage,
|
||||
canEnterPlay,
|
||||
TOOL_LOOP_BURST_MAX,
|
||||
type LifecycleStage,
|
||||
type SkillCatalogEntry,
|
||||
} from "./skill-catalog.js";
|
||||
|
||||
export type AgentMessageKind =
|
||||
| "user_input"
|
||||
| "orchestrator_decision"
|
||||
| "orchestrator_prompt"
|
||||
| "agent_tool"
|
||||
| "worker_running"
|
||||
| "worker_output"
|
||||
| "worker_questions"
|
||||
| "worker_stub"
|
||||
| "system_info"
|
||||
| "error";
|
||||
|
||||
export type SessionFocus = {
|
||||
actorType: "orchestrator" | "worker" | "user" | "idle";
|
||||
actorId?: string;
|
||||
actorLabel: string;
|
||||
action: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type EnrichedMessage = {
|
||||
kind: AgentMessageKind;
|
||||
actor?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ToolTraceEntry = {
|
||||
at: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type BurstState = {
|
||||
count: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
/** @deprecated 用 skillCatalog + lifecycleStage */
|
||||
export type StageStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "pending" | "active" | "done";
|
||||
};
|
||||
|
||||
const WORKER_QUESTION_FALLBACK =
|
||||
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。";
|
||||
|
||||
function formatWorkerQuestionBody(raw: string | undefined): string {
|
||||
const lines = (raw ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^\s*[-*•]\s*/, "").trim())
|
||||
.filter((line) => line.length > 0 && !/^askUser$/i.test(line));
|
||||
if (lines.length === 0) return WORKER_QUESTION_FALLBACK;
|
||||
return lines.map((line) => `- ${line}`).join("\n");
|
||||
}
|
||||
|
||||
export function classifyAgentMessage(text: string): EnrichedMessage {
|
||||
const trimmed = text.trim();
|
||||
|
||||
const agentTool = trimmed.match(/^\[总管 tool\]\s*([^\s:]+)(?::\s*([\s\S]*))?$/);
|
||||
if (agentTool) {
|
||||
const name = agentTool[1];
|
||||
const detail = agentTool[2]?.trim() ?? "";
|
||||
return {
|
||||
kind: "agent_tool",
|
||||
actor: "orchestrator",
|
||||
title: `Tool · ${name}`,
|
||||
body: detail || "(无输出)",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const orchestrator = trimmed.match(/^\[总管\]\s*(\w+):\s*([\s\S]+)$/);
|
||||
if (orchestrator) {
|
||||
return {
|
||||
kind: "orchestrator_decision",
|
||||
actor: "orchestrator",
|
||||
title: `Agent · ${orchestrator[1]}`,
|
||||
body: orchestrator[2].trim(),
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const workerRunning = trimmed.match(/^\[Worker\]\s*(\S+)\s*执行中/);
|
||||
if (workerRunning) {
|
||||
return {
|
||||
kind: "worker_running",
|
||||
actor: workerRunning[1],
|
||||
title: `Worker · ${workerRunning[1]}`,
|
||||
body: "正在调用模型执行 SKILL…",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const workerDone = trimmed.match(/^\[Worker\]\s*(\S+)\s*已完成\s*\n?\n?([\s\S]*)$/);
|
||||
if (workerDone) {
|
||||
return {
|
||||
kind: "worker_output",
|
||||
actor: workerDone[1],
|
||||
title: `Worker · ${workerDone[1]} 产出`,
|
||||
body: workerDone[2]?.trim() || "(无正文)",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[Worker 占位]")) {
|
||||
const stub = trimmed.match(/^\[Worker 占位\]\s*(\S+)/);
|
||||
return {
|
||||
kind: "worker_stub",
|
||||
actor: stub?.[1],
|
||||
title: `占位 Worker · ${stub?.[1] ?? "?"}`,
|
||||
body: trimmed,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const workerAskTagged = trimmed.match(
|
||||
/^\[Worker\]\s*(\S+)\s*提问[::]\s*\n?([\s\S]*)$/,
|
||||
);
|
||||
if (workerAskTagged) {
|
||||
const body = formatWorkerQuestionBody(workerAskTagged[2]);
|
||||
return {
|
||||
kind: "worker_questions",
|
||||
actor: workerAskTagged[1],
|
||||
title: `Worker · ${workerAskTagged[1]} 提问`,
|
||||
body,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const workerQuestions = trimmed.match(/^Worker 提问[::]\s*\n?([\s\S]*)$/);
|
||||
if (workerQuestions) {
|
||||
const body = formatWorkerQuestionBody(workerQuestions[1]);
|
||||
return {
|
||||
kind: "worker_questions",
|
||||
title: "Worker 需要你补充",
|
||||
body,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[请求失败]")) {
|
||||
return {
|
||||
kind: "error",
|
||||
title: "请求失败",
|
||||
body: trimmed.replace(/^\[请求失败\]\s*/, ""),
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[阶段机]")) {
|
||||
return {
|
||||
kind: "system_info",
|
||||
title: "阶段机",
|
||||
body: trimmed.replace(/^\[阶段机\]\s*/, ""),
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.includes("请告诉我") ||
|
||||
trimmed.includes("启动询问") ||
|
||||
/^你选择了/.test(trimmed)
|
||||
) {
|
||||
return {
|
||||
kind: "orchestrator_prompt",
|
||||
actor: "orchestrator",
|
||||
title: "Agent · 启动询问",
|
||||
body: trimmed,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "system_info",
|
||||
title: "系统",
|
||||
body: trimmed,
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 保留兼容;返回空数组 */
|
||||
export function buildPipeline(_session: RuntimeSession): StageStep[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildToolTrace(
|
||||
messages: Array<{ kind?: AgentMessageKind; text: string; createdAt: string }>,
|
||||
): ToolTraceEntry[] {
|
||||
let lastUserIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].kind === "user_input") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const slice = lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages;
|
||||
return slice
|
||||
.filter((m) => m.kind === "agent_tool")
|
||||
.map((m) => {
|
||||
const match = m.text.match(/^\[总管 tool\]\s*([^\s:]+)/);
|
||||
return {
|
||||
at: m.createdAt,
|
||||
name: match?.[1] ?? "tool",
|
||||
summary: m.text.replace(/^\[总管 tool\]\s*\S+:?\s*/, "").slice(0, 200),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBurstState(
|
||||
messages: Array<{ kind?: AgentMessageKind }>,
|
||||
session: RuntimeSession,
|
||||
): BurstState {
|
||||
let lastUserIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].kind === "user_input") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const slice = lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages;
|
||||
const toolCount = slice.filter(
|
||||
(m) =>
|
||||
m.kind === "agent_tool" ||
|
||||
m.kind === "orchestrator_decision",
|
||||
).length;
|
||||
const stored =
|
||||
typeof session.slots.toolLoopBurstCount === "number"
|
||||
? session.slots.toolLoopBurstCount
|
||||
: toolCount;
|
||||
return {
|
||||
count: Math.max(stored, toolCount),
|
||||
max: TOOL_LOOP_BURST_MAX,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFocus(
|
||||
session: RuntimeSession,
|
||||
reason?: WaitingReason,
|
||||
intake?: IntakeProgress,
|
||||
lifecycle?: LifecycleStage,
|
||||
): SessionFocus {
|
||||
const stage = lifecycle ?? inferLifecycleStage(session);
|
||||
|
||||
if (session.phase === "done") {
|
||||
return {
|
||||
actorType: "idle",
|
||||
actorLabel: "流程",
|
||||
action: "已完成",
|
||||
detail: stage === "design" ? "设计阶段结束" : "游玩会话结束",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "intake") {
|
||||
const detail =
|
||||
intake && intake.requiredTotal > 0
|
||||
? `必要项 ${intake.requiredFilled}/${intake.requiredTotal}`
|
||||
: "完成必要项后可进入实例化";
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "填写创作信息",
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "input") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "补充说明",
|
||||
detail: reason.message?.slice(0, 120),
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "approve_step") {
|
||||
const worker = session.pendingDecision?.workerId ?? "skill";
|
||||
return {
|
||||
actorType: "orchestrator",
|
||||
actorId: "orchestrator",
|
||||
actorLabel: "Agent",
|
||||
action: `建议 invoke ${worker}`,
|
||||
detail: session.pendingDecision?.reason,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "worker_questions") {
|
||||
const q = reason.questions?.filter((s) => s?.trim()).join(";") ?? "";
|
||||
return {
|
||||
actorType: "user",
|
||||
actorId: reason.workerId,
|
||||
actorLabel: "你",
|
||||
action: `回答 · ${reason.workerId}`,
|
||||
detail: q.slice(0, 200) || "请在下框补充",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "review_artifact") {
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "验收产物",
|
||||
detail: art?.summary ?? art?.workerId,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.currentWorkerId) {
|
||||
return {
|
||||
actorType: "worker",
|
||||
actorId: session.currentWorkerId,
|
||||
actorLabel: `Skill · ${session.currentWorkerId}`,
|
||||
action: "执行中",
|
||||
detail: "模型按 SKILL 产出…",
|
||||
};
|
||||
}
|
||||
|
||||
if (session.phase === "running" && !reason) {
|
||||
return {
|
||||
actorType: "orchestrator",
|
||||
actorId: "orchestrator",
|
||||
actorLabel: "Agent",
|
||||
action: stage === "design" ? "设计 burst" : "游玩 burst",
|
||||
detail: "tool loop:读黑板 → 选 skill",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "skill_selection") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "选择 Skill 包",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actorType: "idle",
|
||||
actorLabel: "系统",
|
||||
action: "待命",
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
inferLifecycleStage,
|
||||
canEnterPlay,
|
||||
buildSkillCatalog,
|
||||
type LifecycleStage,
|
||||
type SkillCatalogEntry,
|
||||
};
|
||||
190
src/server/book-handlers.ts
Normal file
190
src/server/book-handlers.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import { listSkills } from "../skills/loader.js";
|
||||
|
||||
import { createBook, deleteBook, getBook, listBooks, updateBook } from "../book/store.js";
|
||||
|
||||
import { sessionManager } from "./session-manager.js";
|
||||
|
||||
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of req) {
|
||||
|
||||
chunks.push(chunk as Buffer);
|
||||
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function json(res: ServerResponse, status: number, data: unknown): void {
|
||||
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
|
||||
res.end(JSON.stringify(data));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function handleBooksApi(
|
||||
|
||||
req: IncomingMessage,
|
||||
|
||||
res: ServerResponse,
|
||||
|
||||
pathname: string,
|
||||
|
||||
): Promise<boolean> {
|
||||
|
||||
if (pathname === "/api/skills" && req.method === "GET") {
|
||||
|
||||
const skills = await listSkills();
|
||||
|
||||
json(res, 200, {
|
||||
|
||||
skills: skills.map((s) => ({
|
||||
|
||||
id: s.name,
|
||||
|
||||
name: s.name,
|
||||
|
||||
description: s.description,
|
||||
|
||||
category: s.category,
|
||||
|
||||
bookKind: s.bookKind,
|
||||
|
||||
})),
|
||||
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (pathname === "/api/books" && req.method === "GET") {
|
||||
|
||||
json(res, 200, { books: listBooks() });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (pathname === "/api/books" && req.method === "POST") {
|
||||
|
||||
const body = JSON.parse(await readBody(req)) as { title?: string };
|
||||
|
||||
const book = createBook({ title: body.title });
|
||||
|
||||
const session = await sessionManager.createForBook(book.id);
|
||||
|
||||
json(res, 201, { book, session });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const savesListMatch = pathname.match(/^\/api\/books\/([^/]+)\/saves$/);
|
||||
|
||||
if (savesListMatch) {
|
||||
|
||||
const bookId = decodeURIComponent(savesListMatch[1]);
|
||||
|
||||
const book = getBook(bookId);
|
||||
|
||||
if (!book) {
|
||||
|
||||
json(res, 404, { error: "Book 不存在" });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (req.method === "GET") {
|
||||
|
||||
try {
|
||||
|
||||
const saves = sessionManager.listGameSnapshots(bookId);
|
||||
|
||||
json(res, 200, { saves });
|
||||
|
||||
} catch (err) {
|
||||
|
||||
json(res, 400, {
|
||||
|
||||
error: err instanceof Error ? err.message : "读取存档失败",
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (req.method === "POST") {
|
||||
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
|
||||
label?: string;
|
||||
|
||||
note?: string;
|
||||
|
||||
sessionId?: string;
|
||||
|
||||
kind?: "instance" | "run";
|
||||
|
||||
};
|
||||
|
||||
const sessionId =
|
||||
|
||||
body.sessionId?.trim() ||
|
||||
|
||||
sessionManager.getActiveSessionForBook(bookId)?.id;
|
||||
|
||||
if (!sessionId) {
|
||||
|
||||
json(res, 400, { error: "当前作品没有活跃会话,无法存档" });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
const kind = body.kind === "instance" ? "instance" : "run";
|
||||
|
||||
try {
|
||||
|
||||
const save = sessionManager.saveGameSnapshot(
|
||||
|
||||
sessionId,
|
||||
|
||||
body.label ?? "",
|
||||
|
||||
kind,
|
||||
|
||||
body.note,
|
||||
|
||||
);
|
||||
|
||||
json(res, 201, { save });
|
||||
|
||||
} catch (err) {
|
||||
|
||||
829
src/server/session-manager.ts
Normal file
829
src/server/session-manager.ts
Normal file
@@ -0,0 +1,829 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendBookSession, getBook, updateBook } from "../book/store.js";
|
||||
import { loadBookSession, saveBookSession } from "../book/session-store.js";
|
||||
import {
|
||||
deleteRunSnapshot as deleteRunSnapshotFile,
|
||||
listRunSnapshots,
|
||||
loadRunSnapshot as loadRunSnapshotFile,
|
||||
saveRunSnapshot as saveRunSnapshotFile,
|
||||
} from "../book/run-snapshot-store.js";
|
||||
import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js";
|
||||
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
|
||||
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
|
||||
import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js";
|
||||
import { createSession } from "../runtime/phase-machine.js";
|
||||
import {
|
||||
createDefaultMainAgentLlm,
|
||||
hasRealLlmConfig,
|
||||
reloadDefaultMainAgentLlm,
|
||||
type LlmTrackingRef,
|
||||
} from "../runtime/llm-factory.js";
|
||||
import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js";
|
||||
import type { PersistedBookSession } from "../types/book-session.js";
|
||||
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
|
||||
import type { IntakeProgress } from "../types/intake.js";
|
||||
import {
|
||||
buildFocus,
|
||||
buildPipeline,
|
||||
buildToolTrace,
|
||||
buildBurstState,
|
||||
buildSkillCatalog,
|
||||
inferLifecycleStage,
|
||||
canEnterPlay,
|
||||
classifyAgentMessage,
|
||||
type AgentMessageKind,
|
||||
type SessionFocus,
|
||||
type StageStep,
|
||||
type LifecycleStage,
|
||||
type SkillCatalogEntry,
|
||||
type ToolTraceEntry,
|
||||
type BurstState,
|
||||
} from "./agent-view.js";
|
||||
import {
|
||||
buildIntakeProgress,
|
||||
readIntakeValues,
|
||||
} from "../intake/intake.js";
|
||||
import {
|
||||
bookSkillPackId,
|
||||
persistedSkillPackId,
|
||||
runSnapshotSkillPackId,
|
||||
sessionSkillPackId,
|
||||
skillPacksMatch,
|
||||
} from "../book/skill-id.js";
|
||||
import type { ActiveSkillSnapshot } from "../types/runtime.js";
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: "system" | "user";
|
||||
text: string;
|
||||
createdAt: string;
|
||||
kind?: AgentMessageKind;
|
||||
actor?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
/** LLM 思维链 / reasoning_content */
|
||||
thinking?: string;
|
||||
tokenUsage?: MessageTokenUsage;
|
||||
};
|
||||
|
||||
export type SessionView = {
|
||||
id: string;
|
||||
bookId?: string;
|
||||
bookTitle?: string;
|
||||
activeSkill?: string;
|
||||
phase: RuntimeSession["phase"];
|
||||
waitingReason?: WaitingReason;
|
||||
startupCompleted: boolean;
|
||||
skills: Array<{ name: string; description: string; category: string }>;
|
||||
messages: ChatMessage[];
|
||||
hints: string[];
|
||||
actions: SessionAction[];
|
||||
/** @deprecated 用 skillCatalog */
|
||||
pipeline: StageStep[];
|
||||
lifecycleStage: LifecycleStage;
|
||||
playReady: boolean;
|
||||
skillCatalog: SkillCatalogEntry[];
|
||||
toolTrace: ToolTraceEntry[];
|
||||
burst: BurstState;
|
||||
focus: SessionFocus;
|
||||
/** 启动填空进度(waitingReason.kind === intake 时有值) */
|
||||
intake?: IntakeProgress;
|
||||
intakePrompt?: string;
|
||||
resumed?: boolean;
|
||||
tokenStats?: {
|
||||
sessionTotal: number;
|
||||
sessionCached?: number;
|
||||
sessionCacheMiss?: number;
|
||||
lastCaller?: string;
|
||||
lastTotal?: number;
|
||||
byCaller?: Record<
|
||||
string,
|
||||
{ totalTokens: number; cachedTokens: number; cacheMissTokens: number; calls: number }
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionAction =
|
||||
| { type: "send_message"; label: string; placeholder: string }
|
||||
| { type: "confirm_intake"; label: string }
|
||||
| { type: "approve"; label: string }
|
||||
| { type: "accept"; label: string }
|
||||
| { type: "reject"; label: string }
|
||||
| { type: "run_outline"; label: string }
|
||||
| { type: "finish"; label: string };
|
||||
|
||||
type ManagedSession = {
|
||||
runtime: PhaseRuntime;
|
||||
messages: ChatMessage[];
|
||||
bookId?: string;
|
||||
trackingRef: LlmTrackingRef;
|
||||
};
|
||||
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<string, ManagedSession>();
|
||||
/** bookId → 当前内存中的 sessionId */
|
||||
private readonly activeBookSessions = new Map<string, string>();
|
||||
|
||||
async create(): Promise<SessionView> {
|
||||
return this.createForBook();
|
||||
}
|
||||
|
||||
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView {
|
||||
const s = this.require(sessionId);
|
||||
const session = s.runtime.getSession();
|
||||
if (stage === "play" && !canEnterPlay(session)) {
|
||||
throw new Error("实例尚未就绪,无法进入游玩");
|
||||
}
|
||||
s.runtime.setLifecycleStage(stage);
|
||||
if (s.bookId) this.persist(s);
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 Book:优先恢复磁盘快照;无快照则新建 Session。
|
||||
* 若该 Book 已在内存中,直接返回当前视图。
|
||||
*/
|
||||
async openBook(bookId: string): Promise<SessionView> {
|
||||
const book = getBook(bookId);
|
||||
if (!book) throw new Error("Book 不存在");
|
||||
|
||||
const inMemory = this.getActiveSessionForBook(bookId);
|
||||
if (inMemory) return inMemory;
|
||||
|
||||
const snapshot = loadBookSession(bookId);
|
||||
if (snapshot && skillPacksMatch(book, persistedSkillPackId(snapshot))) {
|
||||
return this.restoreFromSnapshot(snapshot);
|
||||
}
|
||||
|
||||
const legacyPreselect = bookSkillPackId(book);
|
||||
return this.createForBook(bookId, legacyPreselect);
|
||||
}
|
||||
|
||||
async createForBook(
|
||||
bookId?: string,
|
||||
preselectSkillId?: string,
|
||||
): Promise<SessionView> {
|
||||
const id = randomUUID();
|
||||
const messages: ChatMessage[] = [];
|
||||
const book = bookId ? getBook(bookId) : null;
|
||||
const initialSession = createSession("default");
|
||||
initialSession.id = id;
|
||||
const trackingRef: LlmTrackingRef = {
|
||||
current: {
|
||||
sessionId: id,
|
||||
bookId,
|
||||
bookTitle: book?.title,
|
||||
orchestratorId: preselectSkillId ?? (book ? bookSkillPackId(book) : undefined),
|
||||
},
|
||||
};
|
||||
|
||||
const managed: ManagedSession = {
|
||||
runtime: null as unknown as PhaseRuntime,
|
||||
messages,
|
||||
bookId,
|
||||
trackingRef,
|
||||
};
|
||||
const onMessage = this.buildOnMessageHandler(() => managed);
|
||||
|
||||
const llm = createDefaultMainAgentLlm(trackingRef);
|
||||
const runtime = new PhaseRuntime({
|
||||
autoStubWorker: !hasRealLlmConfig(),
|
||||
llm,
|
||||
onMessage,
|
||||
initialSession,
|
||||
});
|
||||
managed.runtime = runtime;
|
||||
|
||||
if (preselectSkillId) {
|
||||
await runtime.startWithOrchestrator(preselectSkillId);
|
||||
} else {
|
||||
await runtime.start();
|
||||
}
|
||||
|
||||
this.sessions.set(id, managed);
|
||||
if (bookId) {
|
||||
this.activeBookSessions.set(bookId, id);
|
||||
appendBookSession(bookId, id);
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
get(id: string): SessionView | null {
|
||||
if (!this.sessions.has(id)) return null;
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
getActiveSessionForBook(bookId: string): SessionView | null {
|
||||
const sessionId = this.activeBookSessions.get(bookId);
|
||||
if (!sessionId) return null;
|
||||
return this.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快照。
|
||||
* - instance:实例化后的对象(情境/规则/角色设定等),不含 run 轮次状态
|
||||
* - run:运行存档,完整进度
|
||||
*/
|
||||
saveGameSnapshot(
|
||||
sessionId: string,
|
||||
label: string,
|
||||
kind: SnapshotKind = "run",
|
||||
note?: string,
|
||||
): RunSnapshotMeta {
|
||||
const s = this.require(sessionId);
|
||||
if (!s.bookId) throw new Error("仅绑定作品时可存档");
|
||||
const book = getBook(s.bookId);
|
||||
if (!book) throw new Error("Book 不存在");
|
||||
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) throw new Error("请输入存档名称");
|
||||
|
||||
let runtimeSession = structuredClone(s.runtime.getSession());
|
||||
let blackboardItems = s.runtime.getBlackboard().exportItems();
|
||||
|
||||
if (kind === "instance") {
|
||||
if (!runtimeSession.slots.startupCompleted) {
|
||||
throw new Error("实例尚未完成(需先完成启动与 setup 验收),无法保存实例快照");
|
||||
}
|
||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
}));
|
||||
}
|
||||
|
||||
const skillPackId =
|
||||
sessionSkillPackId(runtimeSession) ?? bookSkillPackId(book) ?? "";
|
||||
const snapshot: RunSnapshot = {
|
||||
version: 1,
|
||||
id: randomUUID(),
|
||||
bookId: s.bookId,
|
||||
label: trimmed,
|
||||
kind,
|
||||
orchestratorId: skillPackId,
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
messages: s.messages.map((m) => ({ ...m })),
|
||||
createdAt: new Date().toISOString(),
|
||||
note: note?.trim() || undefined,
|
||||
};
|
||||
saveRunSnapshotFile(snapshot);
|
||||
return toRunSnapshotMeta(snapshot);
|
||||
}
|
||||
|
||||
/** 列出某 Book 的全部存档 */
|
||||
listGameSnapshots(bookId: string): RunSnapshotMeta[] {
|
||||
if (!getBook(bookId)) throw new Error("Book 不存在");
|
||||
return listRunSnapshots(bookId);
|
||||
}
|
||||
|
||||
/** 从存档读档:替换当前作品进度,可继续创作 */
|
||||
async loadGameSnapshot(bookId: string, snapshotId: string): Promise<SessionView> {
|
||||
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
|
||||
if (!snapshot) throw new Error("存档不存在");
|
||||
|
||||
const book = getBook(bookId);
|
||||
if (!book) throw new Error("Book 不存在");
|
||||
if (!skillPacksMatch(book, runSnapshotSkillPackId(snapshot))) {
|
||||
throw new Error("存档与当前作品 skill 包不匹配,无法读档");
|
||||
}
|
||||
|
||||
this.dropBookSessions(bookId);
|
||||
|
||||
const newSessionId = randomUUID();
|
||||
const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
|
||||
let runtimeSession = structuredClone(snapshot.runtimeSession);
|
||||
let blackboardItems = snapshot.blackboardItems;
|
||||
|
||||
if (snapshot.kind === "instance") {
|
||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
}));
|
||||
}
|
||||
|
||||
runtimeSession.id = newSessionId;
|
||||
|
||||
const resumeHint =
|
||||
snapshot.kind === "instance"
|
||||
? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。`
|
||||
: `已从存档「${snapshot.label}」读档,可继续创作。`;
|
||||
|
||||
return this.mountRestoredSession({
|
||||
sessionId: newSessionId,
|
||||
bookId,
|
||||
skillPackId: runSnapshotSkillPackId(snapshot) ?? "",
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
messages,
|
||||
resumeHint,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除单个存档 */
|
||||
deleteGameSnapshot(bookId: string, snapshotId: string): void {
|
||||
if (!getBook(bookId)) throw new Error("Book 不存在");
|
||||
if (!deleteRunSnapshotFile(bookId, snapshotId)) {
|
||||
throw new Error("存档不存在");
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除 Book 时清理内存中的会话 */
|
||||
dropBookSessions(bookId: string): void {
|
||||
for (const [sessionId, managed] of this.sessions) {
|
||||
if (managed.bookId === bookId) {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
this.activeBookSessions.delete(bookId);
|
||||
}
|
||||
|
||||
/** 将当前 API / 预设设置应用到所有活跃会话 */
|
||||
reloadAllLlms(): number {
|
||||
let count = 0;
|
||||
for (const s of this.sessions.values()) {
|
||||
const llm = reloadDefaultMainAgentLlm(s.trackingRef);
|
||||
s.runtime.reloadLlm(llm, !hasRealLlmConfig());
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async sendMessage(id: string, text: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
s.messages.push(this.msg("user", text));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
const reason = s.runtime.getSession().waitingReason;
|
||||
try {
|
||||
if (reason?.kind === "approve_step") {
|
||||
await s.runtime.rejectStep(text);
|
||||
} else if (reason?.kind === "review_artifact") {
|
||||
await s.runtime.rejectArtifact(text);
|
||||
} else {
|
||||
await s.runtime.submitInput(text);
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] sendMessage failed:", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async approve(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
await s.runtime.approve();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] approve failed:", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async confirmIntake(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
await s.runtime.confirmIntake();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] confirmIntake failed:", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async accept(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
await s.runtime.acceptArtifact();
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 拒绝当前待确认步骤或待验收产物(无说明时触发重新来 / 回到总管) */
|
||||
async reject(id: string, reason?: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
const waiting = s.runtime.getSession().waitingReason;
|
||||
try {
|
||||
if (waiting?.kind === "approve_step") {
|
||||
await s.runtime.rejectStep(reason ?? "用户暂不执行");
|
||||
} else if (waiting?.kind === "review_artifact") {
|
||||
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
|
||||
} else {
|
||||
throw new Error("当前没有可拒绝的确认或验收");
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[session] reject failed:", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async runOutline(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
await s.runtime.submitDecision(
|
||||
createDecision({
|
||||
action: "run_worker",
|
||||
reason: "根据创作简报生成大纲",
|
||||
workerId: "outline",
|
||||
requiresApproval: true,
|
||||
}),
|
||||
);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async finish(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
await s.runtime.submitDecision(
|
||||
createDecision({
|
||||
action: "finish",
|
||||
reason: "创作流程结束",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
private async restoreFromSnapshot(
|
||||
snapshot: PersistedBookSession,
|
||||
): Promise<SessionView> {
|
||||
return this.mountRestoredSession({
|
||||
sessionId: snapshot.sessionId,
|
||||
bookId: snapshot.bookId,
|
||||
skillPackId: persistedSkillPackId(snapshot) ?? "",
|
||||
runtimeSession: snapshot.runtimeSession,
|
||||
blackboardItems: snapshot.blackboardItems,
|
||||
messages: snapshot.messages.map((m) => ({ ...m })) as ChatMessage[],
|
||||
resumeHint: "已从上次进度恢复,可继续创作。",
|
||||
});
|
||||
}
|
||||
|
||||
private async mountRestoredSession(params: {
|
||||
sessionId: string;
|
||||
bookId: string;
|
||||
skillPackId: string;
|
||||
runtimeSession: RuntimeSession;
|
||||
blackboardItems: import("../types/blackboard.js").BlackboardItem[];
|
||||
messages: ChatMessage[];
|
||||
resumeHint: string;
|
||||
}): Promise<SessionView> {
|
||||
const book = getBook(params.bookId);
|
||||
const trackingRef: LlmTrackingRef = {
|
||||
current: {
|
||||
sessionId: params.sessionId,
|
||||
bookId: params.bookId,
|
||||
bookTitle: book?.title,
|
||||
orchestratorId: params.skillPackId || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
let managedRef: ManagedSession | null = null;
|
||||
const onMessage = this.buildOnMessageHandler(() => managedRef);
|
||||
|
||||
const runtime = new PhaseRuntime({
|
||||
autoStubWorker: !hasRealLlmConfig(),
|
||||
llm: createDefaultMainAgentLlm(trackingRef),
|
||||
onMessage,
|
||||
initialSession: params.runtimeSession,
|
||||
initialBlackboardItems: params.blackboardItems,
|
||||
});
|
||||
await runtime.ensureAvailableSkills();
|
||||
|
||||
managedRef = {
|
||||
runtime,
|
||||
messages: params.messages,
|
||||
bookId: params.bookId,
|
||||
trackingRef,
|
||||
};
|
||||
this.sessions.set(params.sessionId, managedRef);
|
||||
this.activeBookSessions.set(params.bookId, params.sessionId);
|
||||
appendBookSession(params.bookId, params.sessionId);
|
||||
this.persist(managedRef);
|
||||
|
||||
return this.toView(params.sessionId, true, params.resumeHint);
|
||||
}
|
||||
|
||||
private buildOnMessageHandler(getManaged: () => ManagedSession | null) {
|
||||
return (text: string) => {
|
||||
const managed = getManaged();
|
||||
if (!managed) return;
|
||||
const usage = managed.trackingRef.current.pendingUsage;
|
||||
const thinking = managed.trackingRef.current.pendingReasoning;
|
||||
managed.trackingRef.current.pendingUsage = undefined;
|
||||
managed.trackingRef.current.pendingReasoning = undefined;
|
||||
managed.messages.push(this.msg("system", text, usage, thinking));
|
||||
if (managed.bookId) {
|
||||
this.syncBookPreview(managed.bookId, managed.messages);
|
||||
this.persist(managed);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private persist(s: ManagedSession): void {
|
||||
if (!s.bookId) return;
|
||||
const book = getBook(s.bookId);
|
||||
if (!book) return;
|
||||
|
||||
const session = s.runtime.getSession();
|
||||
const skillPackId =
|
||||
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined) ?? "";
|
||||
const snapshot: PersistedBookSession = {
|
||||
version: 1,
|
||||
sessionId: session.id,
|
||||
bookId: s.bookId,
|
||||
orchestratorId: skillPackId || undefined,
|
||||
runtimeSession: session,
|
||||
blackboardItems: s.runtime.getBlackboard().exportItems(),
|
||||
messages: s.messages,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
saveBookSession(snapshot);
|
||||
this.syncBookSkill(s.bookId, session);
|
||||
updateBook(s.bookId, { activeSessionId: session.id });
|
||||
}
|
||||
|
||||
private syncBookSkill(bookId: string, session: RuntimeSession): void {
|
||||
const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
if (!snap?.name) return;
|
||||
try {
|
||||
updateBook(bookId, {
|
||||
activeSkillId: snap.name,
|
||||
activeSkillName: snap.name,
|
||||
});
|
||||
} catch {
|
||||
/* book may have been deleted */
|
||||
}
|
||||
}
|
||||
|
||||
private syncBookPreview(bookId: string, messages: ChatMessage[]): void {
|
||||
const preview = this.previewFromMessages(messages);
|
||||
try {
|
||||
updateBook(bookId, { preview });
|
||||
} catch {
|
||||
/* book may have been deleted */
|
||||
}
|
||||
}
|
||||
|
||||
private previewFromMessages(messages: ChatMessage[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const t = messages[i].text?.trim();
|
||||
if (t) return t.slice(0, 80);
|
||||
}
|
||||
return "等待开始…";
|
||||
}
|
||||
|
||||
private require(id: string): ManagedSession {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) throw new Error("会话不存在");
|
||||
return s;
|
||||
}
|
||||
|
||||
private enrichDisplayMessages(
|
||||
session: RuntimeSession,
|
||||
messages: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
const reason = session.waitingReason;
|
||||
if (reason?.kind === "intake" && !session.slots.startupCompleted) {
|
||||
const prompt = reason.prompt?.trim();
|
||||
if (!prompt) return messages;
|
||||
const needle = prompt.slice(0, 48);
|
||||
const hasStartup = messages.some(
|
||||
(m) =>
|
||||
m.kind === "orchestrator_prompt" ||
|
||||
(typeof m.text === "string" && m.text.includes(needle)),
|
||||
);
|
||||
if (hasStartup) return messages;
|
||||
return [...messages, this.msg("system", prompt)];
|
||||
}
|
||||
if (reason?.kind !== "input" || session.slots.startupCompleted) {
|
||||
return messages;
|
||||
}
|
||||
const prompt = reason.message?.trim();
|
||||
if (!prompt) return messages;
|
||||
const needle = prompt.slice(0, 48);
|
||||
const hasStartup = messages.some(
|
||||
(m) =>
|
||||
m.kind === "orchestrator_prompt" ||
|
||||
(typeof m.text === "string" && m.text.includes(needle)),
|
||||
);
|
||||
if (hasStartup) return messages;
|
||||
return [...messages, this.msg("system", prompt)];
|
||||
}
|
||||
|
||||
private msg(
|
||||
role: ChatMessage["role"],
|
||||
text: string,
|
||||
tokenUsage?: MessageTokenUsage,
|
||||
thinking?: string,
|
||||
): ChatMessage {
|
||||
const base = {
|
||||
id: randomUUID(),
|
||||
role,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(tokenUsage ? { tokenUsage } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
};
|
||||
if (role === "user") {
|
||||
return {
|
||||
...base,
|
||||
kind: "user_input",
|
||||
title: "你的输入",
|
||||
body: text,
|
||||
};
|
||||
}
|
||||
const classified = classifyAgentMessage(text);
|
||||
return { ...base, ...classified };
|
||||
}
|
||||
|
||||
private toView(id: string, resumed = false, resumeHint?: string): SessionView {
|
||||
const s = this.require(id);
|
||||
const session = s.runtime.getSession();
|
||||
const reason = session.waitingReason;
|
||||
const book = s.bookId ? getBook(s.bookId) : null;
|
||||
const hints: string[] = [];
|
||||
const actions: SessionAction[] = [];
|
||||
let intake: IntakeProgress | undefined;
|
||||
let intakePrompt: string | undefined;
|
||||
|
||||
const activeSnap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
|
||||
if (resumeHint) {
|
||||
hints.push(resumeHint);
|
||||
} else if (resumed) {
|
||||
hints.push("已从上次进度恢复,可继续创作。");
|
||||
}
|
||||
|
||||
if (reason?.kind === "intake" && activeSnap?.intakeFields?.length) {
|
||||
intake = buildIntakeProgress(
|
||||
activeSnap.intakeFields,
|
||||
readIntakeValues(session.slots),
|
||||
);
|
||||
intakePrompt = reason.prompt;
|
||||
if (intake.ready) {
|
||||
hints.push("必要项已齐,可确认进入实例化;也可继续补充可选项。");
|
||||
actions.push({
|
||||
type: "confirm_intake",
|
||||
label: "确认,进入实例化",
|
||||
});
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "继续补充",
|
||||
placeholder: "补充可选项或修正已填内容…",
|
||||
});
|
||||
} else {
|
||||
hints.push(
|
||||
`填写必要项(${intake.requiredFilled}/${intake.requiredTotal})后可确认进入实例化`,
|
||||
);
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "按上方说明补充信息,可一次说多项…",
|
||||
});
|
||||
}
|
||||
} else if (reason?.kind === "skill_selection") {
|
||||
hints.push("请选择创作类型(输入 skill 名称或编号)");
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "例如:basic 或 1",
|
||||
});
|
||||
} else if (reason?.kind === "intake") {
|
||||
hints.push(reason.prompt?.trim() ?? "请按填空项补充创作信息");
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "描述你想写什么…",
|
||||
});
|
||||
} else if (reason?.kind === "input") {
|
||||
if (reason.message?.trim()) {
|
||||
hints.push(reason.message.trim());
|
||||
} else {
|
||||
hints.push("请回答启动问题,或补充创作目标");
|
||||
}
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "描述你想写什么…",
|
||||
});
|
||||
} else if (reason?.kind === "worker_questions") {
|
||||
const qs = reason.questions.filter((q) => q?.trim());
|
||||
if (qs.length) {
|
||||
hints.push(`Worker · ${reason.workerId} 提问:${qs.join(" ")}`);
|
||||
} else {
|
||||
hints.push(`Worker · ${reason.workerId} 需要更多信息,请补充说明`);
|
||||
}
|
||||
const placeholder =
|
||||
qs[0]?.slice(0, 120) ?? "回答 Worker 的问题,或补充情境与参数…";
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder,
|
||||
});
|
||||
} else if (reason?.kind === "approve_step") {
|
||||
actions.push({ type: "approve", label: "确认执行" });
|
||||
actions.push({ type: "reject", label: "暂不执行" });
|
||||
} else if (reason?.kind === "review_artifact") {
|
||||
actions.push({ type: "accept", label: "接受产物" });
|
||||
actions.push({ type: "reject", label: "不接受,重新来" });
|
||||
} else if (
|
||||
session.phase === "running" &&
|
||||
s.runtime.needsMainAgentDecision() &&
|
||||
!s.runtime.hasMainAgent()
|
||||
) {
|
||||
hints.push("简报已就绪,可手动生成大纲(未配置 Agent LLM)");
|
||||
actions.push({ type: "run_outline", label: "生成大纲" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "补充说明…",
|
||||
});
|
||||
} else if (session.phase === "running" && s.runtime.needsMainAgentDecision()) {
|
||||
hints.push("Agent 正在调度…");
|
||||
} else if (session.phase === "running" && session.pendingArtifactId) {
|
||||
/* worker 运行中 */
|
||||
} else if (
|
||||
session.phase === "running" &&
|
||||
session.slots.startupCompleted &&
|
||||
!session.pendingDecision
|
||||
) {
|
||||
actions.push({ type: "finish", label: "结束流程" });
|
||||
} else if (session.phase === "done") {
|
||||
hints.push("流程已完成");
|
||||
} else {
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "输入消息…",
|
||||
});
|
||||
}
|
||||
|
||||
if (s.bookId) {
|
||||
this.persist(s);
|
||||
}
|
||||
|
||||
const tokens = getSessionTokenTotals(id);
|
||||
const messages = this.enrichDisplayMessages(session, [...s.messages]);
|
||||
const lifecycleStage = inferLifecycleStage(session);
|
||||
const skillPackId =
|
||||
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
|
||||
const skillCatalog = buildSkillCatalog(
|
||||
session,
|
||||
skillPackId,
|
||||
lifecycleStage,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
bookId: s.bookId,
|
||||
bookTitle: book?.title,
|
||||
activeSkill: s.runtime.getActiveSkill()?.name ?? skillPackId,
|
||||
phase: session.phase,
|
||||
waitingReason: reason,
|
||||
startupCompleted: Boolean(session.slots.startupCompleted),
|
||||
skills: s.runtime.getAvailableSkills(),
|
||||
messages,
|
||||
hints,
|
||||
actions,
|
||||
pipeline: buildPipeline(session),
|
||||
lifecycleStage,
|
||||
playReady: canEnterPlay(session),
|
||||
skillCatalog,
|
||||
toolTrace: buildToolTrace(messages),
|
||||
burst: buildBurstState(messages, session),
|
||||
focus: buildFocus(session, reason, intake, lifecycleStage),
|
||||
intake,
|
||||
intakePrompt,
|
||||
resumed: resumed || undefined,
|
||||
tokenStats: {
|
||||
sessionTotal: tokens.totalTokens,
|
||||
sessionCached: tokens.totalCached || undefined,
|
||||
sessionCacheMiss: tokens.totalCacheMiss || undefined,
|
||||
lastCaller: tokens.last?.caller,
|
||||
lastTotal: tokens.last?.totalTokens,
|
||||
byCaller: tokens.byCaller,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionManager = new SessionManager();
|
||||
|
||||
function formatRuntimeError(detail: string): string {
|
||||
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
|
||||
return (
|
||||
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
|
||||
"或点击「测试连接」验证。"
|
||||
);
|
||||
}
|
||||
if (detail.startsWith("LLM request failed")) {
|
||||
return `[请求失败] ${detail.replace(/^LLM request failed \(\d+\): /, "").slice(0, 300)}`;
|
||||
}
|
||||
if (detail.startsWith("Main Agent returned invalid JSON")) {
|
||||
return "[请求失败] 总管返回了无效 JSON,请检查模型是否支持 json 输出,或暂时关闭预设后重试。";
|
||||
}
|
||||
return `[请求失败] ${detail.slice(0, 300)}`;
|
||||
}
|
||||
267
src/server/settings-handlers.ts
Normal file
267
src/server/settings-handlers.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
createApiProfile,
|
||||
deleteApiProfile,
|
||||
getApiProfile,
|
||||
listApiProfiles,
|
||||
testApiProfile,
|
||||
updateApiProfile,
|
||||
} from "../config/api-profiles.js";
|
||||
import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
saveAppSettings,
|
||||
setActivePresetId,
|
||||
setActiveProfileId,
|
||||
} from "../config/settings.js";
|
||||
import {
|
||||
deletePreset,
|
||||
getPreset,
|
||||
importAndSavePreset,
|
||||
listPresets,
|
||||
} from "../preset/store.js";
|
||||
import {
|
||||
countInjectingEntries,
|
||||
listEnabledPresetEntries,
|
||||
} from "../preset/entries.js";
|
||||
import { sessionManager } from "./session-manager.js";
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
export async function handleSettingsApi(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathname: string,
|
||||
): Promise<boolean> {
|
||||
if (pathname === "/api/settings" && req.method === "GET") {
|
||||
ensureActiveProfileDefault();
|
||||
const settings = loadAppSettings();
|
||||
const profiles = listApiProfiles();
|
||||
const presets = listPresets().map((p) => {
|
||||
const entries = listEnabledPresetEntries(p);
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
source: p.source,
|
||||
enabledCount: p.promptOrder.filter((o) => o.enabled).length,
|
||||
injectingCount: countInjectingEntries(entries),
|
||||
importedAt: p.importedAt,
|
||||
};
|
||||
});
|
||||
json(res, 200, { settings, profiles, presets });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/settings" && req.method === "PUT") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
activeProfileId?: string | null;
|
||||
activePresetId?: string | null;
|
||||
};
|
||||
const settings = loadAppSettings();
|
||||
if (body.activeProfileId !== undefined) {
|
||||
settings.activeProfileId = body.activeProfileId;
|
||||
}
|
||||
if (body.activePresetId !== undefined) {
|
||||
settings.activePresetId = body.activePresetId;
|
||||
}
|
||||
saveAppSettings(settings);
|
||||
json(res, 200, { settings });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/profiles" && req.method === "GET") {
|
||||
ensureActiveProfileDefault();
|
||||
json(res, 200, { profiles: listApiProfiles() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/profiles" && req.method === "POST") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
};
|
||||
const profile = createApiProfile({
|
||||
name: body.name ?? "新配置",
|
||||
baseUrl: body.baseUrl ?? "https://api.deepseek.com",
|
||||
apiKey: body.apiKey ?? "",
|
||||
model: body.model ?? "deepseek-v4-pro",
|
||||
});
|
||||
const activate = (body as { activate?: boolean }).activate === true;
|
||||
if (activate) {
|
||||
setActiveProfileId(profile.id);
|
||||
}
|
||||
const reloadedSessions = activate ? sessionManager.reloadAllLlms() : 0;
|
||||
json(res, 201, { profile, activeProfileId: activate ? profile.id : null, reloadedSessions });
|
||||
return true;
|
||||
}
|
||||
|
||||
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/);
|
||||
if (profileMatch) {
|
||||
const id = decodeURIComponent(profileMatch[1]);
|
||||
const isTest = profileMatch[2] === "/test";
|
||||
|
||||
if (isTest && req.method === "POST") {
|
||||
const profile = getApiProfile(id);
|
||||
if (!profile) {
|
||||
json(res, 404, { error: "配置不存在" });
|
||||
return true;
|
||||
}
|
||||
const result = await testApiProfile(profile);
|
||||
json(res, 200, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const profile = getApiProfile(id);
|
||||
if (!profile) {
|
||||
json(res, 404, { error: "配置不存在" });
|
||||
return true;
|
||||
}
|
||||
json(res, 200, { profile });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
};
|
||||
try {
|
||||
const profile = updateApiProfile(id, body);
|
||||
json(res, 200, { profile });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : "更新失败",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
deleteApiProfile(id);
|
||||
const settings = loadAppSettings();
|
||||
if (settings.activeProfileId === id) {
|
||||
settings.activeProfileId = listApiProfiles()[0]?.id ?? null;
|
||||
saveAppSettings(settings);
|
||||
}
|
||||
json(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const activateProfileMatch = pathname.match(
|
||||
/^\/api\/profiles\/([^/]+)\/activate$/,
|
||||
);
|
||||
if (activateProfileMatch && req.method === "POST") {
|
||||
const id = decodeURIComponent(activateProfileMatch[1]);
|
||||
if (!getApiProfile(id)) {
|
||||
json(res, 404, { error: "配置不存在" });
|
||||
return true;
|
||||
}
|
||||
setActiveProfileId(id);
|
||||
const reloadedSessions = sessionManager.reloadAllLlms();
|
||||
json(res, 200, { activeProfileId: id, reloadedSessions });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/presets" && req.method === "GET") {
|
||||
json(res, 200, { presets: listPresets() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/presets/import" && req.method === "POST") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
raw?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
if (!body.raw) {
|
||||
json(res, 400, { error: "缺少 raw 字段" });
|
||||
return true;
|
||||
}
|
||||
const report = importAndSavePreset(body.raw, { name: body.name });
|
||||
const activate = (body as { activate?: boolean }).activate === true;
|
||||
if (activate) {
|
||||
setActivePresetId(report.preset.id);
|
||||
sessionManager.reloadAllLlms();
|
||||
}
|
||||
json(res, 201, { ...report, activePresetId: activate ? report.preset.id : null });
|
||||
return true;
|
||||
}
|
||||
|
||||
const presetEntriesMatch = pathname.match(
|
||||
/^\/api\/presets\/([^/]+)\/entries$/,
|
||||
);
|
||||
if (presetEntriesMatch && req.method === "GET") {
|
||||
const id = decodeURIComponent(presetEntriesMatch[1]);
|
||||
const preset = getPreset(id);
|
||||
if (!preset) {
|
||||
json(res, 404, { error: "预设不存在" });
|
||||
return true;
|
||||
}
|
||||
const entries = listEnabledPresetEntries(preset);
|
||||
json(res, 200, {
|
||||
presetId: preset.id,
|
||||
presetName: preset.name,
|
||||
generation: preset.generation,
|
||||
entries,
|
||||
injectingCount: countInjectingEntries(entries),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/);
|
||||
if (presetMatch) {
|
||||
const id = decodeURIComponent(presetMatch[1]);
|
||||
const isActivate = presetMatch[2] === "/activate";
|
||||
|
||||
if (isActivate && req.method === "POST") {
|
||||
if (!getPreset(id)) {
|
||||
json(res, 404, { error: "预设不存在" });
|
||||
return true;
|
||||
}
|
||||
setActivePresetId(id);
|
||||
const reloadedSessions = sessionManager.reloadAllLlms();
|
||||
json(res, 200, { activePresetId: id, reloadedSessions });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const preset = getPreset(id);
|
||||
if (!preset) {
|
||||
json(res, 404, { error: "预设不存在" });
|
||||
return true;
|
||||
}
|
||||
const entries = listEnabledPresetEntries(preset);
|
||||
json(res, 200, { preset, entries, injectingCount: countInjectingEntries(entries) });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
deletePreset(id);
|
||||
const settings = loadAppSettings();
|
||||
if (settings.activePresetId === id) {
|
||||
settings.activePresetId = null;
|
||||
saveAppSettings(settings);
|
||||
}
|
||||
json(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
285
src/server/skill-catalog.ts
Normal file
285
src/server/skill-catalog.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { RuntimeSession } from "../types/runtime.js";
|
||||
|
||||
export type LifecycleStage = "design" | "play";
|
||||
|
||||
export type SkillCatalogEntry = {
|
||||
id: string;
|
||||
stage: "design" | "run";
|
||||
label: string;
|
||||
/** 这一步要干嘛(占位说明,详细设计后续补充) */
|
||||
purpose: string;
|
||||
status: "pending" | "active" | "done" | "skipped";
|
||||
};
|
||||
|
||||
type CatalogTemplate = {
|
||||
id: string;
|
||||
stage: "design" | "run";
|
||||
label: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
const GENERIC_DESIGN: CatalogTemplate[] = [
|
||||
{
|
||||
id: "interaction-paradigm",
|
||||
stage: "design",
|
||||
label: "交互范式",
|
||||
purpose: "弄清用户要什么体验,产出 run skill 清单(要哪些能力)。",
|
||||
},
|
||||
{
|
||||
id: "intake",
|
||||
stage: "design",
|
||||
label: "启动收集",
|
||||
purpose: "收集最小需求,写入用户.需求 / book.brief。",
|
||||
},
|
||||
{
|
||||
id: "world-blueprint",
|
||||
stage: "design",
|
||||
label: "世界蓝图",
|
||||
purpose: "定背景板与核心冲突,供后续 skill 引用。",
|
||||
},
|
||||
{
|
||||
id: "narrative-guide",
|
||||
stage: "design",
|
||||
label: "叙事指南",
|
||||
purpose: "定 POV、时态、文风(static 上下文上半)。",
|
||||
},
|
||||
{
|
||||
id: "declare-ready",
|
||||
stage: "design",
|
||||
label: "实例就绪",
|
||||
purpose: "agent 确认设计够开跑,进入游玩阶段。",
|
||||
},
|
||||
];
|
||||
|
||||
const GENERIC_RUN: CatalogTemplate[] = [
|
||||
{
|
||||
id: "agent-burst",
|
||||
stage: "run",
|
||||
label: "Agent 调度",
|
||||
purpose: "总管 tool loop:读黑板 → 选择 invoke 哪个 run skill。",
|
||||
},
|
||||
{
|
||||
id: "narrator",
|
||||
stage: "run",
|
||||
label: "转述 / 展示",
|
||||
purpose: "把世界状态编排成给用户看的叙事回复。",
|
||||
},
|
||||
{
|
||||
id: "world-simulator",
|
||||
stage: "run",
|
||||
label: "世界模拟",
|
||||
purpose: "裁决规则、更新事件流与可见信息。",
|
||||
},
|
||||
];
|
||||
|
||||
const BY_SKILL_PACK: Record<string, CatalogTemplate[]> = {
|
||||
basic: [
|
||||
{
|
||||
id: "intake",
|
||||
stage: "design",
|
||||
label: "创作简报",
|
||||
purpose: "收集题材、篇幅、风格 → book.brief。",
|
||||
},
|
||||
{
|
||||
id: "declare-ready",
|
||||
stage: "design",
|
||||
label: "进入运行",
|
||||
purpose: "简报确认后 declare ready。",
|
||||
},
|
||||
{
|
||||
id: "outline",
|
||||
stage: "run",
|
||||
label: "生成大纲",
|
||||
purpose: "根据 brief 生成 outline 产物。",
|
||||
},
|
||||
],
|
||||
"weird-rules-short": [
|
||||
{
|
||||
id: "intake",
|
||||
stage: "design",
|
||||
label: "创作简报",
|
||||
purpose: "收集规则怪谈情境与条数。",
|
||||
},
|
||||
{
|
||||
id: "write-rules",
|
||||
stage: "run",
|
||||
label: "写规则",
|
||||
purpose: "产出规则草稿与隐藏 core。",
|
||||
},
|
||||
{
|
||||
id: "review-infer",
|
||||
stage: "run",
|
||||
label: "读者验收",
|
||||
purpose: "盲读规则,不写 core。",
|
||||
},
|
||||
{
|
||||
id: "review-author",
|
||||
stage: "run",
|
||||
label: "作者验收",
|
||||
purpose: "对照 core 查一致性。",
|
||||
},
|
||||
],
|
||||
"roleplay-game-theory": [
|
||||
{
|
||||
id: "intake",
|
||||
stage: "design",
|
||||
label: "博弈需求",
|
||||
purpose: "收集情境、角色、轮次 → 用户.博弈需求。",
|
||||
},
|
||||
{
|
||||
id: "setup-scenario",
|
||||
stage: "design",
|
||||
label: "结构化设定",
|
||||
purpose: "整理为情境、规则、角色设定 tag。",
|
||||
},
|
||||
{
|
||||
id: "declare-ready",
|
||||
stage: "design",
|
||||
label: "开始模拟",
|
||||
purpose: "setup 验收后进入 run。",
|
||||
},
|
||||
{
|
||||
id: "world-engine",
|
||||
stage: "run",
|
||||
label: "世界机",
|
||||
purpose: "发可见信息、收行动、裁决回合。",
|
||||
},
|
||||
{
|
||||
id: "role-decide",
|
||||
stage: "run",
|
||||
label: "角色决策",
|
||||
purpose: "各角色独立产出思考与行动。",
|
||||
},
|
||||
{
|
||||
id: "present-round",
|
||||
stage: "run",
|
||||
label: "回合展示",
|
||||
purpose: "编排给用户看的本轮摘要。",
|
||||
},
|
||||
],
|
||||
"world-simulator": [
|
||||
{
|
||||
id: "interaction-paradigm",
|
||||
stage: "design",
|
||||
label: "交互范式",
|
||||
purpose: "定体验与 run skill 清单。",
|
||||
},
|
||||
{
|
||||
id: "world-blueprint",
|
||||
stage: "design",
|
||||
label: "世界蓝图",
|
||||
purpose: "背景板与核心设定。",
|
||||
},
|
||||
{
|
||||
id: "topology",
|
||||
stage: "design",
|
||||
label: "拓扑 / 关系",
|
||||
purpose: "地图、关系网或进阶路径(按需)。",
|
||||
},
|
||||
{
|
||||
id: "generation-rules",
|
||||
stage: "design",
|
||||
label: "生成规则",
|
||||
purpose: "元规则:如何生成实例内容。",
|
||||
},
|
||||
{
|
||||
id: "narrative-guide",
|
||||
stage: "design",
|
||||
label: "叙事指南",
|
||||
purpose: "正文气质与禁忌(static 上)。",
|
||||
},
|
||||
{
|
||||
id: "variable-catalog",
|
||||
stage: "design",
|
||||
label: "变量目录",
|
||||
purpose: "要跟踪的状态与更新格式。",
|
||||
},
|
||||
{
|
||||
id: "declare-ready",
|
||||
stage: "design",
|
||||
label: "实例就绪",
|
||||
purpose: "agent 声明可开跑。",
|
||||
},
|
||||
{
|
||||
id: "world-simulator",
|
||||
stage: "run",
|
||||
label: "世界模拟器",
|
||||
purpose: "每轮推进世界状态与事件流。",
|
||||
},
|
||||
{
|
||||
id: "narrator",
|
||||
stage: "run",
|
||||
label: "转述者",
|
||||
purpose: "把状态写成用户可见叙事。",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function templatesFor(skillPackId?: string): CatalogTemplate[] {
|
||||
if (skillPackId && BY_SKILL_PACK[skillPackId]) {
|
||||
return BY_SKILL_PACK[skillPackId];
|
||||
}
|
||||
return [...GENERIC_DESIGN, ...GENERIC_RUN];
|
||||
}
|
||||
|
||||
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
|
||||
const override = session.slots.uiLifecycleStage;
|
||||
if (override === "design" || override === "play") {
|
||||
return override;
|
||||
}
|
||||
if (!session.slots.startupCompleted) return "design";
|
||||
if (session.phase === "done") return "play";
|
||||
return "play";
|
||||
}
|
||||
|
||||
export function canEnterPlay(session: RuntimeSession): boolean {
|
||||
return Boolean(session.slots.startupCompleted);
|
||||
}
|
||||
|
||||
export function buildSkillCatalog(
|
||||
session: RuntimeSession,
|
||||
skillPackId?: string,
|
||||
lifecycle: LifecycleStage = inferLifecycleStage(session),
|
||||
): SkillCatalogEntry[] {
|
||||
const templates = templatesFor(skillPackId);
|
||||
const filtered = templates.filter((t) =>
|
||||
lifecycle === "design" ? t.stage === "design" : t.stage === "run",
|
||||
);
|
||||
|
||||
const workerIds = new Set(
|
||||
session.artifacts.map((a) => a.workerId).filter(Boolean),
|
||||
);
|
||||
const acceptedWorkers = new Set(
|
||||
session.artifacts
|
||||
.filter((a) => a.status === "accepted")
|
||||
.map((a) => a.workerId),
|
||||
);
|
||||
|
||||
return filtered.map((t) => {
|
||||
let status: SkillCatalogEntry["status"] = "pending";
|
||||
|
||||
if (t.id === "intake") {
|
||||
if (session.slots.startupCompleted) status = "done";
|
||||
else if (
|
||||
session.waitingReason?.kind === "intake" ||
|
||||
session.waitingReason?.kind === "input"
|
||||
) {
|
||||
status = "active";
|
||||
}
|
||||
} else if (t.id === "declare-ready") {
|
||||
if (session.slots.startupCompleted) status = "done";
|
||||
} else if (t.id === "agent-burst") {
|
||||
if (session.phase === "running" && !session.currentWorkerId) {
|
||||
status = "active";
|
||||
}
|
||||
} else if (workerIds.has(t.id)) {
|
||||
status = acceptedWorkers.has(t.id) ? "done" : "active";
|
||||
} else if (session.currentWorkerId === t.id) {
|
||||
status = "active";
|
||||
}
|
||||
|
||||
return { ...t, status };
|
||||
});
|
||||
}
|
||||
|
||||
export const TOOL_LOOP_BURST_MAX = 12;
|
||||
37
src/server/stats-handlers.ts
Normal file
37
src/server/stats-handlers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
readRecords,
|
||||
summarizeTokenUsage,
|
||||
type TokenStatsQuery,
|
||||
} from "../stats/token-store.js";
|
||||
|
||||
function json(res: ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
export async function handleStatsApi(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<boolean> {
|
||||
if (pathname !== "/api/stats/tokens") return false;
|
||||
|
||||
const query: TokenStatsQuery = {
|
||||
bookId: searchParams.get("bookId") ?? undefined,
|
||||
orchestratorId: searchParams.get("orchestratorId") ?? undefined,
|
||||
sessionId: searchParams.get("sessionId") ?? undefined,
|
||||
from: searchParams.get("from") ?? undefined,
|
||||
to: searchParams.get("to") ?? undefined,
|
||||
limit: searchParams.get("limit")
|
||||
? Number(searchParams.get("limit"))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
json(res, 200, {
|
||||
summary: summarizeTokenUsage(query),
|
||||
records: readRecords({ ...query, limit: query.limit ?? 100 }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
203
src/server/web-server.ts
Normal file
203
src/server/web-server.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadDotEnv } from "../config/env.js";
|
||||
import { sessionManager } from "./session-manager.js";
|
||||
import { handleSettingsApi } from "./settings-handlers.js";
|
||||
import { handleBooksApi } from "./book-handlers.js";
|
||||
import { handleStatsApi } from "./stats-handlers.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
||||
loadDotEnv(PROJECT_ROOT);
|
||||
const WEB_ROOT = path.resolve(__dirname, "../../web");
|
||||
const PORT = Number(process.env.PORT ?? 23337);
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
async function serveStatic(res: ServerResponse, filePath: string): Promise<void> {
|
||||
const ext = path.extname(filePath);
|
||||
const types: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
};
|
||||
const content = await readFile(filePath);
|
||||
res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
||||
|
||||
try {
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
version: "0.1.0",
|
||||
routes: [
|
||||
"GET /api/health",
|
||||
"GET /api/settings",
|
||||
"GET /api/profiles",
|
||||
"POST /api/profiles",
|
||||
"GET /api/presets",
|
||||
"GET /api/books",
|
||||
"GET /api/skills",
|
||||
"GET /api/stats/tokens",
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleSettingsApi(req, res, url.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleBooksApi(req, res, url.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleStatsApi(req, res, url.pathname, url.searchParams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions") {
|
||||
const body = await readBody(req).catch(() => "");
|
||||
let bookId: string | undefined;
|
||||
let orchestratorId: string | undefined;
|
||||
if (body) {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as {
|
||||
bookId?: string;
|
||||
orchestratorId?: string;
|
||||
};
|
||||
bookId = parsed.bookId;
|
||||
orchestratorId = parsed.orchestratorId;
|
||||
} catch {
|
||||
/* empty body ok */
|
||||
}
|
||||
}
|
||||
const view = await sessionManager.createForBook(bookId, orchestratorId);
|
||||
json(res, 201, view);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)(\/.*)?$/);
|
||||
if (sessionMatch) {
|
||||
const sessionId = decodeURIComponent(sessionMatch[1]);
|
||||
const sub = sessionMatch[2] ?? "";
|
||||
|
||||
if (req.method === "GET" && sub === "") {
|
||||
const view = sessionManager.get(sessionId);
|
||||
if (!view) {
|
||||
json(res, 404, { error: "会话不存在" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/messages") {
|
||||
const body = JSON.parse(await readBody(req)) as { text?: string };
|
||||
if (!body.text?.trim()) {
|
||||
json(res, 400, { error: "text 不能为空" });
|
||||
return;
|
||||
}
|
||||
const view = await sessionManager.sendMessage(sessionId, body.text.trim());
|
||||
json(res, 200, view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/lifecycle") {
|
||||
const body = JSON.parse(await readBody(req)) as { stage?: string };
|
||||
if (body.stage !== "design" && body.stage !== "play") {
|
||||
json(res, 400, { error: "stage 须为 design 或 play" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const view = sessionManager.setLifecycleStage(sessionId, body.stage);
|
||||
json(res, 200, view);
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "无法切换模式",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/actions") {
|
||||
const body = JSON.parse(await readBody(req)) as { action?: string };
|
||||
let view;
|
||||
switch (body.action) {
|
||||
case "approve":
|
||||
view = await sessionManager.approve(sessionId);
|
||||
break;
|
||||
case "confirm_intake":
|
||||
view = await sessionManager.confirmIntake(sessionId);
|
||||
break;
|
||||
case "accept":
|
||||
view = await sessionManager.accept(sessionId);
|
||||
break;
|
||||
case "reject":
|
||||
view = await sessionManager.reject(sessionId);
|
||||
break;
|
||||
case "run_outline":
|
||||
view = await sessionManager.runOutline(sessionId);
|
||||
break;
|
||||
case "finish":
|
||||
view = await sessionManager.finish(sessionId);
|
||||
break;
|
||||
default:
|
||||
json(res, 400, { error: "未知 action" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, view);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let file = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const safe = path.normalize(file).replace(/^(\.\.[/\\])+/, "");
|
||||
const full = path.join(WEB_ROOT, safe);
|
||||
if (!full.startsWith(WEB_ROOT)) {
|
||||
json(res, 403, { error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await serveStatic(res, full);
|
||||
} catch {
|
||||
json(res, 404, { error: "Not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[api]", req.method, url.pathname, err);
|
||||
json(res, 500, {
|
||||
error: err instanceof Error ? err.message : "服务器错误",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Writing Agent 对话页: http://localhost:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user