Initial commit

This commit is contained in:
2026-07-10 08:31:27 +08:00
commit 2b74c30d36
134 changed files with 21801 additions and 0 deletions

362
src/server/agent-view.ts Normal file
View 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
View 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) {

View 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)}`;
}

View 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
View 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;

View 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
View 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}`);
});