Initial commit
This commit is contained in:
117
src/blackboard/blackboard.ts
Normal file
117
src/blackboard/blackboard.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isFullAccessPattern, tagMatchesPattern } from "./tag-match.js";
|
||||
import type {
|
||||
BlackboardItem,
|
||||
BlackboardTagIndex,
|
||||
BlackboardWriteInput,
|
||||
} from "../types/blackboard.js";
|
||||
|
||||
export class Blackboard {
|
||||
private items = new Map<string, BlackboardItem>();
|
||||
private writeSeq = 0;
|
||||
|
||||
listTagIndex(): BlackboardTagIndex[] {
|
||||
return [...this.items.values()]
|
||||
.map(({ id, tag, source, scope, updatedAt }) => ({
|
||||
id,
|
||||
tag,
|
||||
source,
|
||||
scope,
|
||||
updatedAt,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
get(id: string): BlackboardItem | undefined {
|
||||
return this.items.get(id);
|
||||
}
|
||||
|
||||
getLatestByTag(tag: string): BlackboardItem | undefined {
|
||||
const matches = [...this.items.values()].filter((item) => item.tag === tag);
|
||||
if (matches.length === 0) return undefined;
|
||||
return matches.sort((a, b) => compareItemsByRecency(a, b))[0];
|
||||
}
|
||||
|
||||
getContentByTag(tag: string): string | undefined {
|
||||
return this.getLatestByTag(tag)?.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 worker inputTags 模式取条目。
|
||||
* 同一 pattern 多条命中时:latest 取最新一条;concat 合并 content。
|
||||
*/
|
||||
queryByPatterns(
|
||||
patterns: string[],
|
||||
merge: "latest" | "concat" = "latest",
|
||||
): BlackboardItem[] {
|
||||
if (patterns.some(isFullAccessPattern)) {
|
||||
return [...this.items.values()].sort((a, b) =>
|
||||
a.updatedAt.localeCompare(b.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
const result: BlackboardItem[] = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matched = [...this.items.values()]
|
||||
.filter((item) => tagMatchesPattern(item.tag, pattern))
|
||||
.sort(compareItemsByRecency);
|
||||
|
||||
if (matched.length === 0) continue;
|
||||
|
||||
if (merge === "concat" && matched.length > 1) {
|
||||
result.push({
|
||||
...matched[0],
|
||||
id: `merged:${pattern}`,
|
||||
content: matched
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((m) => m.content)
|
||||
.join("\n\n---\n\n"),
|
||||
});
|
||||
} else {
|
||||
result.push(matched[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
write(input: BlackboardWriteInput): BlackboardItem {
|
||||
const now = new Date().toISOString();
|
||||
this.writeSeq += 1;
|
||||
const existing = this.getLatestByTag(input.tag);
|
||||
const item: BlackboardItem = {
|
||||
id: randomUUID(),
|
||||
tag: input.tag,
|
||||
content: input.content,
|
||||
source: input.source,
|
||||
scope: input.scope ?? existing?.scope,
|
||||
createdAt: now,
|
||||
updatedAt: `${now}#${this.writeSeq}`,
|
||||
dependencies: input.dependencies,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
this.items.set(item.id, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
seed(items: BlackboardItem[]): void {
|
||||
for (const item of items) {
|
||||
this.items.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
exportItems(): BlackboardItem[] {
|
||||
return [...this.items.values()];
|
||||
}
|
||||
|
||||
/** 测试 / 调试:当前条目数 */
|
||||
size(): number {
|
||||
return this.items.size;
|
||||
}
|
||||
}
|
||||
|
||||
function compareItemsByRecency(a: BlackboardItem, b: BlackboardItem): number {
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
}
|
||||
13
src/blackboard/tag-match.ts
Normal file
13
src/blackboard/tag-match.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** 判断 itemTag 是否匹配 worker 声明的 inputTag 模式(精确或 前缀.*) */
|
||||
export function tagMatchesPattern(itemTag: string, pattern: string): boolean {
|
||||
if (pattern === "**") return true;
|
||||
if (pattern.endsWith(".*")) {
|
||||
const prefix = pattern.slice(0, -2);
|
||||
return itemTag === prefix || itemTag.startsWith(`${prefix}.`);
|
||||
}
|
||||
return itemTag === pattern;
|
||||
}
|
||||
|
||||
export function isFullAccessPattern(pattern: string): boolean {
|
||||
return pattern === "**";
|
||||
}
|
||||
28
src/book/orchestrators.ts
Normal file
28
src/book/orchestrators.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { listSkills } from "../skills/loader.js";
|
||||
import type { SkillPackInfo } from "../types/book.js";
|
||||
|
||||
/** @deprecated 使用 listSkillPacks;保留兼容旧 import */
|
||||
export async function listOrchestrators(): Promise<SkillPackInfo[]> {
|
||||
return listSkillPacks();
|
||||
}
|
||||
|
||||
export async function listSkillPacks(): Promise<SkillPackInfo[]> {
|
||||
const skills = await listSkills();
|
||||
return skills.map((s) => ({
|
||||
id: s.name,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
category: s.category,
|
||||
bookKind: s.bookKind,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSkillPack(id: string): Promise<SkillPackInfo | null> {
|
||||
const all = await listSkillPacks();
|
||||
return all.find((o) => o.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export async function getOrchestrator(id: string): Promise<SkillPackInfo | null> {
|
||||
return getSkillPack(id);
|
||||
}
|
||||
96
src/book/run-snapshot-store.ts
Normal file
96
src/book/run-snapshot-store.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||
import type { RunSnapshot, RunSnapshotMeta } from "../types/run-snapshot.js";
|
||||
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
|
||||
|
||||
function snapshotsDir(bookId: string): string {
|
||||
return path.join(getUserDataDir(), "books", bookId, "run-snapshots");
|
||||
}
|
||||
|
||||
function snapshotPath(bookId: string, snapshotId: string): string {
|
||||
return path.join(snapshotsDir(bookId), `${snapshotId}.json`);
|
||||
}
|
||||
|
||||
function isValidRunSnapshot(value: unknown): value is RunSnapshot {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const s = value as RunSnapshot;
|
||||
const kind = s.kind ?? "run";
|
||||
return (
|
||||
s.version === 1 &&
|
||||
typeof s.id === "string" &&
|
||||
typeof s.bookId === "string" &&
|
||||
typeof s.label === "string" &&
|
||||
(kind === "instance" || kind === "run") &&
|
||||
typeof s.orchestratorId === "string" &&
|
||||
s.runtimeSession != null &&
|
||||
Array.isArray(s.blackboardItems) &&
|
||||
Array.isArray(s.messages) &&
|
||||
typeof s.createdAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSnapshot(raw: RunSnapshot): RunSnapshot {
|
||||
return { ...raw, kind: raw.kind ?? "run" };
|
||||
}
|
||||
|
||||
/** 保存运行快照(同 bookId + snapshotId 则覆盖) */
|
||||
export function saveRunSnapshot(snapshot: RunSnapshot): void {
|
||||
ensureUserDataDirs();
|
||||
const dir = snapshotsDir(snapshot.bookId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(snapshotPath(snapshot.bookId, snapshot.id), JSON.stringify(snapshot, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/** 读取单个运行快照;不存在或格式无效时返回 null */
|
||||
export function loadRunSnapshot(bookId: string, snapshotId: string): RunSnapshot | null {
|
||||
try {
|
||||
const raw = readFileSync(snapshotPath(bookId, snapshotId), "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isValidRunSnapshot(parsed) || parsed.bookId !== bookId || parsed.id !== snapshotId) {
|
||||
return null;
|
||||
}
|
||||
return normalizeSnapshot(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单个运行快照;成功删除返回 true */
|
||||
export function deleteRunSnapshot(bookId: string, snapshotId: string): boolean {
|
||||
try {
|
||||
unlinkSync(snapshotPath(bookId, snapshotId));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出某 Book 下全部运行快照(按 createdAt 降序) */
|
||||
export function listRunSnapshots(bookId: string): RunSnapshotMeta[] {
|
||||
ensureUserDataDirs();
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(snapshotsDir(bookId)).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metas: RunSnapshotMeta[] = [];
|
||||
for (const file of files) {
|
||||
const id = file.replace(/\.json$/, "");
|
||||
const snapshot = loadRunSnapshot(bookId, id);
|
||||
if (snapshot) metas.push(toRunSnapshotMeta(snapshot));
|
||||
}
|
||||
|
||||
return metas.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
/** 删除某 Book 下全部运行快照(删 Book 时调用) */
|
||||
export function deleteAllRunSnapshots(bookId: string): void {
|
||||
try {
|
||||
rmSync(snapshotsDir(bookId), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
46
src/book/session-store.ts
Normal file
46
src/book/session-store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||
import type { PersistedBookSession } from "../types/book-session.js";
|
||||
|
||||
const SESSION_FILENAME = "session.json";
|
||||
|
||||
function bookDir(bookId: string): string {
|
||||
return path.join(getUserDataDir(), "books", bookId);
|
||||
}
|
||||
|
||||
function sessionPath(bookId: string): string {
|
||||
return path.join(bookDir(bookId), SESSION_FILENAME);
|
||||
}
|
||||
|
||||
export function saveBookSession(snapshot: PersistedBookSession): void {
|
||||
ensureUserDataDirs();
|
||||
const dir = bookDir(snapshot.bookId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(sessionPath(snapshot.bookId), JSON.stringify(snapshot, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export function loadBookSession(bookId: string): PersistedBookSession | null {
|
||||
try {
|
||||
const raw = readFileSync(sessionPath(bookId), "utf8");
|
||||
const parsed = JSON.parse(raw) as PersistedBookSession;
|
||||
if (parsed.version !== 1 || !parsed.sessionId || !parsed.runtimeSession) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteBookSession(bookId: string): void {
|
||||
try {
|
||||
rmSync(bookDir(bookId), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function hasBookSession(bookId: string): boolean {
|
||||
return loadBookSession(bookId) !== null;
|
||||
}
|
||||
37
src/book/skill-id.ts
Normal file
37
src/book/skill-id.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { BookProject } from "../types/book.js";
|
||||
import type { PersistedBookSession } from "../types/book-session.js";
|
||||
import type { RunSnapshot } from "../types/run-snapshot.js";
|
||||
import type { RuntimeSession } from "../types/runtime.js";
|
||||
import type { ActiveSkillSnapshot } from "../types/runtime.js";
|
||||
|
||||
/** 作品或快照绑定的 skill 包 id(兼容旧 orchestratorId 字段) */
|
||||
export function bookSkillPackId(book: BookProject): string | undefined {
|
||||
return book.activeSkillId ?? book.orchestratorId;
|
||||
}
|
||||
|
||||
export function sessionSkillPackId(session: RuntimeSession): string | undefined {
|
||||
const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
return snap?.name;
|
||||
}
|
||||
|
||||
export function persistedSkillPackId(snapshot: PersistedBookSession): string | undefined {
|
||||
return (
|
||||
sessionSkillPackId(snapshot.runtimeSession) ?? snapshot.orchestratorId ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function runSnapshotSkillPackId(snapshot: RunSnapshot): string | undefined {
|
||||
return (
|
||||
sessionSkillPackId(snapshot.runtimeSession) ?? snapshot.orchestratorId ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function skillPacksMatch(
|
||||
book: BookProject,
|
||||
snapshotSkill: string | undefined,
|
||||
): boolean {
|
||||
const bookSkill = bookSkillPackId(book);
|
||||
if (!snapshotSkill) return true;
|
||||
if (!bookSkill) return true;
|
||||
return bookSkill === snapshotSkill;
|
||||
}
|
||||
53
src/book/snapshot-filters.ts
Normal file
53
src/book/snapshot-filters.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { BlackboardItem } from "../types/blackboard.js";
|
||||
import type { RuntimeSession } from "../types/runtime.js";
|
||||
|
||||
/**
|
||||
* 判断黑板 tag 是否属于 run 阶段(非实例化确认稿)。
|
||||
* 实例快照保存/加载时会剥离这些 tag,只保留「实例化后的对象」。
|
||||
*/
|
||||
export function isRunPhaseBlackboardTag(tag: string): boolean {
|
||||
// 实例化层:保留
|
||||
if (/^角色\.[^.]+\.设定$/.test(tag)) return false;
|
||||
if (/^用户\./.test(tag)) return false;
|
||||
if (/^book\./.test(tag)) return false;
|
||||
if (/^情境\./.test(tag)) return false;
|
||||
if (/^博弈\./.test(tag)) return false;
|
||||
|
||||
// run 层:剥离
|
||||
return (
|
||||
/^运行\./.test(tag) ||
|
||||
/^世界\.(当前|裁决)/.test(tag) ||
|
||||
/^场景\.公开/.test(tag) ||
|
||||
/^输出\./.test(tag) ||
|
||||
/^角色\.[^.]+\.(可见信息|思考|行动)$/.test(tag) ||
|
||||
/^review\./.test(tag)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterBlackboardForInstance(items: BlackboardItem[]): BlackboardItem[] {
|
||||
return items.filter((item) => !isRunPhaseBlackboardTag(item.tag));
|
||||
}
|
||||
|
||||
/** 读档实例时:去掉 run 产物记录,清空 pending,便于从 instanceReady 重新开跑 */
|
||||
export function prepareRuntimeSessionForInstance(session: RuntimeSession): RuntimeSession {
|
||||
const copy = structuredClone(session);
|
||||
copy.pendingArtifactId = undefined;
|
||||
copy.pendingDecision = undefined;
|
||||
copy.currentWorkerId = undefined;
|
||||
copy.waitingReason = undefined;
|
||||
copy.artifacts = copy.artifacts.filter((a) =>
|
||||
a.outputTags.every((tag) => !isRunPhaseBlackboardTag(tag)),
|
||||
);
|
||||
if (copy.phase === "done") copy.phase = "running";
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function materializeInstanceSnapshotPayload(payload: {
|
||||
runtimeSession: RuntimeSession;
|
||||
blackboardItems: BlackboardItem[];
|
||||
}): { runtimeSession: RuntimeSession; blackboardItems: BlackboardItem[] } {
|
||||
return {
|
||||
blackboardItems: filterBlackboardForInstance(payload.blackboardItems),
|
||||
runtimeSession: prepareRuntimeSessionForInstance(payload.runtimeSession),
|
||||
};
|
||||
}
|
||||
117
src/book/store.ts
Normal file
117
src/book/store.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { BookProject, BookSummary } from "../types/book.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||
import { deleteBookSession } from "./session-store.js";
|
||||
import { deleteAllRunSnapshots } from "./run-snapshot-store.js";
|
||||
|
||||
function booksDir(): string {
|
||||
return path.join(getUserDataDir(), "books");
|
||||
}
|
||||
|
||||
function bookPath(id: string): string {
|
||||
return path.join(booksDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
export function listBooks(): BookSummary[] {
|
||||
ensureUserDataDirs();
|
||||
mkdirSync(booksDir(), { recursive: true });
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(booksDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const books: BookProject[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = readFileSync(path.join(booksDir(), file), "utf8");
|
||||
books.push(JSON.parse(raw) as BookProject);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
|
||||
return books
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
activeSkillId: b.activeSkillId ?? b.orchestratorId,
|
||||
activeSkillName: b.activeSkillName ?? b.orchestratorName,
|
||||
preview: b.preview,
|
||||
updatedAt: b.updatedAt,
|
||||
orchestratorId: b.orchestratorId,
|
||||
orchestratorName: b.orchestratorName,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBook(id: string): BookProject | null {
|
||||
try {
|
||||
const raw = readFileSync(bookPath(id), "utf8");
|
||||
return JSON.parse(raw) as BookProject;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBook(input: { title?: string }): BookProject {
|
||||
ensureUserDataDirs();
|
||||
mkdirSync(booksDir(), { recursive: true });
|
||||
const now = new Date().toISOString();
|
||||
const book: BookProject = {
|
||||
id: randomUUID(),
|
||||
title: input.title?.trim() || "未命名作品",
|
||||
preview: "新建作品,选择 skill 包开始…",
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
writeFileSync(bookPath(book.id), JSON.stringify(book, null, 2), "utf8");
|
||||
return book;
|
||||
}
|
||||
|
||||
export function updateBook(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
BookProject,
|
||||
| "title"
|
||||
| "preview"
|
||||
| "sessionIds"
|
||||
| "activeSessionId"
|
||||
| "activeSkillId"
|
||||
| "activeSkillName"
|
||||
>
|
||||
>,
|
||||
): BookProject {
|
||||
const book = getBook(id);
|
||||
if (!book) throw new Error("Book 不存在");
|
||||
const updated: BookProject = {
|
||||
...book,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(bookPath(id), JSON.stringify(updated, null, 2), "utf8");
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function appendBookSession(id: string, sessionId: string): void {
|
||||
const book = getBook(id);
|
||||
if (!book) return;
|
||||
if (!book.sessionIds.includes(sessionId)) {
|
||||
updateBook(id, { sessionIds: [...book.sessionIds, sessionId] });
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteBook(id: string): void {
|
||||
deleteBookSession(id);
|
||||
deleteAllRunSnapshots(id);
|
||||
try {
|
||||
unlinkSync(bookPath(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
169
src/cli/phase-demo.ts
Normal file
169
src/cli/phase-demo.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 阶段机 + Skill 演示 CLI(不依赖 LLM)
|
||||
*
|
||||
* 运行:npm run phase-demo
|
||||
* npm run phase-script
|
||||
*/
|
||||
import * as readline from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import {
|
||||
createDecision,
|
||||
formatSession,
|
||||
PhaseRuntime,
|
||||
runMinimalClosedLoop,
|
||||
} from "../runtime/phase-runtime.js";
|
||||
import { getAllowedEvents } from "../runtime/phase-machine.js";
|
||||
|
||||
const autoStub = process.argv.includes("--auto");
|
||||
const runScript = process.argv.includes("--script");
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`阶段机 + Skill 演示
|
||||
|
||||
启动流程:
|
||||
1. /start → 列出 skills/ 下的 SKILL.md
|
||||
2. 输入 skill name 或编号(如 basic 或 1)
|
||||
3. 按 SKILL.md「启动询问」回答
|
||||
4. /decide worker outline-worker approve → /approve → …
|
||||
|
||||
命令:
|
||||
/start 开始会话
|
||||
/skills 列出可用 skill
|
||||
/status 当前 phase
|
||||
/decide ... 模拟总管决策(见下)
|
||||
/approve /accept 用户确认
|
||||
/worker-start 手动启动 worker
|
||||
/worker-done worker 完成
|
||||
/quit
|
||||
|
||||
/decide 子命令:
|
||||
/decide worker <id> [approve]
|
||||
/decide finish [理由]
|
||||
|
||||
加 --auto 时 worker 自动占位完成。`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runtime = new PhaseRuntime({
|
||||
autoStubWorker: autoStub || runScript,
|
||||
onMessage: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
if (runScript) {
|
||||
const session = await runMinimalClosedLoop(runtime);
|
||||
console.log(formatSession(session));
|
||||
console.log(session.phase === "done" ? "✓ 闭环完成" : "✗ 未完成");
|
||||
process.exit(session.phase === "done" ? 0 : 1);
|
||||
}
|
||||
|
||||
console.log("阶段机 + Skill 演示\n");
|
||||
printHelp();
|
||||
|
||||
const rl = readline.createInterface({ input, output });
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const line = (await rl.question("> ")).trim();
|
||||
if (!line) continue;
|
||||
|
||||
try {
|
||||
if (line === "/quit") break;
|
||||
if (line === "/help") {
|
||||
printHelp();
|
||||
continue;
|
||||
}
|
||||
if (line === "/start") {
|
||||
await runtime.start();
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
continue;
|
||||
}
|
||||
if (line === "/skills") {
|
||||
for (const [i, s] of runtime.getAvailableSkills().entries()) {
|
||||
console.log(` ${i + 1}. ${s.name} — ${s.description}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line === "/status") {
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
if (runtime.getActiveSkill()) {
|
||||
console.log("activeSkill:", runtime.getActiveSkill()?.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line === "/events") {
|
||||
console.log(getAllowedEvents(runtime.getSession()).join(", "));
|
||||
continue;
|
||||
}
|
||||
if (line === "/approve") {
|
||||
await runtime.approve();
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
continue;
|
||||
}
|
||||
if (line === "/accept") {
|
||||
await runtime.acceptArtifact();
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
continue;
|
||||
}
|
||||
if (line === "/worker-start") {
|
||||
await runtime.startPendingWorker();
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
continue;
|
||||
}
|
||||
if (line === "/worker-done") {
|
||||
await runtime.workerComplete();
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("/decide ")) {
|
||||
await handleDecide(runtime, line.slice("/decide ".length));
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
continue;
|
||||
}
|
||||
|
||||
await runtime.submitInput(line);
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
|
||||
if (runtime.getSession().phase === "done") {
|
||||
console.log("流程已完成。");
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecide(runtime: PhaseRuntime, args: string): Promise<void> {
|
||||
const parts = args.split(" ");
|
||||
if (parts[0] === "finish") {
|
||||
await runtime.submitDecision(
|
||||
createDecision({
|
||||
action: "finish",
|
||||
reason: parts.slice(1).join(" ") || "完成",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parts[0] === "worker") {
|
||||
const workerId = parts[1];
|
||||
if (!workerId) throw new Error("用法: /decide worker <id> [approve]");
|
||||
await runtime.submitDecision(
|
||||
createDecision({
|
||||
action: "run_worker",
|
||||
reason: `调度 ${workerId}`,
|
||||
workerId,
|
||||
requiresApproval: parts[2] === "approve",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
134
src/cli/run.ts
Normal file
134
src/cli/run.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Skill 流程 + 总管 LLM 演示 CLI
|
||||
*
|
||||
* npm run dev # 真实 LLM(需 OPENAI_API_KEY)
|
||||
* npm run demo # Mock LLM
|
||||
*/
|
||||
import * as readline from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { loadLlmConfigOptional } from "../config/env.js";
|
||||
import {
|
||||
createMockMainAgentResponse,
|
||||
MockLlmProvider,
|
||||
} from "../llm/client.js";
|
||||
import { createDefaultMainAgentLlm } from "../runtime/llm-factory.js";
|
||||
import { formatSession, PhaseRuntime } from "../runtime/phase-runtime.js";
|
||||
|
||||
const useMock = process.argv.includes("--mock");
|
||||
|
||||
function printSession(runtime: PhaseRuntime): void {
|
||||
console.log("\n--- 会话 ---");
|
||||
console.log(formatSession(runtime.getSession()));
|
||||
const index = runtime.getBlackboard().listTagIndex();
|
||||
if (index.length > 0) {
|
||||
console.log("blackboard tags:");
|
||||
for (const entry of index) {
|
||||
console.log(` - ${entry.tag} [${entry.source}]`);
|
||||
}
|
||||
}
|
||||
console.log("----------------\n");
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`命令:
|
||||
<文本> 选 skill / 提交输入 / 驳回时发修改意见
|
||||
/approve 确认总管建议的 worker
|
||||
/accept 接受当前产物
|
||||
/reject-art 拒绝当前产物
|
||||
/status 查看状态
|
||||
/quit 退出`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const llm = useMock
|
||||
? new MockLlmProvider([
|
||||
createMockMainAgentResponse({
|
||||
action: "run_worker",
|
||||
reason: "建议生成大纲",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
}),
|
||||
createMockMainAgentResponse({
|
||||
action: "finish",
|
||||
reason: "完成",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
])
|
||||
: loadLlmConfigOptional()
|
||||
? createDefaultMainAgentLlm()
|
||||
: null;
|
||||
|
||||
if (!llm) {
|
||||
console.error("请设置 OPENAI_API_KEY 或使用 --mock");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (useMock || !loadLlmConfigOptional()) {
|
||||
console.log("使用 Mock LLM\n");
|
||||
}
|
||||
|
||||
const runtime = new PhaseRuntime({
|
||||
autoStubWorker: true,
|
||||
llm,
|
||||
onMessage: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
printSession(runtime);
|
||||
printHelp();
|
||||
|
||||
const rl = readline.createInterface({ input, output });
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const line = (await rl.question("> ")).trim();
|
||||
if (!line) continue;
|
||||
|
||||
if (line === "/quit") break;
|
||||
if (line === "/help") {
|
||||
printHelp();
|
||||
continue;
|
||||
}
|
||||
if (line === "/status") {
|
||||
printSession(runtime);
|
||||
continue;
|
||||
}
|
||||
if (line === "/approve") {
|
||||
await runtime.approve();
|
||||
printSession(runtime);
|
||||
continue;
|
||||
}
|
||||
if (line === "/accept") {
|
||||
await runtime.acceptArtifact();
|
||||
printSession(runtime);
|
||||
continue;
|
||||
}
|
||||
if (line === "/reject-art") {
|
||||
await runtime.rejectArtifact("用户拒绝产物");
|
||||
printSession(runtime);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason = runtime.getSession().waitingReason;
|
||||
if (reason?.kind === "approve_step") {
|
||||
await runtime.rejectStep(line);
|
||||
} else {
|
||||
await runtime.submitInput(line);
|
||||
}
|
||||
printSession(runtime);
|
||||
|
||||
if (runtime.getSession().phase === "done") {
|
||||
console.log("流程已完成。");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
159
src/config/api-profiles.ts
Normal file
159
src/config/api-profiles.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { LlmConfig } from "./env.js";
|
||||
import { loadLlmConfigOptional } from "./env.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
|
||||
|
||||
export type ApiProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ApiProfilesFile = {
|
||||
version: 1;
|
||||
profiles: ApiProfile[];
|
||||
};
|
||||
|
||||
const FILE_NAME = "profiles.json";
|
||||
|
||||
function profilesPath(): string {
|
||||
return path.join(getUserDataDir(), FILE_NAME);
|
||||
}
|
||||
|
||||
function readFile(): ApiProfilesFile {
|
||||
ensureUserDataDirs();
|
||||
try {
|
||||
const raw = readFileSync(profilesPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as ApiProfilesFile;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.profiles)) {
|
||||
return { version: 1, profiles: [] };
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return { version: 1, profiles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(data: ApiProfilesFile): void {
|
||||
ensureUserDataDirs();
|
||||
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
|
||||
if (data.profiles.length > 0) return data;
|
||||
const env = loadLlmConfigOptional();
|
||||
if (!env) return data;
|
||||
const now = new Date().toISOString();
|
||||
const profile: ApiProfile = {
|
||||
id: randomUUID(),
|
||||
name: "环境变量 (.env)",
|
||||
baseUrl: env.baseUrl,
|
||||
apiKey: env.apiKey,
|
||||
model: env.model,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return { version: 1, profiles: [profile] };
|
||||
}
|
||||
|
||||
export function listApiProfiles(): ApiProfile[] {
|
||||
const data = seedFromEnvIfEmpty(readFile());
|
||||
if (data.profiles.length !== readFile().profiles.length) {
|
||||
writeFile(data);
|
||||
}
|
||||
return data.profiles;
|
||||
}
|
||||
|
||||
export function getApiProfile(id: string): ApiProfile | null {
|
||||
return listApiProfiles().find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function createApiProfile(input: {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}): ApiProfile {
|
||||
const data = readFile();
|
||||
const now = new Date().toISOString();
|
||||
const profile: ApiProfile = {
|
||||
id: randomUUID(),
|
||||
name: input.name.trim() || "未命名",
|
||||
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
|
||||
apiKey: input.apiKey.trim(),
|
||||
model: input.model.trim() || "deepseek-v4-pro",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.profiles.push(profile);
|
||||
writeFile(data);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function updateApiProfile(
|
||||
id: string,
|
||||
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
|
||||
): ApiProfile {
|
||||
const data = readFile();
|
||||
const index = data.profiles.findIndex((p) => p.id === id);
|
||||
if (index < 0) throw new Error("API 配置不存在");
|
||||
const current = data.profiles[index];
|
||||
const updated: ApiProfile = {
|
||||
...current,
|
||||
name: input.name?.trim() || current.name,
|
||||
baseUrl: input.baseUrl?.trim() || current.baseUrl,
|
||||
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
|
||||
? input.apiKey.trim()
|
||||
: current.apiKey,
|
||||
model: input.model?.trim() || current.model,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
data.profiles[index] = updated;
|
||||
writeFile(data);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteApiProfile(id: string): void {
|
||||
const data = readFile();
|
||||
data.profiles = data.profiles.filter((p) => p.id !== id);
|
||||
writeFile(data);
|
||||
}
|
||||
|
||||
export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
|
||||
return {
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKey: profile.apiKey,
|
||||
model: profile.model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function testApiProfile(profile: ApiProfile): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${profile.apiKey}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "连接成功" };
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: false,
|
||||
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "连接失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
56
src/config/env.ts
Normal file
56
src/config/env.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type LlmConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function loadLlmConfig(): LlmConfig {
|
||||
const baseUrl =
|
||||
process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com";
|
||||
const apiKey = process.env.OPENAI_API_KEY ?? "";
|
||||
const model = process.env.OPENAI_MODEL ?? "deepseek-v4-pro";
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"Missing OPENAI_API_KEY. Set it in environment or use --mock for offline demo.",
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl, apiKey, model };
|
||||
}
|
||||
|
||||
export function loadLlmConfigOptional(): LlmConfig | null {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
baseUrl: process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com",
|
||||
apiKey,
|
||||
model: process.env.OPENAI_MODEL ?? "deepseek-v4-pro",
|
||||
};
|
||||
}
|
||||
|
||||
export function loadDotEnv(cwd = process.cwd()): void {
|
||||
const envPath = path.join(cwd, ".env");
|
||||
if (!existsSync(envPath)) return;
|
||||
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
if (process.env[key] !== undefined) continue;
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
101
src/config/settings.ts
Normal file
101
src/config/settings.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { listApiProfiles } from "./api-profiles.js";
|
||||
import { loadLlmConfigOptional } from "./env.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
|
||||
|
||||
export type AppSettings = {
|
||||
version: 1;
|
||||
activeProfileId: string | null;
|
||||
activePresetId: string | null;
|
||||
};
|
||||
|
||||
const FILE_NAME = "settings.json";
|
||||
|
||||
function settingsPath(): string {
|
||||
return path.join(getUserDataDir(), FILE_NAME);
|
||||
}
|
||||
|
||||
function defaultSettings(): AppSettings {
|
||||
return {
|
||||
version: 1,
|
||||
activeProfileId: null,
|
||||
activePresetId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAppSettings(): AppSettings {
|
||||
ensureUserDataDirs();
|
||||
try {
|
||||
const raw = readFileSync(settingsPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as AppSettings;
|
||||
if (parsed.version !== 1) return defaultSettings();
|
||||
return {
|
||||
version: 1,
|
||||
activeProfileId: parsed.activeProfileId ?? null,
|
||||
activePresetId: parsed.activePresetId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return defaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppSettings(settings: AppSettings): void {
|
||||
ensureUserDataDirs();
|
||||
writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export function setActiveProfileId(id: string | null): AppSettings {
|
||||
const settings = loadAppSettings();
|
||||
settings.activeProfileId = id;
|
||||
saveAppSettings(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function setActivePresetId(id: string | null): AppSettings {
|
||||
const settings = loadAppSettings();
|
||||
settings.activePresetId = id;
|
||||
saveAppSettings(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** 解析当前应使用的 API profile:settings > 首个 profile > .env */
|
||||
export function resolveActiveProfile() {
|
||||
const settings = loadAppSettings();
|
||||
const profiles = listApiProfiles();
|
||||
|
||||
if (settings.activeProfileId) {
|
||||
const found = profiles.find((p) => p.id === settings.activeProfileId);
|
||||
if (found?.apiKey?.trim()) return found;
|
||||
}
|
||||
|
||||
if (profiles.length > 0) {
|
||||
const withKey = profiles.find((p) => p.apiKey?.trim());
|
||||
if (withKey) return withKey;
|
||||
}
|
||||
|
||||
const env = loadLlmConfigOptional();
|
||||
if (env) {
|
||||
return {
|
||||
id: "__env__",
|
||||
name: "环境变量 (.env)",
|
||||
baseUrl: env.baseUrl,
|
||||
apiKey: env.apiKey,
|
||||
model: env.model,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ensureActiveProfileDefault(): AppSettings {
|
||||
const settings = loadAppSettings();
|
||||
const profiles = listApiProfiles();
|
||||
if (!settings.activeProfileId && profiles.length > 0) {
|
||||
settings.activeProfileId = profiles[0].id;
|
||||
saveAppSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
25
src/config/user-data-dir.ts
Normal file
25
src/config/user-data-dir.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/** 本地用户数据目录(不进 git、不同步) */
|
||||
export function getUserDataDir(): string {
|
||||
if (process.env.WRITING_AGENT_DATA_DIR) {
|
||||
return path.resolve(process.env.WRITING_AGENT_DATA_DIR);
|
||||
}
|
||||
return path.join(os.homedir(), ".writing-agent");
|
||||
}
|
||||
|
||||
export function getPresetsDir(): string {
|
||||
return path.join(getUserDataDir(), "presets");
|
||||
}
|
||||
|
||||
export function getBooksDir(): string {
|
||||
return path.join(getUserDataDir(), "books");
|
||||
}
|
||||
|
||||
export function ensureUserDataDirs(): void {
|
||||
mkdirSync(getPresetsDir(), { recursive: true });
|
||||
mkdirSync(getBooksDir(), { recursive: true });
|
||||
mkdirSync(path.join(getUserDataDir(), "stats"), { recursive: true });
|
||||
}
|
||||
86
src/intake/extract.ts
Normal file
86
src/intake/extract.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import type { IntakeFieldDef } from "../types/intake.js";
|
||||
import { extractIntakeHeuristic } from "./intake.js";
|
||||
|
||||
export async function extractIntakeFromMessage(
|
||||
text: string,
|
||||
fields: IntakeFieldDef[],
|
||||
current: Record<string, string>,
|
||||
llm?: LlmProvider,
|
||||
): Promise<Record<string, string>> {
|
||||
if (!text.trim()) return { ...current };
|
||||
if (llm) {
|
||||
try {
|
||||
return await extractWithLlm(text, fields, current, llm);
|
||||
} catch {
|
||||
/* fallback */
|
||||
}
|
||||
}
|
||||
return extractIntakeHeuristic(text, fields, current);
|
||||
}
|
||||
|
||||
async function extractWithLlm(
|
||||
text: string,
|
||||
fields: IntakeFieldDef[],
|
||||
current: Record<string, string>,
|
||||
llm: LlmProvider,
|
||||
): Promise<Record<string, string>> {
|
||||
const fieldList = fields.map((f) => ({
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
required: f.required,
|
||||
current: current[f.id] ?? null,
|
||||
}));
|
||||
|
||||
const result = await llm.complete(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `你是信息抽取助手。根据用户最新消息,更新「填空题」各字段的值。
|
||||
规则:
|
||||
1. 只输出 JSON:{ "updates": { "<fieldId>": "<完整字段值或 null>" } }
|
||||
2. 仅更新用户本条消息明确提到或能推断的字段;未提及的字段不要出现在 updates 中
|
||||
3. 若某字段已有 current 值且用户是在补充,合并新旧内容
|
||||
4. 不要臆造用户未说的细节
|
||||
5. 「角色」须能识别出至少 2 个参与者或其倾向;「进程」包含轮次、局数或终止条件(如破产)`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify(
|
||||
{ fields: fieldList, userMessage: text },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
{ responseFormat: "json_object", caller: "intake_extract" },
|
||||
);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result.content);
|
||||
} catch {
|
||||
return extractIntakeHeuristic(text, fields, current);
|
||||
}
|
||||
|
||||
const updates = (parsed as { updates?: Record<string, unknown> }).updates;
|
||||
if (!updates || typeof updates !== "object") {
|
||||
return extractIntakeHeuristic(text, fields, current);
|
||||
}
|
||||
|
||||
const next = { ...current };
|
||||
for (const field of fields) {
|
||||
const val = updates[field.id];
|
||||
if (val === null || val === undefined) continue;
|
||||
if (typeof val !== "string" || !val.trim()) continue;
|
||||
const merged = next[field.id]?.trim()
|
||||
? `${next[field.id].trim()}\n${val.trim()}`
|
||||
: val.trim();
|
||||
next[field.id] = merged;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return extractIntakeHeuristic(text, fields, current);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
170
src/intake/intake.ts
Normal file
170
src/intake/intake.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
IntakeFieldDef,
|
||||
IntakeFieldStatus,
|
||||
IntakeProgress,
|
||||
} from "../types/intake.js";
|
||||
import type { StartupInquiry } from "../skills/types.js";
|
||||
|
||||
export function intakeFieldId(
|
||||
label: string,
|
||||
index: number,
|
||||
required: boolean,
|
||||
): string {
|
||||
const slug = label
|
||||
.replace(/[((][^))]*[))]/g, "")
|
||||
.replace(/\*\*/g, "")
|
||||
.replace(/至少\s*\d+\s*个/g, "")
|
||||
.trim()
|
||||
.slice(0, 28)
|
||||
.replace(/[^\w\u4e00-\u9fff-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase();
|
||||
return `${required ? "r" : "o"}-${index}-${slug || "field"}`;
|
||||
}
|
||||
|
||||
export function intakeFieldsFromInquiry(inquiry: StartupInquiry): IntakeFieldDef[] {
|
||||
const required = inquiry.requiredFields.map((label, i) => ({
|
||||
id: intakeFieldId(label, i, true),
|
||||
label,
|
||||
required: true as const,
|
||||
}));
|
||||
const optional = (inquiry.optionalFields ?? []).map((label, i) => ({
|
||||
id: intakeFieldId(label, i, false),
|
||||
label,
|
||||
required: false as const,
|
||||
}));
|
||||
return [...required, ...optional];
|
||||
}
|
||||
|
||||
export function readIntakeValues(
|
||||
slots: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const raw = slots.intakeValues;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof v === "string" && v.trim()) out[k] = v.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildIntakeProgress(
|
||||
fields: IntakeFieldDef[],
|
||||
values: Record<string, string>,
|
||||
): IntakeProgress {
|
||||
const items: IntakeFieldStatus[] = fields.map((f) => {
|
||||
const value = values[f.id]?.trim();
|
||||
return {
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
required: f.required,
|
||||
value: value || undefined,
|
||||
filled: Boolean(value),
|
||||
};
|
||||
});
|
||||
const required = items.filter((i) => i.required);
|
||||
const optional = items.filter((i) => !i.required);
|
||||
return {
|
||||
fields: items,
|
||||
requiredTotal: required.length,
|
||||
requiredFilled: required.filter((i) => i.filled).length,
|
||||
optionalTotal: optional.length,
|
||||
optionalFilled: optional.filter((i) => i.filled).length,
|
||||
ready: required.length > 0 ? required.every((i) => i.filled) : items.some((i) => i.filled),
|
||||
};
|
||||
}
|
||||
|
||||
/** 将各字段拼成写入黑板 / slots 的需求文档 */
|
||||
export function synthesizeDemandText(
|
||||
fields: IntakeFieldDef[],
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
for (const f of fields) {
|
||||
const v = values[f.id]?.trim();
|
||||
if (!v) continue;
|
||||
parts.push(`## ${f.label}\n${v}`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/** 必要项未齐时的一次追问文案 */
|
||||
export function buildIntakeFollowUpMessage(progress: IntakeProgress): string {
|
||||
const missing = progress.fields.filter((f) => f.required && !f.filled);
|
||||
if (!missing.length) return "";
|
||||
const lines = missing.map((f) => `- ${f.label}`);
|
||||
return `还缺以下必要项,请补充(可简短回答):\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
/** 合并用户在启动阶段的原始输入(worker 兜底用) */
|
||||
export function collectUserInputTranscript(
|
||||
slots: Record<string, unknown>,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
const userInputs = slots.userInputs;
|
||||
if (Array.isArray(userInputs)) {
|
||||
for (const entry of userInputs) {
|
||||
if (typeof entry === "string" && entry.trim()) {
|
||||
parts.push(entry.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/** 无 LLM 时的启发式:优先填第一个未填必要项,并尝试关键词匹配 */
|
||||
export function extractIntakeHeuristic(
|
||||
text: string,
|
||||
fields: IntakeFieldDef[],
|
||||
current: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { ...current };
|
||||
|
||||
const next = { ...current };
|
||||
const unfilledRequired = fields.filter((f) => f.required && !next[f.id]?.trim());
|
||||
|
||||
for (const field of fields) {
|
||||
if (next[field.id]?.trim()) continue;
|
||||
const keywords = fieldKeywords(field.label);
|
||||
if (keywords.some((kw) => trimmed.includes(kw))) {
|
||||
next[field.id] = mergeFieldValue(next[field.id], trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (unfilledRequired.length === 1 && !next[unfilledRequired[0].id]?.trim()) {
|
||||
next[unfilledRequired[0].id] = trimmed;
|
||||
} else if (
|
||||
unfilledRequired.length > 1 &&
|
||||
!fields.some((f) => next[f.id]?.trim() && !current[f.id]?.trim())
|
||||
) {
|
||||
next[unfilledRequired[0].id] = mergeFieldValue(next[unfilledRequired[0].id], trimmed);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeFieldValue(existing: string | undefined, addition: string): string {
|
||||
const a = addition.trim();
|
||||
if (!existing?.trim()) return a;
|
||||
if (existing.includes(a)) return existing;
|
||||
return `${existing.trim()}\n${a}`;
|
||||
}
|
||||
|
||||
function fieldKeywords(label: string): string[] {
|
||||
const words: string[] = [];
|
||||
if (/情境|实验|局面|框架/.test(label)) {
|
||||
words.push("情境", "实验", "囚徒", "通牒", "扑克", "博弈", "游戏");
|
||||
}
|
||||
if (/角色|参与/.test(label)) words.push("角色", "参与", "玩家", "人", "赌徒");
|
||||
if (/轮次|轮|进程|结束|破产/.test(label)) {
|
||||
words.push("轮", "单轮", "多轮", "进程", "结束", "破产", "直到");
|
||||
}
|
||||
if (/题材/.test(label)) words.push("题材", "科幻", "悬疑", "言情");
|
||||
if (/篇幅/.test(label)) words.push("篇幅", "短篇", "中篇", "长篇");
|
||||
if (/人称/.test(label)) words.push("人称", "第一", "第三");
|
||||
if (/输出|思考|描写/.test(label)) words.push("思考", "描写", "报告", "场景");
|
||||
if (/信息/.test(label)) words.push("信息", "私密", "公开");
|
||||
if (words.length === 0) words.push(label.slice(0, 4));
|
||||
return words;
|
||||
}
|
||||
389
src/llm/client.ts
Normal file
389
src/llm/client.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import type { LlmConfig } from "../config/env.js";
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
|
||||
export type ToolCallPayload = {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChatMessage =
|
||||
| { role: "system" | "user"; content: string }
|
||||
| {
|
||||
role: "assistant";
|
||||
content: string | null;
|
||||
tool_calls?: ToolCallPayload[];
|
||||
}
|
||||
| { role: "tool"; content: string; tool_call_id: string };
|
||||
|
||||
export type ToolDefinition = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ParsedToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type TokenUsage = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
/** Prompt tokens served from provider cache (OpenAI cached_tokens, DeepSeek prompt_cache_hit_tokens) */
|
||||
cachedTokens?: number;
|
||||
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
|
||||
cacheMissTokens?: number;
|
||||
};
|
||||
|
||||
export type CompleteResult = {
|
||||
content: string;
|
||||
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
|
||||
reasoning?: string;
|
||||
usage?: TokenUsage;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type CompleteOptions = {
|
||||
responseFormat?: "json_object" | "text";
|
||||
generation?: GenerationParameters;
|
||||
/** 统计用途,如 main_agent / worker:write-rules */
|
||||
caller?: string;
|
||||
};
|
||||
|
||||
export type CompleteWithToolsOptions = CompleteOptions & {
|
||||
tools: ToolDefinition[];
|
||||
};
|
||||
|
||||
export type CompleteWithToolsResult = {
|
||||
content: string | null;
|
||||
toolCalls: ParsedToolCall[];
|
||||
reasoning?: string;
|
||||
usage?: TokenUsage;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type LlmProvider = {
|
||||
complete(
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult>;
|
||||
completeWithTools(
|
||||
messages: ChatMessage[],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult>;
|
||||
};
|
||||
|
||||
function buildRequestBody(
|
||||
config: LlmConfig,
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions & { tools?: ToolDefinition[] },
|
||||
): Record<string, unknown> {
|
||||
const gen = options?.generation ?? {};
|
||||
const body: Record<string, unknown> = {
|
||||
model: config.model,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (options?.tools?.length) {
|
||||
body.tools = options.tools;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
|
||||
if (gen.temperature !== undefined) body.temperature = gen.temperature;
|
||||
else body.temperature = 0.2;
|
||||
|
||||
if (gen.topP !== undefined) body.top_p = gen.topP;
|
||||
if (gen.topK !== undefined) body.top_k = gen.topK;
|
||||
if (gen.minP !== undefined) body.min_p = gen.minP;
|
||||
if (gen.frequencyPenalty !== undefined) {
|
||||
body.frequency_penalty = gen.frequencyPenalty;
|
||||
}
|
||||
if (gen.presencePenalty !== undefined) {
|
||||
body.presence_penalty = gen.presencePenalty;
|
||||
}
|
||||
if (gen.repetitionPenalty !== undefined) {
|
||||
body.repetition_penalty = gen.repetitionPenalty;
|
||||
}
|
||||
if (gen.maxOutputTokens !== undefined) {
|
||||
body.max_tokens = gen.maxOutputTokens;
|
||||
}
|
||||
if (gen.seed !== undefined) body.seed = gen.seed;
|
||||
if (gen.reasoningEffort !== undefined) {
|
||||
body.reasoning_effort = gen.reasoningEffort;
|
||||
}
|
||||
|
||||
if (options?.responseFormat === "json_object") {
|
||||
body.response_format = { type: "json_object" };
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown): number | undefined {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function readCachedTokens(u: Record<string, unknown>): number | undefined {
|
||||
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
|
||||
if (details && typeof details === "object") {
|
||||
const d = details as Record<string, unknown>;
|
||||
const cached = readFiniteNumber(d.cached_tokens ?? d.cachedTokens);
|
||||
if (cached != null) return cached;
|
||||
}
|
||||
return readFiniteNumber(u.prompt_cache_hit_tokens ?? u.promptCacheHitTokens);
|
||||
}
|
||||
|
||||
function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
|
||||
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
|
||||
}
|
||||
|
||||
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
|
||||
export function parseUsage(raw: unknown): TokenUsage | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const u = raw as Record<string, unknown>;
|
||||
const prompt = Number(u.prompt_tokens ?? u.promptTokens);
|
||||
const completion = Number(u.completion_tokens ?? u.completionTokens);
|
||||
const total = Number(u.total_tokens ?? u.totalTokens);
|
||||
if (!Number.isFinite(total) && !Number.isFinite(prompt)) return undefined;
|
||||
|
||||
const cachedTokens = readCachedTokens(u);
|
||||
const cacheMissTokens = readCacheMissTokens(u);
|
||||
|
||||
return {
|
||||
promptTokens: Number.isFinite(prompt) ? prompt : 0,
|
||||
completionTokens: Number.isFinite(completion) ? completion : 0,
|
||||
totalTokens: Number.isFinite(total)
|
||||
? total
|
||||
: (Number.isFinite(prompt) ? prompt : 0) +
|
||||
(Number.isFinite(completion) ? completion : 0),
|
||||
...(cachedTokens != null ? { cachedTokens } : {}),
|
||||
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractMessageParts(message: Record<string, unknown> | undefined): {
|
||||
content: string | null;
|
||||
reasoning?: string;
|
||||
toolCalls: ParsedToolCall[];
|
||||
} {
|
||||
if (!message) return { content: "", toolCalls: [] };
|
||||
const rawContent = message.content;
|
||||
const content =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.trim() || null
|
||||
: rawContent == null
|
||||
? null
|
||||
: "";
|
||||
const reasoning =
|
||||
typeof message.reasoning_content === "string"
|
||||
? message.reasoning_content.trim()
|
||||
: undefined;
|
||||
|
||||
const toolCalls: ParsedToolCall[] = [];
|
||||
const rawCalls = message.tool_calls;
|
||||
if (Array.isArray(rawCalls)) {
|
||||
for (const call of rawCalls) {
|
||||
if (!call || typeof call !== "object") continue;
|
||||
const c = call as Record<string, unknown>;
|
||||
const fn = c.function;
|
||||
if (!fn || typeof fn !== "object") continue;
|
||||
const f = fn as Record<string, unknown>;
|
||||
const name = typeof f.name === "string" ? f.name : "";
|
||||
const id = typeof c.id === "string" ? c.id : "";
|
||||
const args =
|
||||
typeof f.arguments === "string" ? f.arguments : "{}";
|
||||
if (name && id) {
|
||||
toolCalls.push({ id, name, arguments: args });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
return { content, reasoning: reasoning || undefined, toolCalls };
|
||||
}
|
||||
if (content) return { content, reasoning: reasoning || undefined, toolCalls };
|
||||
if (reasoning) return { content: reasoning, reasoning, toolCalls };
|
||||
return { content: "", toolCalls };
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
constructor(private readonly config: LlmConfig) {}
|
||||
|
||||
async complete(
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(buildRequestBody(this.config, messages, options)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM request failed (${response.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
model?: string;
|
||||
usage?: unknown;
|
||||
choices?: Array<{ message?: Record<string, unknown> }>;
|
||||
};
|
||||
const parts = extractMessageParts(data.choices?.[0]?.message);
|
||||
if (!parts.content && parts.toolCalls.length === 0) {
|
||||
throw new Error("LLM returned empty content");
|
||||
}
|
||||
return {
|
||||
content: parts.content ?? "",
|
||||
reasoning: parts.reasoning,
|
||||
usage: parseUsage(data.usage),
|
||||
model: data.model ?? this.config.model,
|
||||
};
|
||||
}
|
||||
|
||||
async completeWithTools(
|
||||
messages: ChatMessage[],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildRequestBody(this.config, messages, {
|
||||
...options,
|
||||
tools: options.tools,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM request failed (${response.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
model?: string;
|
||||
usage?: unknown;
|
||||
choices?: Array<{ message?: Record<string, unknown> }>;
|
||||
};
|
||||
const parts = extractMessageParts(data.choices?.[0]?.message);
|
||||
if (!parts.content && parts.toolCalls.length === 0) {
|
||||
throw new Error("LLM returned empty content and no tool calls");
|
||||
}
|
||||
return {
|
||||
content: parts.content,
|
||||
toolCalls: parts.toolCalls,
|
||||
reasoning: parts.reasoning,
|
||||
usage: parseUsage(data.usage),
|
||||
model: data.model ?? this.config.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type MockLlmStep =
|
||||
| string
|
||||
| {
|
||||
toolCalls: Array<{ name: string; arguments: Record<string, unknown>; id?: string }>;
|
||||
content?: string | null;
|
||||
};
|
||||
|
||||
export class MockLlmProvider implements LlmProvider {
|
||||
private readonly responses: MockLlmStep[];
|
||||
private index = 0;
|
||||
|
||||
constructor(responses: MockLlmStep[]) {
|
||||
this.responses = responses;
|
||||
}
|
||||
|
||||
private nextStep(): MockLlmStep {
|
||||
const step = this.responses[this.index] ?? this.responses.at(-1)!;
|
||||
this.index += 1;
|
||||
return step;
|
||||
}
|
||||
|
||||
async complete(
|
||||
_messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const step = this.nextStep();
|
||||
const response =
|
||||
typeof step === "string"
|
||||
? step
|
||||
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
|
||||
const approx = Math.max(1, Math.ceil(response.length / 4));
|
||||
return {
|
||||
content: response,
|
||||
usage: {
|
||||
promptTokens: approx,
|
||||
completionTokens: approx,
|
||||
totalTokens: approx * 2,
|
||||
},
|
||||
model: "mock",
|
||||
...(options?.caller ? {} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async completeWithTools(
|
||||
_messages: ChatMessage[],
|
||||
_options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const step = this.nextStep();
|
||||
if (typeof step === "string") {
|
||||
return {
|
||||
content: step,
|
||||
toolCalls: [],
|
||||
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
|
||||
model: "mock",
|
||||
};
|
||||
}
|
||||
const toolCalls: ParsedToolCall[] = step.toolCalls.map((tc, i) => ({
|
||||
id: tc.id ?? `mock_call_${this.index}_${i}`,
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.arguments),
|
||||
}));
|
||||
return {
|
||||
content: step.content ?? null,
|
||||
toolCalls,
|
||||
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
|
||||
model: "mock",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockMainAgentResponse(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
action: "ask_user",
|
||||
reason: "请告诉我你想创作什么类型的作品、目标篇幅和风格偏好。",
|
||||
workerId: null,
|
||||
requiresApproval: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMockToolCall(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
id?: string,
|
||||
): MockLlmStep {
|
||||
return { toolCalls: [{ name, arguments: args, id }] };
|
||||
}
|
||||
58
src/llm/preset-wrapper.ts
Normal file
58
src/llm/preset-wrapper.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
CompleteOptions,
|
||||
CompleteResult,
|
||||
CompleteWithToolsOptions,
|
||||
CompleteWithToolsResult,
|
||||
LlmProvider,
|
||||
} from "./client.js";
|
||||
|
||||
/**
|
||||
* 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。
|
||||
*/
|
||||
export class PresetLlmProvider implements LlmProvider {
|
||||
constructor(
|
||||
private readonly inner: LlmProvider,
|
||||
private readonly getPreset: () => PresetPackage | null,
|
||||
) {}
|
||||
|
||||
async complete(
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const preset = this.getPreset();
|
||||
if (!preset) {
|
||||
return this.inner.complete(messages, options);
|
||||
}
|
||||
|
||||
const presetMessages = assemblePresetMessages(preset);
|
||||
const merged = mergeMessages(presetMessages, messages);
|
||||
const generation = options?.generation ?? preset.generation;
|
||||
|
||||
return this.inner.complete(merged, {
|
||||
...options,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
async completeWithTools(
|
||||
messages: ChatMessage[],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const preset = this.getPreset();
|
||||
if (!preset) {
|
||||
return this.inner.completeWithTools(messages, options);
|
||||
}
|
||||
|
||||
const presetMessages = assemblePresetMessages(preset);
|
||||
const merged = mergeMessages(presetMessages, messages);
|
||||
const generation = options.generation ?? preset.generation;
|
||||
|
||||
return this.inner.completeWithTools(merged, {
|
||||
...options,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
}
|
||||
85
src/llm/token-tracker.ts
Normal file
85
src/llm/token-tracker.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
CompleteOptions,
|
||||
CompleteResult,
|
||||
CompleteWithToolsOptions,
|
||||
CompleteWithToolsResult,
|
||||
LlmProvider,
|
||||
} from "./client.js";
|
||||
import {
|
||||
recordTokenUsage,
|
||||
toMessageTokenUsage,
|
||||
type MessageTokenUsage,
|
||||
} from "../stats/token-store.js";
|
||||
|
||||
export type LlmTrackingContext = {
|
||||
sessionId?: string;
|
||||
bookId?: string;
|
||||
bookTitle?: string;
|
||||
orchestratorId?: string;
|
||||
/** Set after each LLM call; consumed when the next system chat message is created */
|
||||
pendingUsage?: MessageTokenUsage;
|
||||
pendingReasoning?: string;
|
||||
};
|
||||
|
||||
export class TokenTrackingProvider implements LlmProvider {
|
||||
constructor(
|
||||
private readonly inner: LlmProvider,
|
||||
private readonly getContext: () => LlmTrackingContext,
|
||||
) {}
|
||||
|
||||
async complete(
|
||||
messages: Parameters<LlmProvider["complete"]>[0],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const result = await this.inner.complete(messages, options);
|
||||
const ctx = this.getContext();
|
||||
if (result.usage) {
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: options?.caller ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async completeWithTools(
|
||||
messages: Parameters<LlmProvider["completeWithTools"]>[0],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const result = await this.inner.completeWithTools(messages, options);
|
||||
const ctx = this.getContext();
|
||||
if (result.usage) {
|
||||
const record = recordTokenUsage({
|
||||
sessionId: ctx.sessionId,
|
||||
bookId: ctx.bookId,
|
||||
bookTitle: ctx.bookTitle,
|
||||
orchestratorId: ctx.orchestratorId,
|
||||
caller: options.caller ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
promptTokens: result.usage.promptTokens,
|
||||
completionTokens: result.usage.completionTokens,
|
||||
totalTokens: result.usage.totalTokens,
|
||||
cachedTokens: result.usage.cachedTokens,
|
||||
cacheMissTokens: result.usage.cacheMissTokens,
|
||||
});
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
191
src/main-agent/main-agent.ts
Normal file
191
src/main-agent/main-agent.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import type { BlackboardTagIndex } from "../types/blackboard.js";
|
||||
import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js";
|
||||
import { runMainAgentToolLoop, type ToolLoopHandlers } from "./tool-loop.js";
|
||||
|
||||
export type MainAgentContext = {
|
||||
session: RuntimeSession;
|
||||
blackboardIndex: BlackboardTagIndex[];
|
||||
availableWorkers: Array<{ id: string; description: string }>;
|
||||
};
|
||||
|
||||
export type MainAgentRunOptions = {
|
||||
handlers: ToolLoopHandlers;
|
||||
};
|
||||
|
||||
function buildMainAgentSystemPrompt(
|
||||
workers: Array<{ id: string; description: string }>,
|
||||
): string {
|
||||
const workerLines =
|
||||
workers.length > 0
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是写作系统的总管(Main Agent)。你的职责是调度 worker,而不是直接创作正文。
|
||||
|
||||
规则:
|
||||
1. 你不能直接生成小说/文章正文。
|
||||
2. 你不能修改运行状态;statePatchAllowed 必须始终为 false。
|
||||
3. 你只能建议下一步动作:ask_user、run_worker、create_temp_worker、review_blackboard、finish。
|
||||
4. 当信息不足时,使用 ask_user 向用户提问。
|
||||
5. 当需要执行任务时,使用 run_worker,只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。
|
||||
6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。
|
||||
7. run_worker 可选 workerContext:{ "roleId": "A" },用于 role-decide 等指定当前决策角色(Runtime 写入 世界.当前角色.id)。
|
||||
8. 你不能把未验收内容当作事实。
|
||||
9. 向用户提问是 worker 的能力(ask_user tool),不是独立 worker。总管只在调度层提问。
|
||||
10. blackboardIndex 只有 tag 索引,不含正文 content。
|
||||
|
||||
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
|
||||
${workerLines}
|
||||
|
||||
输出必须是 JSON 对象,字段:
|
||||
{
|
||||
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
|
||||
"reason": "string",
|
||||
"workerId": "string | null",
|
||||
"requiresApproval": boolean,
|
||||
"workerContext": { "roleId": "string" } | null
|
||||
}`;
|
||||
}
|
||||
|
||||
export class MainAgent {
|
||||
constructor(private readonly llm: LlmProvider) {}
|
||||
|
||||
/** @deprecated 单次 JSON 决策;请用 runToolLoop */
|
||||
async decide(context: MainAgentContext): Promise<MainAgentDecision> {
|
||||
const userPrompt = buildMainAgentUserPrompt(context);
|
||||
const systemPrompt = buildMainAgentSystemPrompt(context.availableWorkers);
|
||||
const result = await this.llm.complete(
|
||||
[
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
{ responseFormat: "json_object", caller: "main_agent" },
|
||||
);
|
||||
return parseMainAgentDecision(result.content);
|
||||
}
|
||||
|
||||
/** running 相位:tool loop 直到终止 tool */
|
||||
async runToolLoop(
|
||||
context: MainAgentContext,
|
||||
options: MainAgentRunOptions,
|
||||
): Promise<MainAgentDecision> {
|
||||
const { decision } = await runMainAgentToolLoop(
|
||||
this.llm,
|
||||
context,
|
||||
options.handlers,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMainAgentUserPrompt(context: MainAgentContext): string {
|
||||
const { session, blackboardIndex, availableWorkers } = context;
|
||||
return JSON.stringify(
|
||||
{
|
||||
runtimePhase: session.phase,
|
||||
waitingReason: session.waitingReason,
|
||||
flowId: session.flowId,
|
||||
currentStepId: session.currentStepId,
|
||||
currentWorkerId: session.currentWorkerId,
|
||||
acceptanceMode: session.acceptanceMode,
|
||||
slots: session.slots,
|
||||
pendingDecision: session.pendingDecision
|
||||
? {
|
||||
id: session.pendingDecision.id,
|
||||
action: session.pendingDecision.action,
|
||||
reason: session.pendingDecision.reason,
|
||||
}
|
||||
: null,
|
||||
pendingArtifactId: session.pendingArtifactId,
|
||||
artifacts: session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
blackboardIndex,
|
||||
availableWorkers,
|
||||
instruction:
|
||||
"根据当前状态决定下一步。若 collecting_input 或 revision_requested,优先理解用户最新输入。若 planning 且已有足够信息,建议 run_worker(仅 workerId)。",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMainAgentDecision(raw: string): MainAgentDecision {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Main Agent returned invalid JSON: ${raw.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Main Agent decision must be an object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const action = obj.action;
|
||||
if (
|
||||
action !== "ask_user" &&
|
||||
action !== "run_worker" &&
|
||||
action !== "create_temp_worker" &&
|
||||
action !== "review_blackboard" &&
|
||||
action !== "finish"
|
||||
) {
|
||||
throw new Error(`Invalid action: ${String(action)}`);
|
||||
}
|
||||
|
||||
const reason = typeof obj.reason === "string" ? obj.reason : "";
|
||||
if (!reason) {
|
||||
throw new Error("Main Agent decision requires reason");
|
||||
}
|
||||
|
||||
const workerId =
|
||||
typeof obj.workerId === "string"
|
||||
? obj.workerId
|
||||
: obj.workerId === null || obj.workerId === undefined
|
||||
? undefined
|
||||
: undefined;
|
||||
|
||||
let workerContext: MainAgentDecision["workerContext"];
|
||||
const ctxRaw = obj.workerContext;
|
||||
if (ctxRaw && typeof ctxRaw === "object" && !Array.isArray(ctxRaw)) {
|
||||
const roleIdRaw = (ctxRaw as Record<string, unknown>).roleId;
|
||||
const roleId = typeof roleIdRaw === "string" ? roleIdRaw.trim() : undefined;
|
||||
if (roleId) workerContext = { roleId };
|
||||
}
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action,
|
||||
reason,
|
||||
workerId,
|
||||
workerContext,
|
||||
requiresApproval: Boolean(obj.requiresApproval),
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKERS = [
|
||||
{
|
||||
id: "write-rules",
|
||||
description: "规则怪谈:内部 core.danger + 护命规则 rules.draft + 解析",
|
||||
},
|
||||
{
|
||||
id: "outline",
|
||||
description: "根据 book.brief 生成 outline.draft",
|
||||
},
|
||||
{
|
||||
id: "review-infer",
|
||||
description: "读者视角验收 rules(不含 core.danger)",
|
||||
},
|
||||
{
|
||||
id: "review-author",
|
||||
description: "作者视角验收 rules 与 core 一致性",
|
||||
},
|
||||
] as const;
|
||||
173
src/main-agent/tool-loop.ts
Normal file
173
src/main-agent/tool-loop.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { ChatMessage, LlmProvider, ParsedToolCall } from "../llm/client.js";
|
||||
import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js";
|
||||
import type { MainAgentDecision } from "../types/runtime.js";
|
||||
import { isMainAgentTerminalTool } from "../types/tools.js";
|
||||
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
|
||||
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
|
||||
import type { MainAgentContext } from "./main-agent.js";
|
||||
|
||||
export type ToolLoopHandlers = {
|
||||
readBlackboard: (tags: string[]) => Record<string, string>;
|
||||
listWorkers: () => Array<{ id: string; description: string }>;
|
||||
listArtifacts: () => Array<{
|
||||
id: string;
|
||||
workerId: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
outputTags: string[];
|
||||
}>;
|
||||
onToolCall?: (name: string, detail: string) => void;
|
||||
};
|
||||
|
||||
export type ToolLoopResult = {
|
||||
decision: MainAgentDecision;
|
||||
iterations: number;
|
||||
toolTrace: string[];
|
||||
};
|
||||
|
||||
const MAX_TOOL_LOOP_ITERATIONS = 12;
|
||||
|
||||
function buildToolLoopSystemPrompt(
|
||||
workers: Array<{ id: string; description: string }>,
|
||||
): string {
|
||||
const workerLines =
|
||||
workers.length > 0
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是写作系统的总管(Main Agent)。你在 running 相位通过 **tool call** 推进流程。
|
||||
|
||||
规则:
|
||||
1. 你不能直接生成小说/文章正文。
|
||||
2. 你不能修改运行状态。
|
||||
3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。
|
||||
4. 终止动作只能用 tool:ask_user、run_worker、review_blackboard、finish。
|
||||
5. run_worker 只传 workerId;inputTags/outputTags 由 Runtime 从 Worker Skill 读取。
|
||||
6. requiresApproval=true 时 run_worker 需用户确认后再执行。
|
||||
7. run_worker 可选 roleId,用于 role-decide 等指定当前决策角色。
|
||||
8. 不能把未验收产物当作已定事实。
|
||||
|
||||
当前 skill 可用 worker:
|
||||
${workerLines}
|
||||
|
||||
在信息足够前可多次调用 read_blackboard 等;一旦调用终止 tool,本轮循环结束。`;
|
||||
}
|
||||
|
||||
function executeLoopTool(
|
||||
call: ParsedToolCall,
|
||||
handlers: ToolLoopHandlers,
|
||||
): string {
|
||||
const name = validateLoopToolCall(call);
|
||||
const args = JSON.parse(call.arguments || "{}") as Record<string, unknown>;
|
||||
|
||||
switch (name) {
|
||||
case "read_blackboard": {
|
||||
const tags = Array.isArray(args.tags)
|
||||
? args.tags.filter((t): t is string => typeof t === "string")
|
||||
: [];
|
||||
if (tags.length === 0) {
|
||||
return JSON.stringify({ error: "tags must be a non-empty string array" });
|
||||
}
|
||||
return JSON.stringify(handlers.readBlackboard(tags));
|
||||
}
|
||||
case "list_workers":
|
||||
return JSON.stringify(handlers.listWorkers());
|
||||
case "list_artifacts":
|
||||
return JSON.stringify(handlers.listArtifacts());
|
||||
default:
|
||||
return JSON.stringify({ error: `Unhandled loop tool: ${name}` });
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessageFromToolCalls(
|
||||
content: string | null,
|
||||
toolCalls: ParsedToolCall[],
|
||||
): ChatMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
tool_calls: toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: { name: tc.name, arguments: tc.arguments },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 总管 tool loop:在 running 相位内可多轮调用 read_blackboard 等,
|
||||
* 直到调用终止 tool 并返回 MainAgentDecision。
|
||||
*/
|
||||
export async function runMainAgentToolLoop(
|
||||
llm: LlmProvider,
|
||||
context: MainAgentContext,
|
||||
handlers: ToolLoopHandlers,
|
||||
): Promise<ToolLoopResult> {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
role: "system",
|
||||
content: buildToolLoopSystemPrompt(context.availableWorkers),
|
||||
},
|
||||
{ role: "user", content: buildMainAgentUserPrompt(context) },
|
||||
];
|
||||
|
||||
const toolTrace: string[] = [];
|
||||
|
||||
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
|
||||
const result = await llm.completeWithTools(messages, {
|
||||
tools: MAIN_AGENT_TOOL_DEFINITIONS,
|
||||
caller: "main_agent",
|
||||
});
|
||||
|
||||
if (result.toolCalls.length === 0) {
|
||||
if (result.content?.trim()) {
|
||||
const decision = parseMainAgentDecision(result.content);
|
||||
return { decision, iterations: iteration, toolTrace };
|
||||
}
|
||||
throw new Error("Main Agent returned no tool calls and no content");
|
||||
}
|
||||
|
||||
const terminalCalls = result.toolCalls.filter((tc) =>
|
||||
isMainAgentTerminalTool(tc.name),
|
||||
);
|
||||
const loopCalls = result.toolCalls.filter(
|
||||
(tc) => !isMainAgentTerminalTool(tc.name),
|
||||
);
|
||||
|
||||
if (terminalCalls.length > 1) {
|
||||
throw new Error(
|
||||
`Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
assistantMessageFromToolCalls(result.content, result.toolCalls),
|
||||
);
|
||||
|
||||
if (terminalCalls.length === 1) {
|
||||
const terminal = terminalCalls[0];
|
||||
handlers.onToolCall?.(terminal.name, terminal.arguments);
|
||||
toolTrace.push(`${terminal.name} (terminal)`);
|
||||
return {
|
||||
decision: toolCallToDecision(terminal),
|
||||
iterations: iteration,
|
||||
toolTrace,
|
||||
};
|
||||
}
|
||||
|
||||
for (const call of loopCalls) {
|
||||
const output = executeLoopTool(call, handlers);
|
||||
handlers.onToolCall?.(call.name, output.slice(0, 200));
|
||||
toolTrace.push(call.name);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: output,
|
||||
tool_call_id: call.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Main Agent tool loop exceeded ${MAX_TOOL_LOOP_ITERATIONS} iterations`,
|
||||
);
|
||||
}
|
||||
121
src/main-agent/tools.ts
Normal file
121
src/main-agent/tools.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ToolDefinition } from "../llm/client.js";
|
||||
|
||||
/** 总管可用 tool 的 OpenAI function 定义 */
|
||||
export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_blackboard",
|
||||
description:
|
||||
"读取黑板 tag 正文。tags 可为精确 tag 或带 * 前缀模式。调度前用此了解已有内容。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "要读取的 tag 或模式列表",
|
||||
},
|
||||
},
|
||||
required: ["tags"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_workers",
|
||||
description: "列出当前 skill 可调度的 worker id 与说明。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_artifacts",
|
||||
description: "列出当前会话 worker 产物(id、workerId、status、summary)。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ask_user",
|
||||
description: "信息不足时向用户提问,将暂停 agent 循环等待用户输入。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string", description: "为何需要用户输入" },
|
||||
message: { type: "string", description: "展示给用户的问题或说明" },
|
||||
},
|
||||
required: ["reason"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "run_worker",
|
||||
description:
|
||||
"调度 worker 执行任务。只传 workerId,inputTags/outputTags 由 Runtime 从 Worker Skill 读取。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workerId: { type: "string" },
|
||||
reason: { type: "string" },
|
||||
requiresApproval: {
|
||||
type: "boolean",
|
||||
description: "true 时需用户确认后才执行",
|
||||
},
|
||||
roleId: {
|
||||
type: "string",
|
||||
description: "role-decide 等 worker 的当前角色 id",
|
||||
},
|
||||
},
|
||||
required: ["workerId", "reason", "requiresApproval"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "review_blackboard",
|
||||
description: "向用户说明当前黑板与进度概况,并暂停等待用户回复。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string" },
|
||||
summary: { type: "string", description: "给用户看的概况说明" },
|
||||
},
|
||||
required: ["reason", "summary"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "finish",
|
||||
description: "正常结束当前创作流程。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string" },
|
||||
},
|
||||
required: ["reason"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
49
src/preset/assembler.ts
Normal file
49
src/preset/assembler.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ChatMessage } from "../llm/client.js";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
|
||||
export type MarkerResolver = (identifier: string) => string | null;
|
||||
|
||||
const defaultMarkerResolver: MarkerResolver = () => null;
|
||||
|
||||
/**
|
||||
* 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。
|
||||
*/
|
||||
export function assemblePresetMessages(
|
||||
preset: PresetPackage,
|
||||
resolveMarker: MarkerResolver = defaultMarkerResolver,
|
||||
): ChatMessage[] {
|
||||
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
const ordered = [...preset.promptOrder].sort(
|
||||
(a, b) => a.orderIndex - b.orderIndex,
|
||||
);
|
||||
|
||||
for (const orderItem of ordered) {
|
||||
if (!orderItem.enabled) continue;
|
||||
const entry = promptById.get(orderItem.promptId);
|
||||
if (!entry || !entry.enabled) continue;
|
||||
|
||||
let content = entry.content.trim();
|
||||
if (!content && entry.marker) {
|
||||
const resolved = resolveMarker(entry.sourceIdentifier);
|
||||
if (resolved?.trim()) content = resolved.trim();
|
||||
}
|
||||
|
||||
if (!content) continue;
|
||||
|
||||
messages.push({
|
||||
role: entry.role,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
presetMessages: ChatMessage[],
|
||||
taskMessages: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
return [...presetMessages, ...taskMessages];
|
||||
}
|
||||
46
src/preset/entries.ts
Normal file
46
src/preset/entries.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { PresetPackage, PresetPromptRole } from "../types/preset.js";
|
||||
|
||||
export type PresetEnabledEntryView = {
|
||||
orderIndex: number;
|
||||
id: string;
|
||||
name: string;
|
||||
role: PresetPromptRole;
|
||||
marker: boolean;
|
||||
content: string;
|
||||
/** 实际会注入 LLM 请求(有非空 content) */
|
||||
willInject: boolean;
|
||||
};
|
||||
|
||||
/** 按 prompt_order 列出所有启用条目及其内容 */
|
||||
export function listEnabledPresetEntries(
|
||||
preset: PresetPackage,
|
||||
): PresetEnabledEntryView[] {
|
||||
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
|
||||
const ordered = [...preset.promptOrder].sort(
|
||||
(a, b) => a.orderIndex - b.orderIndex,
|
||||
);
|
||||
const entries: PresetEnabledEntryView[] = [];
|
||||
|
||||
for (const orderItem of ordered) {
|
||||
if (!orderItem.enabled) continue;
|
||||
const entry = promptById.get(orderItem.promptId);
|
||||
if (!entry || !entry.enabled) continue;
|
||||
|
||||
const content = entry.content.trim();
|
||||
entries.push({
|
||||
orderIndex: orderItem.orderIndex,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
marker: entry.marker,
|
||||
content,
|
||||
willInject: content.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function countInjectingEntries(entries: PresetEnabledEntryView[]): number {
|
||||
return entries.filter((e) => e.willInject).length;
|
||||
}
|
||||
235
src/preset/importer.ts
Normal file
235
src/preset/importer.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
GenerationParameters,
|
||||
PresetImportReport,
|
||||
PresetPackage,
|
||||
PresetPromptEntry,
|
||||
PresetPromptOrderItem,
|
||||
UnsupportedPresetSection,
|
||||
} from "../types/preset.js";
|
||||
|
||||
type StPrompt = {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
role?: string;
|
||||
content?: string;
|
||||
marker?: boolean;
|
||||
system_prompt?: boolean;
|
||||
injection_position?: number;
|
||||
injection_depth?: number;
|
||||
injection_order?: number;
|
||||
forbid_overrides?: boolean;
|
||||
};
|
||||
|
||||
type StPromptOrderBlock = {
|
||||
character_id?: number;
|
||||
order?: Array<{ identifier: string; enabled: boolean }>;
|
||||
};
|
||||
|
||||
type SillyTavernPreset = {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
min_p?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
repetition_penalty?: number;
|
||||
openai_max_context?: number;
|
||||
openai_max_tokens?: number;
|
||||
stream_openai?: boolean;
|
||||
reasoning_effort?: string;
|
||||
verbosity?: string;
|
||||
seed?: number;
|
||||
n?: number;
|
||||
prompts?: StPrompt[];
|
||||
prompt_order?: StPromptOrderBlock[];
|
||||
regex_scripts?: unknown;
|
||||
extensions?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fff-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function normalizeRole(role: string | undefined): PresetPromptEntry["role"] {
|
||||
if (role === "user" || role === "assistant" || role === "system") {
|
||||
return role;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function readGeneration(raw: SillyTavernPreset): GenerationParameters {
|
||||
return {
|
||||
temperature: raw.temperature,
|
||||
topP: raw.top_p,
|
||||
topK: raw.top_k,
|
||||
minP: raw.min_p,
|
||||
frequencyPenalty: raw.frequency_penalty,
|
||||
presencePenalty: raw.presence_penalty,
|
||||
repetitionPenalty: raw.repetition_penalty,
|
||||
maxContextTokens: raw.openai_max_context,
|
||||
maxOutputTokens: raw.openai_max_tokens,
|
||||
stream: raw.stream_openai,
|
||||
reasoningEffort: raw.reasoning_effort,
|
||||
verbosity: raw.verbosity,
|
||||
seed: raw.seed !== undefined && raw.seed >= 0 ? raw.seed : undefined,
|
||||
variants: raw.n,
|
||||
};
|
||||
}
|
||||
|
||||
function selectPromptOrder(
|
||||
raw: SillyTavernPreset,
|
||||
promptMap: Map<string, StPrompt>,
|
||||
): StPromptOrderBlock {
|
||||
const orders = raw.prompt_order ?? [];
|
||||
if (orders.length === 0) return { order: [] };
|
||||
if (orders.length === 1) return orders[0];
|
||||
|
||||
let best = orders[0];
|
||||
let bestScore = -1;
|
||||
for (const block of orders) {
|
||||
let score = 0;
|
||||
for (const item of block.order ?? []) {
|
||||
if (!item.enabled) continue;
|
||||
const prompt = promptMap.get(item.identifier);
|
||||
if (!prompt) continue;
|
||||
if (prompt.content?.trim()) score += 3;
|
||||
else if (prompt.marker) score += 0;
|
||||
else score += 1;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = block;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] {
|
||||
const unsupported: UnsupportedPresetSection[] = [];
|
||||
if (raw.regex_scripts) {
|
||||
unsupported.push({
|
||||
path: "regex_scripts",
|
||||
reason: "第一版不支持正则脚本",
|
||||
});
|
||||
}
|
||||
if (raw.extensions) {
|
||||
unsupported.push({
|
||||
path: "extensions",
|
||||
reason: "第一版不支持扩展脚本",
|
||||
});
|
||||
}
|
||||
return unsupported;
|
||||
}
|
||||
|
||||
export function importSillyTavernPreset(
|
||||
rawInput: unknown,
|
||||
options: { name?: string; id?: string } = {},
|
||||
): PresetImportReport {
|
||||
const raw = rawInput as SillyTavernPreset;
|
||||
const warnings: string[] = [];
|
||||
const stPrompts = raw.prompts ?? [];
|
||||
const promptMap = new Map<string, StPrompt>();
|
||||
|
||||
for (const p of stPrompts) {
|
||||
const id = p.identifier ?? randomUUID();
|
||||
if (promptMap.has(id)) {
|
||||
warnings.push(`重复 identifier: ${id}`);
|
||||
}
|
||||
promptMap.set(id, p);
|
||||
}
|
||||
|
||||
const selectedOrder = selectPromptOrder(raw, promptMap);
|
||||
if ((raw.prompt_order?.length ?? 0) > 1) {
|
||||
warnings.push(
|
||||
`检测到 ${raw.prompt_order!.length} 套 prompt_order,已自动选用启用内容最多的一套 (character_id=${selectedOrder.character_id ?? "?"})`,
|
||||
);
|
||||
}
|
||||
|
||||
const prompts: PresetPromptEntry[] = stPrompts.map((p) => {
|
||||
const sourceId = p.identifier ?? randomUUID();
|
||||
return {
|
||||
id: sourceId,
|
||||
name: p.name ?? sourceId,
|
||||
enabled: p.enabled !== false,
|
||||
role: normalizeRole(p.role),
|
||||
content: p.content ?? "",
|
||||
marker: Boolean(p.marker),
|
||||
sourceIdentifier: sourceId,
|
||||
injection: {
|
||||
position: p.injection_position,
|
||||
depth: p.injection_depth,
|
||||
order: p.injection_order,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const promptOrder: PresetPromptOrderItem[] = [];
|
||||
const missingIdentifiers: string[] = [];
|
||||
const orderItems = selectedOrder.order ?? [];
|
||||
|
||||
orderItems.forEach((item, index) => {
|
||||
if (!promptMap.has(item.identifier)) {
|
||||
missingIdentifiers.push(item.identifier);
|
||||
return;
|
||||
}
|
||||
promptOrder.push({
|
||||
promptId: item.identifier,
|
||||
enabled: item.enabled,
|
||||
orderIndex: index,
|
||||
});
|
||||
});
|
||||
|
||||
const referenced = new Set(promptOrder.map((o) => o.promptId));
|
||||
const unreferencedCount = prompts.filter(
|
||||
(p) => !referenced.has(p.id),
|
||||
).length;
|
||||
|
||||
const enabledCount = promptOrder.filter((o) => {
|
||||
if (!o.enabled) return false;
|
||||
const entry = prompts.find((p) => p.id === o.promptId);
|
||||
return entry?.enabled !== false;
|
||||
}).length;
|
||||
|
||||
const generation = readGeneration(raw);
|
||||
const generationFields = Object.entries(generation)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k]) => k);
|
||||
|
||||
const presetName =
|
||||
options.name?.trim() ||
|
||||
(typeof rawInput === "object" &&
|
||||
rawInput &&
|
||||
"name" in rawInput &&
|
||||
typeof (rawInput as { name: unknown }).name === "string"
|
||||
? (rawInput as { name: string }).name
|
||||
: "导入的预设");
|
||||
|
||||
const preset: PresetPackage = {
|
||||
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
|
||||
name: presetName,
|
||||
source: "sillytavern",
|
||||
prompts,
|
||||
promptOrder,
|
||||
generation,
|
||||
unsupported: collectUnsupported(raw),
|
||||
importedAt: new Date().toISOString(),
|
||||
raw: rawInput,
|
||||
};
|
||||
|
||||
return {
|
||||
preset,
|
||||
promptCount: prompts.length,
|
||||
enabledCount,
|
||||
unreferencedCount,
|
||||
missingIdentifiers,
|
||||
generationFields,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
67
src/preset/store.ts
Normal file
67
src/preset/store.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
|
||||
import { importSillyTavernPreset } from "./importer.js";
|
||||
|
||||
function presetPath(id: string): string {
|
||||
return path.join(getPresetsDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
export function listPresets(): PresetPackage[] {
|
||||
ensureUserDataDirs();
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(getPresetsDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const presets: PresetPackage[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = readFileSync(path.join(getPresetsDir(), file), "utf8");
|
||||
presets.push(JSON.parse(raw) as PresetPackage);
|
||||
} catch {
|
||||
/* skip corrupt files */
|
||||
}
|
||||
}
|
||||
return presets.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
}
|
||||
|
||||
export function getPreset(id: string): PresetPackage | null {
|
||||
try {
|
||||
const raw = readFileSync(presetPath(id), "utf8");
|
||||
return JSON.parse(raw) as PresetPackage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePreset(preset: PresetPackage): PresetPackage {
|
||||
ensureUserDataDirs();
|
||||
writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8");
|
||||
return preset;
|
||||
}
|
||||
|
||||
export function deletePreset(id: string): void {
|
||||
try {
|
||||
unlinkSync(presetPath(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function importAndSavePreset(
|
||||
raw: unknown,
|
||||
options: { name?: string } = {},
|
||||
) {
|
||||
const report = importSillyTavernPreset(raw, options);
|
||||
savePreset(report.preset);
|
||||
return report;
|
||||
}
|
||||
|
||||
export function resolveActivePreset(activePresetId: string | null): PresetPackage | null {
|
||||
if (!activePresetId) return null;
|
||||
return getPreset(activePresetId);
|
||||
}
|
||||
78
src/runtime/llm-factory.ts
Normal file
78
src/runtime/llm-factory.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { profileToLlmConfig } from "../config/api-profiles.js";
|
||||
import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
resolveActiveProfile,
|
||||
} from "../config/settings.js";
|
||||
import { resolveActivePreset } from "../preset/store.js";
|
||||
import {
|
||||
createMockMainAgentResponse,
|
||||
MockLlmProvider,
|
||||
OpenAiCompatibleProvider,
|
||||
type LlmProvider,
|
||||
} from "../llm/client.js";
|
||||
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
|
||||
import {
|
||||
TokenTrackingProvider,
|
||||
type LlmTrackingContext,
|
||||
} from "../llm/token-tracker.js";
|
||||
|
||||
export type LlmTrackingRef = { current: LlmTrackingContext };
|
||||
|
||||
function buildInnerLlm(): LlmProvider {
|
||||
ensureActiveProfileDefault();
|
||||
const profile = resolveActiveProfile();
|
||||
|
||||
if (profile?.apiKey?.trim()) {
|
||||
return new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||
}
|
||||
|
||||
return new MockLlmProvider([
|
||||
createMockMainAgentResponse({
|
||||
action: "run_worker",
|
||||
reason: "信息已足够,建议运行 outline-worker 生成大纲。",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
}),
|
||||
createMockMainAgentResponse({
|
||||
action: "finish",
|
||||
reason: "创作流程结束",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function wrapWithPreset(inner: LlmProvider): LlmProvider {
|
||||
const settings = loadAppSettings();
|
||||
const preset = resolveActivePreset(settings.activePresetId);
|
||||
if (!preset) return inner;
|
||||
return new PresetLlmProvider(inner, () =>
|
||||
resolveActivePreset(loadAppSettings().activePresetId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Web/CLI 默认 LLM:本地 profile + 全局 preset + token 统计 */
|
||||
export function createDefaultMainAgentLlm(
|
||||
trackingRef?: LlmTrackingRef,
|
||||
): LlmProvider {
|
||||
const llm = wrapWithPreset(buildInnerLlm());
|
||||
if (!trackingRef) return llm;
|
||||
return new TokenTrackingProvider(llm, () => trackingRef.current);
|
||||
}
|
||||
|
||||
export function hasRealLlmConfig(): boolean {
|
||||
ensureActiveProfileDefault();
|
||||
return Boolean(resolveActiveProfile()?.apiKey?.trim());
|
||||
}
|
||||
|
||||
/** @deprecated 包装层下 instanceof 不可靠,请用 hasRealLlmConfig */
|
||||
export function isMockLlm(_llm: LlmProvider): boolean {
|
||||
return !hasRealLlmConfig();
|
||||
}
|
||||
|
||||
/** 设置页切换 profile / preset 后调用,返回新 LLM 实例 */
|
||||
export function reloadDefaultMainAgentLlm(
|
||||
trackingRef?: LlmTrackingRef,
|
||||
): LlmProvider {
|
||||
return createDefaultMainAgentLlm(trackingRef);
|
||||
}
|
||||
291
src/runtime/orchestrator.ts
Normal file
291
src/runtime/orchestrator.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @deprecated 请使用 PhaseRuntime(已含 Skill 流程 + 总管 LLM 循环)。
|
||||
* 本文件保留供对照测试,新入口见 phase-demo / web / run.ts。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import {
|
||||
DEFAULT_WORKERS,
|
||||
MainAgent,
|
||||
} from "../main-agent/main-agent.js";
|
||||
import {
|
||||
applyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
} from "../runtime/phase-machine.js";
|
||||
import type {
|
||||
AcceptanceMode,
|
||||
MainAgentDecision,
|
||||
PhaseEffect,
|
||||
RuntimeEvent,
|
||||
RuntimeSession,
|
||||
} from "../types/runtime.js";
|
||||
|
||||
export type RuntimeOrchestratorOptions = {
|
||||
presetId?: string;
|
||||
flowId?: string;
|
||||
llm: LlmProvider;
|
||||
blackboard?: Blackboard;
|
||||
acceptanceMode?: AcceptanceMode;
|
||||
onMessage?: (message: string) => void;
|
||||
};
|
||||
|
||||
export class RuntimeOrchestrator {
|
||||
private session: RuntimeSession;
|
||||
private readonly blackboard: Blackboard;
|
||||
private readonly mainAgent: MainAgent;
|
||||
private readonly acceptanceMode: AcceptanceMode;
|
||||
private readonly onMessage: (message: string) => void;
|
||||
|
||||
constructor(options: RuntimeOrchestratorOptions) {
|
||||
this.session = createSession(options.presetId ?? "default", options.flowId);
|
||||
this.blackboard = options.blackboard ?? new Blackboard();
|
||||
this.mainAgent = new MainAgent(options.llm);
|
||||
this.acceptanceMode = options.acceptanceMode ?? "user_confirmed";
|
||||
this.onMessage = options.onMessage ?? (() => {});
|
||||
}
|
||||
|
||||
getSession(): RuntimeSession {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
getBlackboard(): Blackboard {
|
||||
return this.blackboard;
|
||||
}
|
||||
|
||||
async start(): Promise<RuntimeSession> {
|
||||
await this.dispatch({
|
||||
type: "session_started",
|
||||
payload: {
|
||||
presetId: this.session.presetId,
|
||||
flowId: this.session.flowId,
|
||||
availableSkills: [{ name: "basic", description: "fallback", category: "novel" }],
|
||||
},
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async submitUserInput(text: string): Promise<RuntimeSession> {
|
||||
await this.dispatch({
|
||||
type: "user_submitted_input",
|
||||
payload: { text },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async approveNextStep(): Promise<RuntimeSession> {
|
||||
const decisionId = this.session.pendingDecision?.id;
|
||||
if (!decisionId) {
|
||||
throw new Error("No pending decision to approve");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_approved_next_step",
|
||||
payload: { decisionId },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async rejectNextStep(reason?: string): Promise<RuntimeSession> {
|
||||
const decisionId = this.session.pendingDecision?.id;
|
||||
if (!decisionId) {
|
||||
throw new Error("No pending decision to reject");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_rejected_next_step",
|
||||
payload: { decisionId, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) {
|
||||
throw new Error("No pending artifact to accept");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_accepted_artifact",
|
||||
payload: { artifactId: id },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) {
|
||||
throw new Error("No pending artifact to reject");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_rejected_artifact",
|
||||
payload: { artifactId: id, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async completeFlow(): Promise<RuntimeSession> {
|
||||
await this.dispatch({ type: "flow_completed", payload: {} });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private async dispatch(event: RuntimeEvent): Promise<void> {
|
||||
let result = applyEvent(this.session, event);
|
||||
this.session = result.session;
|
||||
|
||||
if (result.error && event.type !== "runtime_failed") {
|
||||
this.onMessage(result.error);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
|
||||
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
||||
for (const effect of effects) {
|
||||
switch (effect.type) {
|
||||
case "emit_message":
|
||||
this.onMessage(effect.message);
|
||||
break;
|
||||
case "run_worker":
|
||||
await this.runStubWorker(effect);
|
||||
break;
|
||||
case "resume_worker":
|
||||
await this.resumeStubWorker();
|
||||
break;
|
||||
case "run_programmatic_review":
|
||||
await this.runProgrammaticReview(effect.artifactId);
|
||||
break;
|
||||
case "invoke_main_agent":
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runMainAgent(): Promise<void> {
|
||||
if (this.session.phase !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = await this.mainAgent.decide({
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers: [...DEFAULT_WORKERS],
|
||||
});
|
||||
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = applyEvent(this.session, {
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
this.session = result.session;
|
||||
await this.processEffects(result.effects);
|
||||
|
||||
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async runStubWorker(
|
||||
effect: Extract<PhaseEffect, { type: "run_worker" }>,
|
||||
): Promise<void> {
|
||||
await this.executeStubWorker(effect.workerId);
|
||||
}
|
||||
|
||||
private async resumeStubWorker(): Promise<void> {
|
||||
const ctx = this.session.resumeContext;
|
||||
if (!ctx) {
|
||||
throw new Error("No resume context");
|
||||
}
|
||||
await this.executeStubWorker(ctx.workerId);
|
||||
}
|
||||
|
||||
private async executeStubWorker(workerId: string): Promise<void> {
|
||||
const stepId = this.session.currentStepId;
|
||||
|
||||
this.session = applyEvent(this.session, {
|
||||
type: "worker_started",
|
||||
payload: {
|
||||
workerId,
|
||||
stepId,
|
||||
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
|
||||
},
|
||||
}).session;
|
||||
|
||||
this.onMessage(`[Worker 占位] 正在执行 ${workerId} ...`);
|
||||
|
||||
const lastInput = String(this.session.slots.lastUserInput ?? "");
|
||||
const summary = `${workerId} 针对用户输入生成的占位产物`;
|
||||
const value = `[${workerId} 占位输出]\n用户输入: ${lastInput}\n说明: 这是第一版 stub worker,后续会替换为真实 LLM worker。`;
|
||||
|
||||
const outputTags = ["output.草稿"];
|
||||
|
||||
for (const tag of outputTags) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: value,
|
||||
source: workerId,
|
||||
});
|
||||
}
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId,
|
||||
stepId,
|
||||
outputTags,
|
||||
summary,
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
artifacts: [...this.session.artifacts, artifact],
|
||||
};
|
||||
|
||||
const completed = applyEvent(this.session, {
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
this.session = completed.session;
|
||||
await this.processEffects(completed.effects);
|
||||
|
||||
if (completed.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async runProgrammaticReview(artifactId: string): Promise<void> {
|
||||
this.session = applyEvent(this.session, {
|
||||
type: "programmatic_review_started",
|
||||
payload: { artifactId },
|
||||
}).session;
|
||||
|
||||
const artifact = this.session.artifacts.find((a) => a.id === artifactId);
|
||||
const passed = Boolean(artifact?.summary && artifact.summary.length > 0);
|
||||
|
||||
const reviewEvent: RuntimeEvent = passed
|
||||
? { type: "programmatic_review_passed", payload: { artifactId } }
|
||||
: {
|
||||
type: "programmatic_review_failed",
|
||||
payload: { artifactId, reason: "产物 summary 为空" },
|
||||
};
|
||||
|
||||
const result = applyEvent(this.session, reviewEvent);
|
||||
this.session = result.session;
|
||||
await this.processEffects(result.effects);
|
||||
|
||||
if (result.effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDecision(
|
||||
partial: Omit<MainAgentDecision, "id" | "statePatchAllowed"> &
|
||||
Partial<Pick<MainAgentDecision, "id">>,
|
||||
): MainAgentDecision {
|
||||
return {
|
||||
id: partial.id ?? randomUUID(),
|
||||
statePatchAllowed: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
793
src/runtime/phase-machine.ts
Normal file
793
src/runtime/phase-machine.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
/**
|
||||
* 纯函数运行阶段机。
|
||||
*
|
||||
* - 无副作用:不读写文件、不调用 LLM、不启动 worker
|
||||
* - 输入 (session, event),输出 { session, effects }
|
||||
* - phase 只能由 applyEvent 改变;非法事件返回 error 并进入 phase=error
|
||||
*
|
||||
* 上层调用方:PhaseRuntime.dispatch() → applyEvent → processEffects()
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ApplyEventResult,
|
||||
ArtifactRecord,
|
||||
PhaseEffect,
|
||||
ResumeContext,
|
||||
RuntimeEvent,
|
||||
RuntimePhase,
|
||||
RuntimeSession,
|
||||
WaitingReason,
|
||||
} from "../types/runtime.js";
|
||||
import type { ActiveSkillSnapshot } from "../types/runtime.js";
|
||||
import {
|
||||
buildIntakeProgress,
|
||||
buildIntakeFollowUpMessage,
|
||||
readIntakeValues,
|
||||
synthesizeDemandText,
|
||||
} from "../intake/intake.js";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** 更新 updatedAt 时间戳 */
|
||||
function touch(session: RuntimeSession): RuntimeSession {
|
||||
return { ...session, updatedAt: nowIso() };
|
||||
}
|
||||
|
||||
/** 将用户补充文本追加到 slot(用于合并多轮 ask_user / worker 答复到需求 tag) */
|
||||
function mergeSlotText(
|
||||
slots: Record<string, unknown>,
|
||||
key: string,
|
||||
text: string,
|
||||
): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const prev = String(slots[key] ?? "").trim();
|
||||
slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
/** 追加事件到 history 并 touch */
|
||||
function appendHistory(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
): RuntimeSession {
|
||||
return touch({ ...session, history: [...session.history, event] });
|
||||
}
|
||||
|
||||
/** 进入 waiting_user,并设置 waitingReason */
|
||||
function waiting(session: RuntimeSession, reason: WaitingReason): RuntimeSession {
|
||||
return touch({ ...session, phase: "waiting_user", waitingReason: reason });
|
||||
}
|
||||
|
||||
/** 进入 running,清除 waitingReason */
|
||||
function running(session: RuntimeSession): RuntimeSession {
|
||||
return touch({ ...session, phase: "running", waitingReason: undefined });
|
||||
}
|
||||
|
||||
function findArtifact(
|
||||
session: RuntimeSession,
|
||||
artifactId: string,
|
||||
): ArtifactRecord | undefined {
|
||||
return session.artifacts.find((a) => a.id === artifactId);
|
||||
}
|
||||
|
||||
function updateArtifact(
|
||||
session: RuntimeSession,
|
||||
artifactId: string,
|
||||
patch: Partial<ArtifactRecord>,
|
||||
): RuntimeSession {
|
||||
return {
|
||||
...session,
|
||||
artifacts: session.artifacts.map((a) =>
|
||||
a.id === artifactId ? { ...a, ...patch, updatedAt: nowIso() } : a,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建 idle 状态的新会话 */
|
||||
export function createSession(
|
||||
presetId: string,
|
||||
flowId?: string,
|
||||
): RuntimeSession {
|
||||
const ts = nowIso();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
phase: "idle",
|
||||
presetId,
|
||||
flowId,
|
||||
slots: {},
|
||||
artifacts: [],
|
||||
history: [],
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前 phase 返回允许的事件类型列表(第一层校验)。
|
||||
* waitingReason 的细粒度匹配见 canApplyEvent。
|
||||
*/
|
||||
export function getAllowedEvents(
|
||||
session: RuntimeSession,
|
||||
): RuntimeEvent["type"][] {
|
||||
switch (session.phase) {
|
||||
case "idle":
|
||||
return ["session_started", "runtime_failed"];
|
||||
case "running":
|
||||
return [
|
||||
"main_agent_decision_created",
|
||||
"worker_started",
|
||||
"worker_completed",
|
||||
"worker_needs_input",
|
||||
"programmatic_review_started",
|
||||
"programmatic_review_passed",
|
||||
"programmatic_review_failed",
|
||||
"flow_completed",
|
||||
"runtime_failed",
|
||||
];
|
||||
case "waiting_user":
|
||||
return [
|
||||
"skill_selected",
|
||||
"user_submitted_input",
|
||||
"user_confirmed_intake",
|
||||
"user_approved_next_step",
|
||||
"user_rejected_next_step",
|
||||
"user_accepted_artifact",
|
||||
"user_rejected_artifact",
|
||||
"user_requested_revision",
|
||||
"main_agent_decision_created",
|
||||
"runtime_failed",
|
||||
];
|
||||
case "done":
|
||||
case "error":
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断事件是否可在当前会话状态下应用。
|
||||
* 除 phase 白名单外,waiting_user 还需 waitingReason 与事件类型匹配。
|
||||
*/
|
||||
export function canApplyEvent(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
): boolean {
|
||||
if (!getAllowedEvents(session).includes(event.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reason = session.waitingReason;
|
||||
|
||||
switch (event.type) {
|
||||
case "skill_selected":
|
||||
return reason?.kind === "skill_selection";
|
||||
case "user_submitted_input":
|
||||
return (
|
||||
reason?.kind === "intake" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision"
|
||||
);
|
||||
case "user_confirmed_intake":
|
||||
return reason?.kind === "intake";
|
||||
case "user_approved_next_step":
|
||||
case "user_rejected_next_step":
|
||||
return reason?.kind === "approve_step";
|
||||
case "user_accepted_artifact":
|
||||
case "user_rejected_artifact":
|
||||
return reason?.kind === "review_artifact";
|
||||
case "user_requested_revision":
|
||||
return (
|
||||
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
|
||||
);
|
||||
case "main_agent_decision_created":
|
||||
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话
|
||||
return session.phase === "running" || reason?.kind === "input";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验失败或 runtime_failed:进入 error phase 并 emit_message */
|
||||
function fail(session: RuntimeSession, reason: string): ApplyEventResult {
|
||||
return {
|
||||
session: touch({ ...session, phase: "error", waitingReason: undefined }),
|
||||
effects: [{ type: "emit_message", message: reason }],
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* worker_completed 的核心分支:按 acceptanceMode 决定下一步。
|
||||
* user_confirmed 时 caller 需再包一层 waiting(review_artifact)。
|
||||
*/
|
||||
function handleWorkerCompleted(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent & { type: "worker_completed" },
|
||||
): ApplyEventResult {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
|
||||
const mode = session.acceptanceMode ?? "user_confirmed";
|
||||
let next = appendHistory(session, event);
|
||||
|
||||
if (mode === "user_confirmed") {
|
||||
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
||||
return {
|
||||
session: touch({
|
||||
...next,
|
||||
pendingArtifactId: artifact.id,
|
||||
currentWorkerId: undefined,
|
||||
resumeContext: undefined,
|
||||
}),
|
||||
effects: [],
|
||||
// worker_completed case 外层会补 waiting(review_artifact)
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "no_confirmation") {
|
||||
next = updateArtifact(next, artifact.id, { status: "accepted" });
|
||||
return {
|
||||
session: touch({
|
||||
...running(next),
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
pendingDecision: undefined,
|
||||
resumeContext: undefined,
|
||||
}),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// programmatic_review
|
||||
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
||||
return {
|
||||
session: touch({
|
||||
...running(next),
|
||||
pendingArtifactId: artifact.id,
|
||||
currentWorkerId: undefined,
|
||||
}),
|
||||
effects: [{ type: "run_programmatic_review", artifactId: artifact.id }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单个 RuntimeEvent,返回新会话与副作用列表。
|
||||
* 不合法的事件不会抛异常,而是返回 error 并将 phase 置为 error。
|
||||
*/
|
||||
export function applyEvent(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
): ApplyEventResult {
|
||||
if (!canApplyEvent(session, event)) {
|
||||
return fail(
|
||||
session,
|
||||
`Event ${event.type} is not allowed in phase ${session.phase}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
// ── 启动:选 skill ──
|
||||
case "session_started": {
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
presetId: event.payload.presetId,
|
||||
flowId: event.payload.flowId,
|
||||
},
|
||||
event,
|
||||
);
|
||||
const skills = event.payload.availableSkills;
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "skill_selection",
|
||||
availableSkills: skills,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: formatSkillSelectionPrompt(skills),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "skill_selected": {
|
||||
const { skill } = event.payload;
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
flowId: skill.defaultFlowId ?? session.flowId,
|
||||
slots: {
|
||||
...session.slots,
|
||||
activeSkill: skill,
|
||||
},
|
||||
},
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "intake",
|
||||
prompt: skill.startupPrompt,
|
||||
}),
|
||||
effects: [{ type: "emit_message", message: skill.startupPrompt }],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_confirmed_intake": {
|
||||
if (session.waitingReason?.kind !== "intake") {
|
||||
return fail(session, "user_confirmed_intake requires intake waiting");
|
||||
}
|
||||
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
if (!activeSkill?.startupTargetKey || !activeSkill.intakeFields?.length) {
|
||||
return fail(session, "No intake fields on active skill");
|
||||
}
|
||||
const values = readIntakeValues(session.slots);
|
||||
const progress = buildIntakeProgress(activeSkill.intakeFields, values);
|
||||
if (!progress.ready) {
|
||||
return fail(session, "必要项尚未填完,无法确认");
|
||||
}
|
||||
const demandText = synthesizeDemandText(activeSkill.intakeFields, values);
|
||||
const slots: Record<string, unknown> = {
|
||||
...session.slots,
|
||||
intakeValues: values,
|
||||
startupCompleted: true,
|
||||
[activeSkill.startupTargetKey]: demandText,
|
||||
lastUserInput: demandText,
|
||||
};
|
||||
const next = appendHistory({ ...session, slots, resumeContext: undefined }, event);
|
||||
return {
|
||||
session: touch(running(next)),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户输入 ──
|
||||
case "user_submitted_input": {
|
||||
const hadResume = Boolean(session.resumeContext);
|
||||
const activeSkill = session.slots.activeSkill as
|
||||
| { startupTargetKey?: string }
|
||||
| undefined;
|
||||
const slots: Record<string, unknown> = {
|
||||
...session.slots,
|
||||
lastUserInput: event.payload.text,
|
||||
userInputs: [
|
||||
...((session.slots.userInputs as string[] | undefined) ?? []),
|
||||
event.payload.text,
|
||||
],
|
||||
};
|
||||
const demandKey = activeSkill?.startupTargetKey;
|
||||
const text = event.payload.text.trim();
|
||||
|
||||
if (session.waitingReason?.kind === "intake") {
|
||||
const skill = activeSkill as ActiveSkillSnapshot | undefined;
|
||||
const intakeValues =
|
||||
event.payload.intakeValues ??
|
||||
readIntakeValues(session.slots);
|
||||
slots.intakeValues = intakeValues;
|
||||
slots.intakeSubmitCount =
|
||||
((session.slots.intakeSubmitCount as number) ?? 0) + 1;
|
||||
if (skill?.intakeFields?.length) {
|
||||
const preview = synthesizeDemandText(skill.intakeFields, intakeValues);
|
||||
if (preview.trim()) {
|
||||
slots[demandKey ?? "intake.preview"] = preview;
|
||||
} else if (text) {
|
||||
mergeSlotText(slots, demandKey ?? "intake.preview", text);
|
||||
}
|
||||
} else if (text && demandKey) {
|
||||
mergeSlotText(slots, demandKey, text);
|
||||
}
|
||||
|
||||
const progress = skill?.intakeFields?.length
|
||||
? buildIntakeProgress(skill.intakeFields, intakeValues)
|
||||
: null;
|
||||
const followUpSent = Boolean(session.slots.intakeFollowUpSent);
|
||||
const effects: PhaseEffect[] = [];
|
||||
if (
|
||||
progress &&
|
||||
!progress.ready &&
|
||||
!followUpSent &&
|
||||
(slots.intakeSubmitCount as number) >= 1
|
||||
) {
|
||||
const msg = buildIntakeFollowUpMessage(progress);
|
||||
if (msg) {
|
||||
slots.intakeFollowUpSent = true;
|
||||
effects.push({ type: "emit_message", message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
resumeContext: hadResume ? session.resumeContext : undefined,
|
||||
},
|
||||
event,
|
||||
);
|
||||
const prompt =
|
||||
session.waitingReason.prompt ?? skill?.startupPrompt ?? "";
|
||||
return {
|
||||
session: waiting(touch(next), { kind: "intake", prompt }),
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "worker_questions") {
|
||||
slots["用户.worker答复"] = text;
|
||||
if (demandKey && text) {
|
||||
mergeSlotText(slots, demandKey, text);
|
||||
}
|
||||
} else if (demandKey && session.waitingReason?.kind === "input" && text) {
|
||||
if (!session.slots.startupCompleted) {
|
||||
slots[demandKey] = text;
|
||||
slots.startupCompleted = true;
|
||||
} else {
|
||||
mergeSlotText(slots, demandKey, text);
|
||||
}
|
||||
}
|
||||
const next = appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
resumeContext: hadResume ? session.resumeContext : undefined,
|
||||
},
|
||||
event,
|
||||
);
|
||||
|
||||
if (hadResume) {
|
||||
// worker 提问后的回复 → 恢复 worker
|
||||
return {
|
||||
session: touch(running({ ...next, resumeContext: session.resumeContext })),
|
||||
effects: [{ type: "resume_worker" }],
|
||||
};
|
||||
}
|
||||
|
||||
// 常规定稿用户输入 → 等总管下一步
|
||||
return {
|
||||
session: touch(running({ ...next, resumeContext: undefined })),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 总管决策 ──
|
||||
case "main_agent_decision_created": {
|
||||
const { decision } = event.payload;
|
||||
if (decision.statePatchAllowed !== false) {
|
||||
return fail(session, "Main Agent decision must set statePatchAllowed to false");
|
||||
}
|
||||
|
||||
const next = appendHistory({ ...session, pendingDecision: decision }, event);
|
||||
|
||||
switch (decision.action) {
|
||||
case "ask_user":
|
||||
case "review_blackboard":
|
||||
return {
|
||||
session: waiting(
|
||||
{ ...next, pendingDecision: undefined },
|
||||
{ kind: "input", message: decision.reason },
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
case "finish":
|
||||
return {
|
||||
session: touch({
|
||||
...next,
|
||||
phase: "done",
|
||||
waitingReason: undefined,
|
||||
pendingDecision: undefined,
|
||||
}),
|
||||
effects: [
|
||||
{ type: "emit_message", message: decision.reason || "流程已完成。" },
|
||||
],
|
||||
};
|
||||
case "create_temp_worker":
|
||||
case "run_worker": {
|
||||
if (!decision.workerId) {
|
||||
return fail(session, "run_worker requires workerId");
|
||||
}
|
||||
if (decision.requiresApproval) {
|
||||
// 需用户确认后才真正 run_worker
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "approve_step",
|
||||
decisionId: decision.id,
|
||||
}),
|
||||
effects: [
|
||||
{ type: "emit_message", message: `等待确认:${decision.reason}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
session: touch(running({ ...next, currentWorkerId: decision.workerId })),
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: decision.workerId,
|
||||
workerContext: decision.workerContext,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return fail(session, "Unknown decision action");
|
||||
}
|
||||
}
|
||||
|
||||
case "user_approved_next_step": {
|
||||
const decision = session.pendingDecision;
|
||||
if (!decision || decision.id !== event.payload.decisionId) {
|
||||
return fail(session, "No matching pending decision to approve");
|
||||
}
|
||||
if (!decision.workerId) {
|
||||
return fail(session, "Approved decision has no workerId");
|
||||
}
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, currentWorkerId: decision.workerId },
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: decision.workerId,
|
||||
workerContext: decision.workerContext,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_rejected_next_step": {
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, pendingDecision: undefined },
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Worker 生命周期 ──
|
||||
case "worker_started": {
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
currentWorkerId: event.payload.workerId,
|
||||
currentStepId: event.payload.stepId,
|
||||
acceptanceMode: event.payload.acceptanceMode,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "worker_needs_input": {
|
||||
const questions = event.payload.questions
|
||||
.map((q) => q.trim())
|
||||
.filter(Boolean);
|
||||
const normalized =
|
||||
questions.length > 0
|
||||
? questions
|
||||
: ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
|
||||
const ctx: ResumeContext = {
|
||||
workerId: event.payload.workerId,
|
||||
stepId: event.payload.stepId ?? session.currentStepId,
|
||||
acceptanceMode: session.acceptanceMode ?? "user_confirmed",
|
||||
questions: normalized,
|
||||
};
|
||||
return {
|
||||
session: waiting(
|
||||
appendHistory(
|
||||
{ ...session, resumeContext: ctx, currentWorkerId: event.payload.workerId },
|
||||
event,
|
||||
),
|
||||
{
|
||||
kind: "worker_questions",
|
||||
workerId: event.payload.workerId,
|
||||
questions: normalized,
|
||||
},
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q}`).join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "worker_completed": {
|
||||
const result = handleWorkerCompleted(session, event);
|
||||
const mode = session.acceptanceMode ?? "user_confirmed";
|
||||
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
|
||||
return {
|
||||
...result,
|
||||
session: waiting(result.session, {
|
||||
kind: "review_artifact",
|
||||
artifactId: result.session.pendingArtifactId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 产物验收 ──
|
||||
case "user_accepted_artifact": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "accepted" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(
|
||||
running({
|
||||
...next,
|
||||
pendingArtifactId: undefined,
|
||||
pendingDecision: undefined,
|
||||
}),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_rejected_artifact": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "rejected" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "revision",
|
||||
instruction: event.payload.reason,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: event.payload.reason ?? "产物已被拒绝,等待修改指示。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_requested_revision": {
|
||||
if (session.waitingReason?.kind === "approve_step") {
|
||||
// 在确认步骤时发修改意见,等同拒绝并回到总管
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory({ ...session, pendingDecision: undefined }, event),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
session: waiting(
|
||||
appendHistory({ ...session, pendingArtifactId: undefined }, event),
|
||||
{ kind: "revision", instruction: event.payload.instruction },
|
||||
),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `返工请求:${event.payload.instruction}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 程序验收 ──
|
||||
case "programmatic_review_started":
|
||||
return { session: appendHistory(running(session), event), effects: [] };
|
||||
|
||||
case "programmatic_review_passed": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "accepted" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: touch(
|
||||
running({ ...next, pendingArtifactId: undefined }),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
};
|
||||
}
|
||||
|
||||
case "programmatic_review_failed": {
|
||||
const artifact = findArtifact(session, event.payload.artifactId);
|
||||
if (!artifact) {
|
||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||
}
|
||||
const next = appendHistory(
|
||||
updateArtifact(session, artifact.id, { status: "revision_requested" }),
|
||||
event,
|
||||
);
|
||||
return {
|
||||
session: waiting(next, {
|
||||
kind: "revision",
|
||||
instruction: event.payload.reason,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: `程序验收失败:${event.payload.reason}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 终止 ──
|
||||
case "flow_completed":
|
||||
return {
|
||||
session: touch({
|
||||
...appendHistory(session, event),
|
||||
phase: "done",
|
||||
waitingReason: undefined,
|
||||
}),
|
||||
effects: [],
|
||||
};
|
||||
|
||||
case "runtime_failed":
|
||||
return fail(appendHistory(session, event), event.payload.reason);
|
||||
|
||||
default:
|
||||
return fail(session, "Unhandled event type");
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建 worker 产物记录(drafted 状态),由 runtime 层在 worker_complete 前写入 session.artifacts */
|
||||
export function createArtifact(params: {
|
||||
workerId: string;
|
||||
stepId?: string;
|
||||
outputTags: string[];
|
||||
summary?: string;
|
||||
}): ArtifactRecord {
|
||||
const ts = nowIso();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
workerId: params.workerId,
|
||||
stepId: params.stepId,
|
||||
outputTags: params.outputTags,
|
||||
status: "drafted",
|
||||
summary: params.summary,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
}
|
||||
|
||||
export { type RuntimePhase, type WaitingReason };
|
||||
|
||||
function formatSkillSelectionPrompt(
|
||||
skills: Array<{ name: string; description: string }>,
|
||||
): string {
|
||||
const lines = skills.map((s, i) => ` ${i + 1}. ${s.name} — ${s.description}`);
|
||||
return ["请选择创作 skill(输入 name 或编号):", ...lines].join("\n");
|
||||
}
|
||||
763
src/runtime/phase-runtime.ts
Normal file
763
src/runtime/phase-runtime.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* 阶段机运行层(有副作用的执行器)。
|
||||
*
|
||||
* 职责:
|
||||
* - 持有可变 session,统一 dispatch(event) 入口
|
||||
* - 调用纯函数 applyEvent,再 processEffects 执行 IO(消息、worker、验收)
|
||||
* - 启动时从 skills/ 加载 registry,驱动 skill 选择与 SKILL.md 启动询问
|
||||
*
|
||||
* 与 phase-machine.ts 的分工:前者「算状态」,本文件「跑状态」。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
applyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
getAllowedEvents,
|
||||
} from "./phase-machine.js";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
|
||||
import { listSkills, listWorkerSkills, loadSkill, loadWorkerSkill, resolveSkillId } from "../skills/loader.js";
|
||||
import { toActiveSkillSnapshot } from "../skills/snapshot.js";
|
||||
import { runWorkerSkill } from "../worker/executor.js";
|
||||
import { resolveWorkerId } from "../worker/resolve-id.js";
|
||||
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
|
||||
import { extractIntakeFromMessage } from "../intake/extract.js";
|
||||
import { readIntakeValues } from "../intake/intake.js";
|
||||
import type {
|
||||
AcceptanceMode,
|
||||
ActiveSkillSnapshot,
|
||||
ApplyEventResult,
|
||||
MainAgentDecision,
|
||||
PhaseEffect,
|
||||
RuntimeEvent,
|
||||
RuntimeSession,
|
||||
SkillIndexEntry,
|
||||
} from "../types/runtime.js";
|
||||
import type { BlackboardItem } from "../types/blackboard.js";
|
||||
|
||||
export type PhaseRuntimeOptions = {
|
||||
presetId?: string;
|
||||
flowId?: string;
|
||||
/** worker 完成后的默认验收模式,会写入 worker_started 事件 */
|
||||
acceptanceMode?: AcceptanceMode;
|
||||
/** true 时 run_worker 自动走占位 worker(演示 / 测试用) */
|
||||
autoStubWorker?: boolean;
|
||||
/** 传入后 invoke_main_agent 会自动调用总管 LLM */
|
||||
llm?: LlmProvider;
|
||||
/** 运行时黑板;worker 产出写入此处 */
|
||||
blackboard?: Blackboard;
|
||||
/** 副作用 emit_message 与用户提示的回调(Web / CLI 接入点) */
|
||||
onMessage?: (message: string) => void;
|
||||
/** 从磁盘恢复时使用,跳过 createSession */
|
||||
initialSession?: RuntimeSession;
|
||||
/** 与 initialSession 一并恢复黑板 */
|
||||
initialBlackboardItems?: BlackboardItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 阶段机运行层:包装纯函数 phase-machine,处理副作用。
|
||||
* 启动时加载 skills/,先选 skill,再读 SKILL.md 启动询问。
|
||||
*/
|
||||
export class PhaseRuntime {
|
||||
private session: RuntimeSession;
|
||||
private readonly acceptanceMode: AcceptanceMode;
|
||||
private autoStubWorker: boolean;
|
||||
private readonly onMessage: (message: string) => void;
|
||||
private readonly blackboard: Blackboard;
|
||||
private llm?: LlmProvider;
|
||||
private mainAgent?: MainAgent;
|
||||
/** autoStubWorker=false 时,run_worker effect 暂存于此,等 startPendingWorker() */
|
||||
private pendingWorkerEffect: Extract<PhaseEffect, { type: "run_worker" }> | null =
|
||||
null;
|
||||
/** start() 时从 registry 加载,供 skill_selection 展示与编号解析 */
|
||||
private availableSkills: SkillIndexEntry[] = [];
|
||||
|
||||
constructor(options: PhaseRuntimeOptions = {}) {
|
||||
this.session =
|
||||
options.initialSession ??
|
||||
createSession(options.presetId ?? "default", options.flowId);
|
||||
this.acceptanceMode = options.acceptanceMode ?? "user_confirmed";
|
||||
this.autoStubWorker = options.autoStubWorker ?? false;
|
||||
this.onMessage = options.onMessage ?? (() => {});
|
||||
this.blackboard = options.blackboard ?? new Blackboard();
|
||||
if (options.initialBlackboardItems?.length) {
|
||||
this.blackboard.seed(options.initialBlackboardItems);
|
||||
}
|
||||
if (options.llm) {
|
||||
this.llm = options.llm;
|
||||
this.mainAgent = new MainAgent(options.llm);
|
||||
}
|
||||
}
|
||||
|
||||
getBlackboard(): Blackboard {
|
||||
return this.blackboard;
|
||||
}
|
||||
|
||||
hasMainAgent(): boolean {
|
||||
return Boolean(this.mainAgent);
|
||||
}
|
||||
|
||||
/** 设置变更后热更新 LLM(API profile / preset) */
|
||||
reloadLlm(llm: LlmProvider, autoStubWorker?: boolean): void {
|
||||
this.llm = llm;
|
||||
this.mainAgent = new MainAgent(llm);
|
||||
if (autoStubWorker !== undefined) {
|
||||
this.autoStubWorker = autoStubWorker;
|
||||
}
|
||||
}
|
||||
|
||||
getLlm(): LlmProvider | undefined {
|
||||
return this.llm;
|
||||
}
|
||||
|
||||
getSession(): RuntimeSession {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
setLifecycleStage(stage: "design" | "play"): void {
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, uiLifecycleStage: stage },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableSkills(): SkillIndexEntry[] {
|
||||
return this.availableSkills;
|
||||
}
|
||||
|
||||
/** 恢复会话或首次 start 前加载 skill 列表(供 UI 展示) */
|
||||
async ensureAvailableSkills(): Promise<void> {
|
||||
if (this.availableSkills.length === 0) {
|
||||
this.availableSkills = await listSkills();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveSkill(): ActiveSkillSnapshot | undefined {
|
||||
return this.session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
}
|
||||
|
||||
getAllowedEventTypes(): RuntimeEvent["type"][] {
|
||||
return getAllowedEvents(this.session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否处于「running 且无事可做,等总管决策」状态。
|
||||
* Web 层据此显示「生成大纲」等按钮。
|
||||
*/
|
||||
needsMainAgentDecision(): boolean {
|
||||
return (
|
||||
this.session.phase === "running" &&
|
||||
!this.session.currentWorkerId &&
|
||||
!this.pendingWorkerEffect
|
||||
);
|
||||
}
|
||||
|
||||
/** 加载 skills/registry → 触发 session_started → waiting_user(skill_selection) */
|
||||
async start(): Promise<RuntimeSession> {
|
||||
this.availableSkills = await listSkills();
|
||||
if (this.availableSkills.length === 0) {
|
||||
throw new Error("skills/ 下没有找到任何 skill 文件(novel/*.md 或 dialogue/*.md)");
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "session_started",
|
||||
payload: {
|
||||
presetId: this.session.presetId,
|
||||
flowId: this.session.flowId,
|
||||
availableSkills: this.availableSkills,
|
||||
},
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 启动并预选总管,跳过 skill_selection */
|
||||
async startWithOrchestrator(orchestratorId: string): Promise<RuntimeSession> {
|
||||
await this.start();
|
||||
if (this.session.waitingReason?.kind === "skill_selection") {
|
||||
await this.selectSkill(orchestratorId);
|
||||
}
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 解析并加载 SKILL.md → 触发 skill_selected → waiting_user(input) */
|
||||
async selectSkill(skillIdOrName: string): Promise<RuntimeSession> {
|
||||
const dir = await resolveSkillId(skillIdOrName);
|
||||
if (!dir) {
|
||||
throw new Error(`未找到 skill: ${skillIdOrName}`);
|
||||
}
|
||||
const parsed = await loadSkill(dir);
|
||||
await this.dispatch({
|
||||
type: "skill_selected",
|
||||
payload: { skill: toActiveSkillSnapshot(parsed) },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
|
||||
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
|
||||
const result = applyEvent(this.session, event);
|
||||
this.session = result.session;
|
||||
|
||||
if (
|
||||
event.type === "user_submitted_input" ||
|
||||
event.type === "user_confirmed_intake"
|
||||
) {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
}
|
||||
|
||||
if (result.error && event.type !== "runtime_failed") {
|
||||
this.onMessage(result.error);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 将 slots 中的启动目标等同步为黑板 tag(迁移期 tag 名可与旧 key 相同) */
|
||||
private syncSlotsToBlackboard(session: RuntimeSession): void {
|
||||
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
if (activeSkill?.startupTargetKey) {
|
||||
const tag = activeSkill.startupTargetKey;
|
||||
const content = session.slots[tag];
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: content.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
}
|
||||
const workerReply = session.slots["用户.worker答复"];
|
||||
if (typeof workerReply === "string" && workerReply.trim()) {
|
||||
this.blackboard.write({
|
||||
tag: "用户.worker答复",
|
||||
content: workerReply.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
const revision = session.slots.revisionInstruction;
|
||||
if (typeof revision === "string" && revision.trim()) {
|
||||
this.blackboard.write({
|
||||
tag: "用户.修改说明",
|
||||
content: revision.trim(),
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户文本输入的统一入口。
|
||||
* skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。
|
||||
*/
|
||||
async submitInput(text: string): Promise<RuntimeSession> {
|
||||
if (this.session.waitingReason?.kind === "skill_selection") {
|
||||
const picked = await this.resolveSkillFromUserInput(text);
|
||||
if (!picked) {
|
||||
throw new Error(`无法识别 skill: ${text}。请输入 name 或列表编号。`);
|
||||
}
|
||||
return this.selectSkill(picked);
|
||||
}
|
||||
if (this.session.waitingReason?.kind === "intake") {
|
||||
const skill = this.getActiveSkill();
|
||||
const fields = skill?.intakeFields ?? [];
|
||||
const current = readIntakeValues(this.session.slots);
|
||||
const intakeValues = await extractIntakeFromMessage(
|
||||
text,
|
||||
fields,
|
||||
current,
|
||||
this.llm,
|
||||
);
|
||||
await this.dispatch({
|
||||
type: "user_submitted_input",
|
||||
payload: { text, intakeValues },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
await this.dispatch({ type: "user_submitted_input", payload: { text } });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 必要项已填完:确认进入实例化 / 下一阶段 */
|
||||
async confirmIntake(): Promise<RuntimeSession> {
|
||||
if (this.session.waitingReason?.kind !== "intake") {
|
||||
throw new Error("当前不在填空收集阶段");
|
||||
}
|
||||
await this.dispatch({ type: "user_confirmed_intake", payload: {} });
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 支持按编号(1-based)或 skill name 解析 */
|
||||
private async resolveSkillFromUserInput(text: string): Promise<string | null> {
|
||||
const trimmed = text.trim();
|
||||
const num = Number(trimmed);
|
||||
if (Number.isInteger(num) && num >= 1 && num <= this.availableSkills.length) {
|
||||
const entry = this.availableSkills[num - 1];
|
||||
return resolveSkillId(entry.name);
|
||||
}
|
||||
return resolveSkillId(trimmed);
|
||||
}
|
||||
|
||||
/** 提交总管决策(通常来自 Main Agent / 手动按钮) */
|
||||
async submitDecision(decision: MainAgentDecision): Promise<RuntimeSession> {
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户确认 pendingDecision(approve_step) */
|
||||
async approve(): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待确认的决策");
|
||||
await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingDecision,回到总管 */
|
||||
async rejectStep(reason?: string): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待拒绝的决策");
|
||||
await this.dispatch({
|
||||
type: "user_rejected_next_step",
|
||||
payload: { decisionId: id, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户接受 pendingArtifact */
|
||||
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) throw new Error("当前没有待验收的产物");
|
||||
await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } });
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingArtifact,进入 revision */
|
||||
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
||||
const id = artifactId ?? this.session.pendingArtifactId;
|
||||
if (!id) throw new Error("当前没有待拒绝的产物");
|
||||
await this.dispatch({
|
||||
type: "user_rejected_artifact",
|
||||
payload: { artifactId: id, reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** worker 调用 ask_user 能力时,由外层触发此事件 */
|
||||
async workerAsk(questions: string[]): Promise<RuntimeSession> {
|
||||
const workerId = this.session.currentWorkerId;
|
||||
if (!workerId) throw new Error("当前没有运行中的 worker");
|
||||
await this.dispatch({
|
||||
type: "worker_needs_input",
|
||||
payload: { workerId, stepId: this.session.currentStepId, questions },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动标记 worker 完成:先创建 artifact 写入 session,再 dispatch worker_completed。
|
||||
* 真实 worker 集成时,产物内容应在此之前写入 slots / blackboard。
|
||||
*/
|
||||
async workerComplete(summary?: string): Promise<RuntimeSession> {
|
||||
const workerId = this.session.currentWorkerId;
|
||||
if (!workerId) throw new Error("当前没有运行中的 worker");
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags: ["output.草稿"],
|
||||
summary: summary ?? `${workerId} 产物`,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
|
||||
await this.dispatch({
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 非 autoStub 模式下,手动启动 pendingWorkerEffect */
|
||||
async startPendingWorker(): Promise<RuntimeSession> {
|
||||
const effect = this.pendingWorkerEffect;
|
||||
if (!effect) throw new Error("没有待启动的 worker");
|
||||
this.pendingWorkerEffect = null;
|
||||
await this.runStubWorker(effect, false);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 消费 applyEvent 返回的 PhaseEffect 列表 */
|
||||
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
||||
for (const effect of effects) {
|
||||
switch (effect.type) {
|
||||
case "emit_message":
|
||||
this.onMessage(effect.message);
|
||||
break;
|
||||
case "invoke_main_agent":
|
||||
if (!this.mainAgent) {
|
||||
this.onMessage(
|
||||
"[阶段机] running:等待总管决策。请用 /decide 提交 run_worker 或 finish。",
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "run_worker":
|
||||
if (this.autoStubWorker) {
|
||||
await this.runStubWorker(effect);
|
||||
} else if (this.llm) {
|
||||
await this.runRealWorker(effect);
|
||||
} else {
|
||||
this.pendingWorkerEffect = effect;
|
||||
this.onMessage(
|
||||
`[阶段机] 待启动 worker: ${effect.workerId}。输入 /worker-start`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "resume_worker": {
|
||||
const ctx = this.session.resumeContext;
|
||||
if (!ctx) break;
|
||||
const workerEffect: Extract<PhaseEffect, { type: "run_worker" }> = {
|
||||
type: "run_worker",
|
||||
workerId: ctx.workerId,
|
||||
};
|
||||
if (this.autoStubWorker) {
|
||||
await this.runStubWorker(workerEffect);
|
||||
} else if (this.llm) {
|
||||
await this.runRealWorker(workerEffect);
|
||||
} else {
|
||||
this.pendingWorkerEffect = workerEffect;
|
||||
this.onMessage(`[阶段机] 恢复 worker: ${ctx.workerId}。输入 /worker-start`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "run_programmatic_review":
|
||||
await this.runProgrammaticReview(effect.artifactId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runRealWorker(
|
||||
effect: Extract<PhaseEffect, { type: "run_worker" }>,
|
||||
): Promise<void> {
|
||||
const activeSkill = this.getActiveSkill();
|
||||
if (!activeSkill?.name) {
|
||||
throw new Error("当前没有 active skill,无法运行 worker");
|
||||
}
|
||||
if (!this.llm) {
|
||||
throw new Error("未配置 LLM,无法运行 worker");
|
||||
}
|
||||
|
||||
const workerId = resolveWorkerId(effect.workerId);
|
||||
let slots = { ...this.session.slots };
|
||||
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
|
||||
if (effect.workerContext?.roleId?.trim()) {
|
||||
const roleId = effect.workerContext.roleId.trim();
|
||||
slots["世界.当前角色.id"] = roleId;
|
||||
this.blackboard.write({
|
||||
tag: "世界.当前角色.id",
|
||||
content: roleId,
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = { ...this.session, slots };
|
||||
}
|
||||
|
||||
this.session = (
|
||||
await this.dispatch({
|
||||
type: "worker_started",
|
||||
payload: {
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
|
||||
},
|
||||
})
|
||||
).session;
|
||||
|
||||
const skill = await loadSkill(activeSkill.name);
|
||||
const worker = await loadWorkerSkill(activeSkill.name, effect.workerId);
|
||||
const workerLlm = resolveWorkerLlmProvider({
|
||||
worker,
|
||||
bindings: skill.workerLlmBindings,
|
||||
slots,
|
||||
fallbackLlm: this.llm,
|
||||
});
|
||||
|
||||
this.onMessage(`[Worker] ${workerId} 执行中…`);
|
||||
|
||||
const result = await runWorkerSkill({
|
||||
skillName: activeSkill.name,
|
||||
workerId: effect.workerId,
|
||||
slots,
|
||||
blackboard: this.blackboard,
|
||||
llm: workerLlm,
|
||||
});
|
||||
|
||||
if (result.askUser?.length) {
|
||||
await this.workerAsk(result.askUser);
|
||||
return;
|
||||
}
|
||||
|
||||
slots = { ...this.session.slots };
|
||||
for (const [tag, content] of Object.entries(result.outputs)) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content,
|
||||
source: workerId,
|
||||
});
|
||||
slots[tag] = content;
|
||||
}
|
||||
this.session = { ...this.session, slots };
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags: Object.keys(result.outputs),
|
||||
summary: result.summary,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
|
||||
this.onMessage(
|
||||
`[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`,
|
||||
);
|
||||
|
||||
await this.dispatch({
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 占位 worker:worker_started → 可选自动 worker_completed。
|
||||
* 演示与单测用,真实环境应替换为真实 worker 调度。
|
||||
*/
|
||||
private async runStubWorker(
|
||||
effect: Extract<PhaseEffect, { type: "run_worker" }>,
|
||||
autoComplete = true,
|
||||
): Promise<void> {
|
||||
this.session = (
|
||||
await this.dispatch({
|
||||
type: "worker_started",
|
||||
payload: {
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode,
|
||||
},
|
||||
})
|
||||
).session;
|
||||
|
||||
this.onMessage(`[Worker 占位] ${effect.workerId} 已开始。`);
|
||||
|
||||
if (!autoComplete) return;
|
||||
|
||||
const lastInput = String(this.session.slots.lastUserInput ?? "");
|
||||
const value = `[${effect.workerId} 占位输出]\n${lastInput ? `输入: ${lastInput}\n` : ""}说明: stub worker,后续替换为真实 LLM worker。`;
|
||||
|
||||
const outputTags = await this.resolveWorkerOutputTags(effect.workerId);
|
||||
const tagsToWrite = outputTags.length > 0 ? outputTags : ["output.草稿"];
|
||||
|
||||
for (const tag of tagsToWrite) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: value,
|
||||
source: effect.workerId,
|
||||
});
|
||||
}
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: effect.workerId,
|
||||
stepId: this.session.currentStepId,
|
||||
outputTags: tagsToWrite,
|
||||
summary: `${effect.workerId} 占位产物`,
|
||||
});
|
||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||
|
||||
await this.dispatch({
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWorkerOutputTags(workerId: string): Promise<string[]> {
|
||||
const active = this.getActiveSkill();
|
||||
if (!active?.name) return [];
|
||||
try {
|
||||
const worker = await loadWorkerSkill(active.name, workerId);
|
||||
return worker.outputTags;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 简易程序验收:summary 非空则通过,否则 revision */
|
||||
private async runProgrammaticReview(artifactId: string): Promise<void> {
|
||||
await this.dispatch({ type: "programmatic_review_started", payload: { artifactId } });
|
||||
|
||||
const artifact = this.session.artifacts.find((a) => a.id === artifactId);
|
||||
const passed = Boolean(artifact?.summary && artifact.summary.length > 0);
|
||||
|
||||
await this.dispatch(
|
||||
passed
|
||||
? { type: "programmatic_review_passed", payload: { artifactId } }
|
||||
: {
|
||||
type: "programmatic_review_failed",
|
||||
payload: { artifactId, reason: "产物 summary 为空" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
|
||||
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
|
||||
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
}
|
||||
|
||||
private async runMainAgent(): Promise<void> {
|
||||
if (!this.mainAgent || this.session.phase !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWorkers = await this.resolveAvailableWorkers();
|
||||
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
{
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers,
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
|
||||
listWorkers: () => availableWorkers,
|
||||
listArtifacts: () =>
|
||||
this.session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
onToolCall: (name, detail) => {
|
||||
const preview =
|
||||
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
|
||||
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = applyEvent(this.session, {
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
this.session = result.session;
|
||||
|
||||
if (result.error) {
|
||||
this.onMessage(result.error);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
}
|
||||
|
||||
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */
|
||||
private readBlackboardForAgent(tags: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const pattern of tags) {
|
||||
const items = this.blackboard.queryByPatterns([pattern], "latest");
|
||||
if (items.length === 0) {
|
||||
const slotVal = this.session.slots[pattern];
|
||||
if (typeof slotVal === "string" && slotVal.trim()) {
|
||||
out[pattern] = slotVal.trim();
|
||||
} else {
|
||||
out[pattern] = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const item of items) {
|
||||
out[item.tag] = item.content;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async resolveAvailableWorkers(): Promise<
|
||||
Array<{ id: string; description: string }>
|
||||
> {
|
||||
const active = this.getActiveSkill();
|
||||
if (!active?.name) return [...DEFAULT_WORKERS];
|
||||
try {
|
||||
const workers = await listWorkerSkills(active.name);
|
||||
if (workers.length === 0) return [...DEFAULT_WORKERS];
|
||||
return workers.map((w) => ({ id: w.id, description: w.description }));
|
||||
} catch {
|
||||
return [...DEFAULT_WORKERS];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造总管决策,强制 statePatchAllowed=false */
|
||||
export function createDecision(
|
||||
partial: Omit<MainAgentDecision, "id" | "statePatchAllowed"> &
|
||||
Partial<Pick<MainAgentDecision, "id">>,
|
||||
): MainAgentDecision {
|
||||
return {
|
||||
id: partial.id ?? randomUUID(),
|
||||
statePatchAllowed: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/** CLI 调试:单行摘要当前会话状态 */
|
||||
export function formatSession(session: RuntimeSession): string {
|
||||
const active = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
|
||||
const lines = [
|
||||
`phase: ${session.phase}`,
|
||||
session.waitingReason ? `waitingReason: ${session.waitingReason.kind}` : null,
|
||||
active ? `skill: ${active.name}` : null,
|
||||
session.slots.startupCompleted ? "startup: done" : null,
|
||||
session.slots["book.brief"] ? "book.brief: yes" : null,
|
||||
session.currentWorkerId ? `worker: ${session.currentWorkerId}` : null,
|
||||
`artifacts: ${session.artifacts.length}`,
|
||||
`events: ${session.history.length}`,
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/** 集成测试用:从 start 到 finish 的最小闭环脚本 */
|
||||
export async function runMinimalClosedLoop(
|
||||
runtime: PhaseRuntime,
|
||||
): Promise<RuntimeSession> {
|
||||
await runtime.start();
|
||||
await runtime.selectSkill("basic");
|
||||
await runtime.submitInput("科幻长篇,第三人称,约 20 万字");
|
||||
await runtime.confirmIntake();
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
reason: "生成大纲",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
});
|
||||
await runtime.submitDecision(decision);
|
||||
await runtime.approve();
|
||||
|
||||
if (runtime.getSession().currentWorkerId) {
|
||||
await runtime.workerComplete();
|
||||
}
|
||||
|
||||
await runtime.acceptArtifact();
|
||||
await runtime.submitDecision(
|
||||
createDecision({ action: "finish", reason: "完成", requiresApproval: false }),
|
||||
);
|
||||
|
||||
return runtime.getSession();
|
||||
}
|
||||
8
src/runtime/state-machine.ts
Normal file
8
src/runtime/state-machine.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @deprecated 请使用 phase-machine.ts */
|
||||
export {
|
||||
applyEvent,
|
||||
canApplyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
getAllowedEvents,
|
||||
} from "./phase-machine.js";
|
||||
119
src/runtime/tool-registry.ts
Normal file
119
src/runtime/tool-registry.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ParsedToolCall } from "../llm/client.js";
|
||||
import type { MainAgentDecision } from "../types/runtime.js";
|
||||
import {
|
||||
isMainAgentLoopTool,
|
||||
isMainAgentTerminalTool,
|
||||
type MainAgentToolName,
|
||||
} from "../types/tools.js";
|
||||
|
||||
export class ToolValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ToolValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new ToolValidationError("Tool arguments must be a JSON object");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
if (e instanceof ToolValidationError) throw e;
|
||||
throw new ToolValidationError(`Invalid tool arguments JSON: ${raw.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(
|
||||
args: Record<string, unknown>,
|
||||
key: string,
|
||||
): string {
|
||||
const val = args[key];
|
||||
if (typeof val !== "string" || !val.trim()) {
|
||||
throw new ToolValidationError(`Tool parameter "${key}" must be a non-empty string`);
|
||||
}
|
||||
return val.trim();
|
||||
}
|
||||
|
||||
function optionalString(
|
||||
args: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const val = args[key];
|
||||
if (typeof val !== "string") return undefined;
|
||||
const trimmed = val.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
/** 将终止 tool call 转为 MainAgentDecision */
|
||||
export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision {
|
||||
const name = call.name;
|
||||
if (!isMainAgentTerminalTool(name)) {
|
||||
throw new ToolValidationError(`Not a terminal tool: ${name}`);
|
||||
}
|
||||
|
||||
const args = parseArgs(call.arguments);
|
||||
|
||||
switch (name as MainAgentToolName) {
|
||||
case "ask_user": {
|
||||
const reason = requireString(args, "reason");
|
||||
const message = optionalString(args, "message");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "ask_user",
|
||||
reason: message ? `${reason}\n${message}` : reason,
|
||||
requiresApproval: false,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
case "run_worker": {
|
||||
const workerId = requireString(args, "workerId");
|
||||
const reason = requireString(args, "reason");
|
||||
const requiresApproval = Boolean(args.requiresApproval);
|
||||
const roleId = optionalString(args, "roleId");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "run_worker",
|
||||
reason,
|
||||
workerId,
|
||||
workerContext: roleId ? { roleId } : undefined,
|
||||
requiresApproval,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
case "review_blackboard": {
|
||||
const reason = requireString(args, "reason");
|
||||
const summary = requireString(args, "summary");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "review_blackboard",
|
||||
reason: `${reason}\n${summary}`,
|
||||
requiresApproval: false,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
case "finish": {
|
||||
const reason = requireString(args, "reason");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
action: "finish",
|
||||
reason,
|
||||
requiresApproval: false,
|
||||
statePatchAllowed: false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new ToolValidationError(`Unknown terminal tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLoopToolCall(call: ParsedToolCall): MainAgentToolName {
|
||||
if (!isMainAgentLoopTool(call.name)) {
|
||||
throw new ToolValidationError(`Unknown loop tool: ${call.name}`);
|
||||
}
|
||||
parseArgs(call.arguments);
|
||||
return call.name;
|
||||
}
|
||||
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}`);
|
||||
});
|
||||
502
src/skills/loader.ts
Normal file
502
src/skills/loader.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { BookKind } from "../types/runtime.js";
|
||||
import type {
|
||||
ParsedSkill,
|
||||
ParsedWorkerSkill,
|
||||
SkillIndexEntry,
|
||||
SkillWorkerLlmBindings,
|
||||
StartupInquiry,
|
||||
} from "./types.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_ROOT = path.resolve(__dirname, "../../skills");
|
||||
|
||||
export const ORCHESTRATOR_FILENAME = "orchestrator.md";
|
||||
export const WORKER_SKILL_FILENAME = "SKILL.md";
|
||||
export const DEFAULT_SHARED_CONTEXT_FILENAME = "shared-context.md";
|
||||
export const LLM_BINDINGS_FILENAME = "llm-bindings.yaml";
|
||||
|
||||
/** 按 Book 形态分文件夹;skill 可为平铺 .md 或 {name}/orchestrator.md 包 */
|
||||
export const SKILL_BOOK_KIND_FOLDERS: BookKind[] = ["novel", "dialogue"];
|
||||
|
||||
type RegistryDoc = {
|
||||
skills?: Array<
|
||||
SkillIndexEntry & { path?: string; bookKind?: BookKind }
|
||||
>;
|
||||
};
|
||||
|
||||
/** 解析 YAML frontmatter(仅支持本项目用到的简单字段) */
|
||||
function parseFrontmatter(raw: string): {
|
||||
meta: Record<string, string | string[] | number>;
|
||||
body: string;
|
||||
} {
|
||||
if (!raw.startsWith("---")) {
|
||||
return { meta: {}, body: raw };
|
||||
}
|
||||
const end = raw.indexOf("\n---", 3);
|
||||
if (end === -1) {
|
||||
return { meta: {}, body: raw };
|
||||
}
|
||||
const yaml = raw.slice(3, end).trim();
|
||||
const body = raw.slice(end + 4).trim();
|
||||
const meta: Record<string, string | string[] | number> = {};
|
||||
|
||||
let currentKey = "";
|
||||
let listItems: string[] = [];
|
||||
let inList = false;
|
||||
|
||||
const flushList = () => {
|
||||
if (inList && currentKey) {
|
||||
meta[currentKey] = listItems;
|
||||
listItems = [];
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of yaml.split("\n")) {
|
||||
const listMatch = line.match(/^\s+-\s+(.+)$/);
|
||||
if (listMatch && inList) {
|
||||
listItems.push(listMatch[1].trim());
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
const kv = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
const [, key, value] = kv;
|
||||
currentKey = key;
|
||||
if (value === "" || value === ">-" || value === "|") {
|
||||
inList = true;
|
||||
listItems = [];
|
||||
} else if (value === ">-" || value.startsWith(">")) {
|
||||
meta[key] = value;
|
||||
} else {
|
||||
meta[key] = value.trim();
|
||||
inList = false;
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
function extractSection(body: string, heading: string): string {
|
||||
const re = new RegExp(`^## ${heading}\\s*$`, "m");
|
||||
const match = re.exec(body);
|
||||
if (!match) return "";
|
||||
const start = match.index + match[0].length;
|
||||
const rest = body.slice(start);
|
||||
const next = rest.search(/^## /m);
|
||||
return (next === -1 ? rest : rest.slice(0, next)).trim();
|
||||
}
|
||||
|
||||
function parseStartupInquiry(section: string): StartupInquiry {
|
||||
const promptBlock = section.match(/```text\n([\s\S]*?)```/);
|
||||
const prompt = promptBlock?.[1]?.trim() ?? section.slice(0, 500);
|
||||
|
||||
const targetMatch = section.match(/\*\*写入目标:\*\*\s*`([^`]+)`/);
|
||||
const targetKey = targetMatch?.[1]?.trim() ?? "book.brief";
|
||||
|
||||
const required: string[] = [];
|
||||
const reqSection = section.match(/\*\*必须收集[::]*\*\*([\s\S]*?)(?=\n\*\*|$)/);
|
||||
if (reqSection) {
|
||||
for (const line of reqSection[1].split("\n")) {
|
||||
const item = line.match(/^-\s+(.+)/);
|
||||
if (item) required.push(item[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
const optional: string[] = [];
|
||||
const optSection = section.match(/\*\*可选收集[::]*\*\*([\s\S]*?)(?=\n\*\*|$)/);
|
||||
if (optSection) {
|
||||
for (const line of optSection[1].split("\n")) {
|
||||
const item = line.match(/^-\s+(.+)/);
|
||||
if (item) optional.push(item[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return { prompt, targetKey, requiredFields: required, optionalFields: optional };
|
||||
}
|
||||
|
||||
function metaString(meta: Record<string, string | string[] | number>, key: string): string {
|
||||
const v = meta[key];
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function metaStringArray(meta: Record<string, string | string[] | number>, key: string): string[] {
|
||||
const v = meta[key];
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
|
||||
function parseBookKind(value: string): BookKind | undefined {
|
||||
if (value === "novel" || value === "dialogue") return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bookKindFromRelativePath(relativePath: string): BookKind | undefined {
|
||||
const folder = relativePath.split("/")[0];
|
||||
return parseBookKind(folder);
|
||||
}
|
||||
|
||||
function skillPackRootFromPath(relativePath: string): string | undefined {
|
||||
const normalized = relativePath.replace(/\\/g, "/");
|
||||
if (path.basename(normalized) === ORCHESTRATOR_FILENAME) {
|
||||
return path.posix.dirname(normalized);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function workerIdsFromMeta(meta: Record<string, string | string[] | number>): string[] {
|
||||
const workers = metaStringArray(meta, "workers");
|
||||
if (workers.length > 0) return workers;
|
||||
return metaStringArray(meta, "suggestedWorkers");
|
||||
}
|
||||
|
||||
async function readRegistry(skillsRoot = SKILLS_ROOT): Promise<RegistryDoc["skills"]> {
|
||||
const registryPath = path.join(skillsRoot, "registry.yaml");
|
||||
try {
|
||||
const raw = await readFile(registryPath, "utf8");
|
||||
const doc = parseYaml(raw) as RegistryDoc;
|
||||
return doc.skills ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function isDirectory(fullPath: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(fullPath);
|
||||
return s.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描 skills/novel/*.md 与 skill 包 novel/{name}/orchestrator.md */
|
||||
async function scanSkillFiles(skillsRoot = SKILLS_ROOT): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const folder of SKILL_BOOK_KIND_FOLDERS) {
|
||||
const dir = path.join(skillsRoot, folder);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry);
|
||||
if (entry.endsWith(".md")) {
|
||||
files.push(`${folder}/${entry}`);
|
||||
continue;
|
||||
}
|
||||
if (await isDirectory(full)) {
|
||||
const orchestrator = path.join(full, ORCHESTRATOR_FILENAME);
|
||||
try {
|
||||
await readFile(orchestrator, "utf8");
|
||||
files.push(`${folder}/${entry}/${ORCHESTRATOR_FILENAME}`);
|
||||
} catch {
|
||||
// not a skill pack
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function loadWorkerLlmBindings(
|
||||
packRoot: string | undefined,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<SkillWorkerLlmBindings | undefined> {
|
||||
if (!packRoot) return undefined;
|
||||
const bindingsPath = path.join(skillsRoot, packRoot, LLM_BINDINGS_FILENAME);
|
||||
try {
|
||||
const raw = await readFile(bindingsPath, "utf8");
|
||||
const doc = parseYaml(raw) as SkillWorkerLlmBindings;
|
||||
if (!doc || typeof doc !== "object") return undefined;
|
||||
return doc;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSkillFile(
|
||||
relativePath: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<ParsedSkill> {
|
||||
const fullPath = path.join(skillsRoot, relativePath);
|
||||
const raw = await readFile(fullPath, "utf8");
|
||||
const { meta, body } = parseFrontmatter(raw);
|
||||
const startupSection = extractSection(body, "启动询问");
|
||||
const folderBookKind = bookKindFromRelativePath(relativePath);
|
||||
const category = metaString(meta, "category") || folderBookKind || "custom";
|
||||
const bookKind =
|
||||
parseBookKind(metaString(meta, "bookKind")) ??
|
||||
folderBookKind ??
|
||||
parseBookKind(category);
|
||||
|
||||
const normalizedPath = relativePath.replace(/\\/g, "/");
|
||||
const fileStem = path.basename(normalizedPath, ".md");
|
||||
const packRoot = skillPackRootFromPath(normalizedPath);
|
||||
const defaultName = packRoot ? path.basename(packRoot) : fileStem;
|
||||
const workerLlmBindings = await loadWorkerLlmBindings(packRoot, skillsRoot);
|
||||
|
||||
return {
|
||||
name: metaString(meta, "name") || defaultName,
|
||||
description: metaString(meta, "description"),
|
||||
category,
|
||||
bookKind,
|
||||
path: normalizedPath,
|
||||
skillPackRoot: packRoot,
|
||||
version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1,
|
||||
defaultFlowId: metaString(meta, "defaultFlowId") || undefined,
|
||||
suggestedWorkers: workerIdsFromMeta(meta),
|
||||
tags: metaStringArray(meta, "tags"),
|
||||
sharedContextPath: resolveSharedContextPath(meta, packRoot),
|
||||
workerLlmBindings,
|
||||
startupInquiry: parseStartupInquiry(startupSection),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSharedContextPath(
|
||||
meta: Record<string, string | string[] | number>,
|
||||
packRoot: string | undefined,
|
||||
): string | undefined {
|
||||
if (!packRoot) return undefined;
|
||||
const explicit = metaString(meta, "sharedContext");
|
||||
return explicit || DEFAULT_SHARED_CONTEXT_FILENAME;
|
||||
}
|
||||
|
||||
async function parseWorkerSkillFile(
|
||||
relativePath: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<ParsedWorkerSkill> {
|
||||
const fullPath = path.join(skillsRoot, relativePath);
|
||||
const raw = await readFile(fullPath, "utf8");
|
||||
const { meta, body } = parseFrontmatter(raw);
|
||||
const normalizedPath = relativePath.replace(/\\/g, "/");
|
||||
const idFromPath = path.posix.basename(path.posix.dirname(normalizedPath));
|
||||
const inputTags =
|
||||
metaStringArray(meta, "inputTags").length > 0
|
||||
? metaStringArray(meta, "inputTags")
|
||||
: metaStringArray(meta, "inputKeys");
|
||||
const outputTags =
|
||||
metaStringArray(meta, "outputTags").length > 0
|
||||
? metaStringArray(meta, "outputTags")
|
||||
: metaStringArray(meta, "outputKeys");
|
||||
const inputMergeRaw = metaString(meta, "inputMerge");
|
||||
const inputMerge =
|
||||
inputMergeRaw === "concat" || inputMergeRaw === "latest"
|
||||
? inputMergeRaw
|
||||
: undefined;
|
||||
const llmProfileId = metaString(meta, "llmProfileId") || undefined;
|
||||
|
||||
return {
|
||||
id: metaString(meta, "id") || idFromPath,
|
||||
skill: metaString(meta, "skill"),
|
||||
name: metaString(meta, "name") || idFromPath,
|
||||
description: metaString(meta, "description"),
|
||||
version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1,
|
||||
inputTags,
|
||||
outputTags,
|
||||
inputMerge,
|
||||
llmProfileId,
|
||||
path: normalizedPath,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/** 列举 skill:优先 registry.yaml,否则扫描 novel/ 与 dialogue/ */
|
||||
export async function listSkills(skillsRoot = SKILLS_ROOT): Promise<SkillIndexEntry[]> {
|
||||
const registry = (await readRegistry(skillsRoot)) ?? [];
|
||||
if (registry.length > 0) {
|
||||
return registry.map((entry) => ({
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
category: entry.category,
|
||||
bookKind: entry.bookKind ?? parseBookKind(entry.category),
|
||||
path: entry.path,
|
||||
}));
|
||||
}
|
||||
|
||||
const entries: SkillIndexEntry[] = [];
|
||||
for (const relativePath of await scanSkillFiles(skillsRoot)) {
|
||||
try {
|
||||
const parsed = await parseSkillFile(relativePath, skillsRoot);
|
||||
entries.push({
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
category: parsed.category,
|
||||
bookKind: parsed.bookKind,
|
||||
path: parsed.path,
|
||||
});
|
||||
} catch {
|
||||
// skip unreadable
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 按 skill name 解析相对路径 */
|
||||
export async function resolveSkillPath(
|
||||
skillIdOrName: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<string | null> {
|
||||
const trimmed = skillIdOrName.trim();
|
||||
const registry = (await readRegistry(skillsRoot)) ?? [];
|
||||
|
||||
const fromRegistry = registry.find((s) => s.name === trimmed);
|
||||
if (fromRegistry?.path) {
|
||||
return fromRegistry.path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
for (const folder of SKILL_BOOK_KIND_FOLDERS) {
|
||||
const packCandidate = `${folder}/${trimmed}/${ORCHESTRATOR_FILENAME}`;
|
||||
try {
|
||||
await readFile(path.join(skillsRoot, packCandidate), "utf8");
|
||||
return packCandidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
const flatCandidate = `${folder}/${trimmed}.md`;
|
||||
try {
|
||||
await readFile(path.join(skillsRoot, flatCandidate), "utf8");
|
||||
return flatCandidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of await scanSkillFiles(skillsRoot)) {
|
||||
try {
|
||||
const parsed = await parseSkillFile(relativePath, skillsRoot);
|
||||
if (parsed.name === trimmed) {
|
||||
return relativePath;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 加载并解析总管 skill(orchestrator.md 或平铺 .md) */
|
||||
export async function loadSkill(
|
||||
skillIdOrName: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<ParsedSkill> {
|
||||
const relativePath = await resolveSkillPath(skillIdOrName, skillsRoot);
|
||||
if (!relativePath) {
|
||||
throw new Error(`未找到 skill: ${skillIdOrName}`);
|
||||
}
|
||||
return parseSkillFile(relativePath, skillsRoot);
|
||||
}
|
||||
|
||||
/** 解析 skill 包内 worker 的 SKILL.md 相对路径 */
|
||||
export async function resolveWorkerSkillPath(
|
||||
skillIdOrName: string,
|
||||
workerId: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<string | null> {
|
||||
const skill = await loadSkill(skillIdOrName, skillsRoot);
|
||||
if (!skill.skillPackRoot) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = `${skill.skillPackRoot}/workers/${workerId}/${WORKER_SKILL_FILENAME}`;
|
||||
try {
|
||||
await readFile(path.join(skillsRoot, relativePath), "utf8");
|
||||
return relativePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载 skill 包固定上下文(注入所有 worker prompt 开头) */
|
||||
export async function loadSkillSharedContext(
|
||||
skillIdOrName: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<string | null> {
|
||||
const skill = await loadSkill(skillIdOrName, skillsRoot);
|
||||
if (!skill.skillPackRoot || !skill.sharedContextPath) return null;
|
||||
const fullPath = path.join(skillsRoot, skill.skillPackRoot, skill.sharedContextPath);
|
||||
try {
|
||||
return await readFile(fullPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载 worker skill 正文,可选拼接 skill 包固定上下文 */
|
||||
export async function loadWorkerSkillWithContext(
|
||||
skillIdOrName: string,
|
||||
workerId: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
|
||||
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
|
||||
const sharedContext = await loadSkillSharedContext(skillIdOrName, skillsRoot);
|
||||
const promptBody = sharedContext
|
||||
? `# 固定创作上下文\n\n${sharedContext}\n\n---\n\n${worker.body}`
|
||||
: worker.body;
|
||||
return { worker, sharedContext, promptBody };
|
||||
}
|
||||
|
||||
/** 加载 skill 包内专属 worker skill */
|
||||
export async function loadWorkerSkill(
|
||||
skillIdOrName: string,
|
||||
workerId: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<ParsedWorkerSkill> {
|
||||
const relativePath = await resolveWorkerSkillPath(skillIdOrName, workerId, skillsRoot);
|
||||
if (!relativePath) {
|
||||
throw new Error(`未找到 worker skill: ${skillIdOrName}/${workerId}`);
|
||||
}
|
||||
return parseWorkerSkillFile(relativePath, skillsRoot);
|
||||
}
|
||||
|
||||
/** 列举 skill 包内所有 worker skill */
|
||||
export async function listWorkerSkills(
|
||||
skillIdOrName: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<ParsedWorkerSkill[]> {
|
||||
const skill = await loadSkill(skillIdOrName, skillsRoot);
|
||||
if (!skill.skillPackRoot) {
|
||||
return [];
|
||||
}
|
||||
const workersDir = path.join(skillsRoot, skill.skillPackRoot, "workers");
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(workersDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const workers: ParsedWorkerSkill[] = [];
|
||||
for (const entry of entries.sort()) {
|
||||
const skillPath = `${skill.skillPackRoot}/workers/${entry}/${WORKER_SKILL_FILENAME}`;
|
||||
try {
|
||||
workers.push(await parseWorkerSkillFile(skillPath, skillsRoot));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return workers;
|
||||
}
|
||||
|
||||
/** 按 name 或文件名(不含 .md)匹配 skill,返回 loadSkill 可用的 id */
|
||||
export async function resolveSkillId(
|
||||
input: string,
|
||||
skillsRoot = SKILLS_ROOT,
|
||||
): Promise<string | null> {
|
||||
const trimmed = input.trim();
|
||||
const relativePath = await resolveSkillPath(trimmed, skillsRoot);
|
||||
if (!relativePath) return null;
|
||||
try {
|
||||
const parsed = await parseSkillFile(relativePath, skillsRoot);
|
||||
return parsed.name;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { SKILLS_ROOT };
|
||||
19
src/skills/snapshot.ts
Normal file
19
src/skills/snapshot.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ActiveSkillSnapshot } from "../types/runtime.js";
|
||||
import type { IntakeFieldDef } from "../types/intake.js";
|
||||
import type { ParsedSkill } from "./types.js";
|
||||
import { intakeFieldsFromInquiry } from "../intake/intake.js";
|
||||
|
||||
export function toActiveSkillSnapshot(skill: ParsedSkill): ActiveSkillSnapshot {
|
||||
const intakeFields: IntakeFieldDef[] = intakeFieldsFromInquiry(skill.startupInquiry);
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: skill.category,
|
||||
bookKind: skill.bookKind,
|
||||
defaultFlowId: skill.defaultFlowId,
|
||||
suggestedWorkers: skill.suggestedWorkers,
|
||||
startupPrompt: skill.startupInquiry.prompt,
|
||||
startupTargetKey: skill.startupInquiry.targetKey,
|
||||
intakeFields,
|
||||
};
|
||||
}
|
||||
76
src/skills/types.ts
Normal file
76
src/skills/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { BlackboardInputMerge } from "../types/blackboard.js";
|
||||
import type { AdvancePolicy } from "../types/runtime.js";
|
||||
|
||||
export type WorkerLlmBinding = {
|
||||
/** 固定 ApiProfile.id;省略 = 会话默认 */
|
||||
profileId?: string | null;
|
||||
/** 按当前决策角色 id 选用 profile(多 AI 博弈) */
|
||||
byRole?: Record<string, string>;
|
||||
};
|
||||
|
||||
/** skill 包 llm-bindings.yaml 解析结果(可选) */
|
||||
export type SkillWorkerLlmBindings = {
|
||||
defaultProfileId?: string | null;
|
||||
workers?: Record<string, WorkerLlmBinding>;
|
||||
};
|
||||
|
||||
/** skills/registry.yaml 或目录扫描得到的索引项 */
|
||||
export type SkillIndexEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
/** 与 bookKind 一致,兼容旧字段名 */
|
||||
category: string;
|
||||
bookKind?: "novel" | "dialogue";
|
||||
/** 相对 skills/ 的路径,如 novel/weird-rules-short/orchestrator.md 或 novel/basic.md */
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/** 来自 skill 文件 ## 启动询问 */
|
||||
export type StartupInquiry = {
|
||||
prompt: string;
|
||||
targetKey: string;
|
||||
requiredFields: string[];
|
||||
optionalFields: string[];
|
||||
};
|
||||
|
||||
/** 解析后的总管 skill,供 session.slots.activeSkill 使用 */
|
||||
export type ParsedSkill = {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
bookKind?: "novel" | "dialogue";
|
||||
/** 相对 skills/ 的路径(orchestrator.md 或平铺 .md) */
|
||||
path: string;
|
||||
/** skill 包根目录,如 novel/weird-rules-short;平铺 .md 时为 undefined */
|
||||
skillPackRoot?: string;
|
||||
version: number;
|
||||
defaultFlowId?: string;
|
||||
/** 本包可调度 worker id(frontmatter workers 或 suggestedWorkers) */
|
||||
suggestedWorkers: string[];
|
||||
tags: string[];
|
||||
/** 包内固定上下文相对路径(可选);有则注入该包 worker prompt */
|
||||
sharedContextPath?: string;
|
||||
/** llm-bindings.yaml(可选);见 docs/worker-skill-format.md §9 */
|
||||
workerLlmBindings?: SkillWorkerLlmBindings;
|
||||
startupInquiry: StartupInquiry;
|
||||
/** 推进策略(预留)。loader 第一版不解析 orchestrator ## 推进策略 */
|
||||
advancePolicy?: AdvancePolicy;
|
||||
body: string;
|
||||
};
|
||||
|
||||
/** 解析后的 worker skill(skills/{pack}/workers/{id}/SKILL.md) */
|
||||
export type ParsedWorkerSkill = {
|
||||
id: string;
|
||||
skill: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
inputTags: string[];
|
||||
outputTags: string[];
|
||||
inputMerge?: BlackboardInputMerge;
|
||||
/** ApiProfile.id;省略 = 走 llm-bindings 或会话默认 */
|
||||
llmProfileId?: string;
|
||||
/** 相对 skills/ 的路径 */
|
||||
path: string;
|
||||
body: string;
|
||||
};
|
||||
146
src/skills/worker-llm.ts
Normal file
146
src/skills/worker-llm.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { getApiProfile, profileToLlmConfig } from "../config/api-profiles.js";
|
||||
import {
|
||||
OpenAiCompatibleProvider,
|
||||
type LlmProvider,
|
||||
} from "../llm/client.js";
|
||||
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
|
||||
import { resolveActivePreset } from "../preset/store.js";
|
||||
import { loadAppSettings } from "../config/settings.js";
|
||||
import {
|
||||
TokenTrackingProvider,
|
||||
type LlmTrackingContext,
|
||||
} from "../llm/token-tracker.js";
|
||||
import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js";
|
||||
|
||||
type LlmTrackingRef = { current: LlmTrackingContext };
|
||||
|
||||
/** 按 ApiProfile.id 构建 LLM;找不到 profile 时回退 fallback */
|
||||
export function createLlmForProfileId(
|
||||
profileId: string,
|
||||
fallback: LlmProvider,
|
||||
trackingRef?: LlmTrackingRef,
|
||||
): LlmProvider {
|
||||
const profile = getApiProfile(profileId);
|
||||
if (!profile?.apiKey?.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let inner: LlmProvider = new OpenAiCompatibleProvider(
|
||||
profileToLlmConfig(profile),
|
||||
);
|
||||
const preset = resolveActivePreset(loadAppSettings().activePresetId);
|
||||
if (preset) {
|
||||
inner = new PresetLlmProvider(inner, () =>
|
||||
resolveActivePreset(loadAppSettings().activePresetId),
|
||||
);
|
||||
}
|
||||
if (!trackingRef) return inner;
|
||||
return new TokenTrackingProvider(inner, () => trackingRef.current);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 worker 应使用的 LLM。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. worker SKILL frontmatter `llmProfileId`
|
||||
* 2. llm-bindings.yaml `workers[id].byRole[roleId]`(roleId 来自 slots.世界.当前角色.id)
|
||||
* 3. llm-bindings.yaml `workers[id].profileId`
|
||||
* 4. fallback(会话默认 profile,与总管相同)
|
||||
*/
|
||||
export function resolveWorkerLlmProvider(options: {
|
||||
worker: ParsedWorkerSkill;
|
||||
bindings?: SkillWorkerLlmBindings;
|
||||
slots: Record<string, unknown>;
|
||||
fallbackLlm: LlmProvider;
|
||||
trackingRef?: LlmTrackingRef;
|
||||
}): LlmProvider {
|
||||
const { worker, bindings, slots, fallbackLlm, trackingRef } = options;
|
||||
|
||||
if (worker.llmProfileId?.trim()) {
|
||||
return createLlmForProfileId(
|
||||
worker.llmProfileId.trim(),
|
||||
fallbackLlm,
|
||||
trackingRef,
|
||||
);
|
||||
}
|
||||
|
||||
const workerBinding = bindings?.workers?.[worker.id];
|
||||
if (workerBinding?.byRole) {
|
||||
const roleId = slotString(slots, "世界.当前角色.id");
|
||||
if (roleId && workerBinding.byRole[roleId]?.trim()) {
|
||||
return createLlmForProfileId(
|
||||
workerBinding.byRole[roleId].trim(),
|
||||
fallbackLlm,
|
||||
trackingRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (workerBinding?.profileId?.trim()) {
|
||||
return createLlmForProfileId(
|
||||
workerBinding.profileId.trim(),
|
||||
fallbackLlm,
|
||||
trackingRef,
|
||||
);
|
||||
}
|
||||
|
||||
if (bindings?.defaultProfileId?.trim()) {
|
||||
return createLlmForProfileId(
|
||||
bindings.defaultProfileId.trim(),
|
||||
fallbackLlm,
|
||||
trackingRef,
|
||||
);
|
||||
}
|
||||
|
||||
return fallbackLlm;
|
||||
}
|
||||
|
||||
function slotString(slots: Record<string, unknown>, key: string): string | undefined {
|
||||
const val = slots[key];
|
||||
return typeof val === "string" && val.trim() ? val.trim() : undefined;
|
||||
}
|
||||
|
||||
/** 角色 tag:仅用户可见 */
|
||||
export const ROLE_USER_ONLY_SUFFIXES = [".思考", ".推理.候选"] as const;
|
||||
|
||||
/** 角色 tag:对其余角色 agent 可见(经 world-engine 公开) */
|
||||
export const ROLE_AGENT_VISIBLE_SUFFIXES = [".行动", ".行动.候选"] as const;
|
||||
|
||||
export function isRoleUserOnlyTag(tag: string): boolean {
|
||||
return ROLE_USER_ONLY_SUFFIXES.some((s) => tag.endsWith(s));
|
||||
}
|
||||
|
||||
export function isRoleAgentVisibleTag(tag: string): boolean {
|
||||
return ROLE_AGENT_VISIBLE_SUFFIXES.some((s) => tag.endsWith(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 executor:角色 worker 只能看到当前角色的私有 tag。
|
||||
* 其他角色的思考永不可见;对方行动仅经 world-engine 分发后的可见信息/公开叙述获知。
|
||||
*/
|
||||
export function filterInputsForRolePerspective(
|
||||
inputs: Record<string, string>,
|
||||
slots: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const roleId = slotString(slots, "世界.当前角色.id");
|
||||
if (!roleId) return inputs;
|
||||
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [tag, content] of Object.entries(inputs)) {
|
||||
const rolePrefix = `角色.${roleId}.`;
|
||||
const otherRoleMatch = tag.match(/^角色\.([^.]+)\./);
|
||||
if (otherRoleMatch && otherRoleMatch[1] !== roleId) {
|
||||
continue;
|
||||
}
|
||||
if (tag.startsWith("角色.") && !tag.startsWith(rolePrefix)) {
|
||||
continue;
|
||||
}
|
||||
if (tag.startsWith(rolePrefix) && isRoleUserOnlyTag(tag)) {
|
||||
continue;
|
||||
}
|
||||
filtered[tag] = content;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export type { LlmTrackingContext };
|
||||
247
src/stats/token-store.ts
Normal file
247
src/stats/token-store.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||
|
||||
export type TokenUsageRecord = {
|
||||
id: string;
|
||||
at: string;
|
||||
bookId?: string;
|
||||
bookTitle?: string;
|
||||
orchestratorId?: string;
|
||||
sessionId?: string;
|
||||
caller: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cachedTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
/** Token usage attached to a chat message in SessionView */
|
||||
export type MessageTokenUsage = {
|
||||
totalTokens: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cachedTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
caller: string;
|
||||
model?: string;
|
||||
recordId?: string;
|
||||
};
|
||||
|
||||
export type CallerTokenBreakdown = {
|
||||
totalTokens: number;
|
||||
cachedTokens: number;
|
||||
cacheMissTokens: number;
|
||||
calls: number;
|
||||
};
|
||||
|
||||
export type TokenStatsQuery = {
|
||||
bookId?: string;
|
||||
orchestratorId?: string;
|
||||
sessionId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type TokenStatsSummary = {
|
||||
totalCalls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
totalCached: number;
|
||||
totalCacheMiss: number;
|
||||
byCaller: Record<string, number>;
|
||||
byCallerDetailed: Record<string, CallerTokenBreakdown>;
|
||||
byBook: Record<string, number>;
|
||||
byOrchestrator: Record<string, number>;
|
||||
};
|
||||
|
||||
function statsDir(): string {
|
||||
return path.join(getUserDataDir(), "stats");
|
||||
}
|
||||
|
||||
function dayFile(date = new Date()): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return path.join(statsDir(), `${y}-${m}-${d}.jsonl`);
|
||||
}
|
||||
|
||||
function ensureStatsDir(): void {
|
||||
ensureUserDataDirs();
|
||||
mkdirSync(statsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function recordTokenUsage(
|
||||
partial: Omit<TokenUsageRecord, "id" | "at"> & { at?: string },
|
||||
): TokenUsageRecord {
|
||||
ensureStatsDir();
|
||||
const record: TokenUsageRecord = {
|
||||
id: randomUUID(),
|
||||
at: partial.at ?? new Date().toISOString(),
|
||||
bookId: partial.bookId,
|
||||
bookTitle: partial.bookTitle,
|
||||
orchestratorId: partial.orchestratorId,
|
||||
sessionId: partial.sessionId,
|
||||
caller: partial.caller,
|
||||
model: partial.model,
|
||||
promptTokens: partial.promptTokens,
|
||||
completionTokens: partial.completionTokens,
|
||||
totalTokens: partial.totalTokens,
|
||||
cachedTokens: partial.cachedTokens,
|
||||
cacheMissTokens: partial.cacheMissTokens,
|
||||
messageId: partial.messageId,
|
||||
};
|
||||
appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8");
|
||||
return record;
|
||||
}
|
||||
|
||||
function listStatFiles(): string[] {
|
||||
ensureStatsDir();
|
||||
const dir = statsDir();
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort()
|
||||
.map((f) => path.join(dir, f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function readRecords(query: TokenStatsQuery = {}): TokenUsageRecord[] {
|
||||
const files = listStatFiles();
|
||||
const records: TokenUsageRecord[] = [];
|
||||
const fromMs = query.from ? Date.parse(query.from) : NaN;
|
||||
const toMs = query.to ? Date.parse(query.to) : NaN;
|
||||
const limit = query.limit ?? 500;
|
||||
|
||||
for (let i = files.length - 1; i >= 0 && records.length < limit; i--) {
|
||||
const file = files[i];
|
||||
if (!existsSync(file)) continue;
|
||||
const lines = readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
for (let j = lines.length - 1; j >= 0 && records.length < limit; j--) {
|
||||
try {
|
||||
const record = JSON.parse(lines[j]) as TokenUsageRecord;
|
||||
if (query.bookId && record.bookId !== query.bookId) continue;
|
||||
if (query.orchestratorId && record.orchestratorId !== query.orchestratorId) {
|
||||
continue;
|
||||
}
|
||||
if (query.sessionId && record.sessionId !== query.sessionId) continue;
|
||||
const atMs = Date.parse(record.at);
|
||||
if (Number.isFinite(fromMs) && atMs < fromMs) continue;
|
||||
if (Number.isFinite(toMs) && atMs > toMs) continue;
|
||||
records.push(record);
|
||||
} catch {
|
||||
/* skip bad line */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records.sort((a, b) => b.at.localeCompare(a.at));
|
||||
}
|
||||
|
||||
function accumulateCaller(
|
||||
map: Record<string, CallerTokenBreakdown>,
|
||||
caller: string,
|
||||
record: Pick<
|
||||
TokenUsageRecord,
|
||||
"totalTokens" | "cachedTokens" | "cacheMissTokens"
|
||||
>,
|
||||
): void {
|
||||
const prev = map[caller] ?? {
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
calls: 0,
|
||||
};
|
||||
map[caller] = {
|
||||
totalTokens: prev.totalTokens + record.totalTokens,
|
||||
cachedTokens: prev.cachedTokens + (record.cachedTokens ?? 0),
|
||||
cacheMissTokens: prev.cacheMissTokens + (record.cacheMissTokens ?? 0),
|
||||
calls: prev.calls + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeTokenUsage(query: TokenStatsQuery = {}): TokenStatsSummary {
|
||||
const records = readRecords({ ...query, limit: query.limit ?? 2000 });
|
||||
const summary: TokenStatsSummary = {
|
||||
totalCalls: records.length,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCached: 0,
|
||||
totalCacheMiss: 0,
|
||||
byCaller: {},
|
||||
byCallerDetailed: {},
|
||||
byBook: {},
|
||||
byOrchestrator: {},
|
||||
};
|
||||
|
||||
for (const r of records) {
|
||||
summary.promptTokens += r.promptTokens;
|
||||
summary.completionTokens += r.completionTokens;
|
||||
summary.totalTokens += r.totalTokens;
|
||||
summary.totalCached += r.cachedTokens ?? 0;
|
||||
summary.totalCacheMiss += r.cacheMissTokens ?? 0;
|
||||
summary.byCaller[r.caller] = (summary.byCaller[r.caller] ?? 0) + r.totalTokens;
|
||||
accumulateCaller(summary.byCallerDetailed, r.caller, r);
|
||||
if (r.bookId) {
|
||||
const label = r.bookTitle ? `${r.bookTitle} (${r.bookId.slice(0, 8)})` : r.bookId;
|
||||
summary.byBook[label] = (summary.byBook[label] ?? 0) + r.totalTokens;
|
||||
}
|
||||
if (r.orchestratorId) {
|
||||
summary.byOrchestrator[r.orchestratorId] =
|
||||
(summary.byOrchestrator[r.orchestratorId] ?? 0) + r.totalTokens;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function getSessionTokenTotals(sessionId: string): {
|
||||
totalTokens: number;
|
||||
totalCached: number;
|
||||
totalCacheMiss: number;
|
||||
byCaller: Record<string, CallerTokenBreakdown>;
|
||||
last?: TokenUsageRecord;
|
||||
records: TokenUsageRecord[];
|
||||
} {
|
||||
const records = readRecords({ sessionId, limit: 500 });
|
||||
let totalTokens = 0;
|
||||
let totalCached = 0;
|
||||
let totalCacheMiss = 0;
|
||||
const byCaller: Record<string, CallerTokenBreakdown> = {};
|
||||
for (const r of records) {
|
||||
totalTokens += r.totalTokens;
|
||||
totalCached += r.cachedTokens ?? 0;
|
||||
totalCacheMiss += r.cacheMissTokens ?? 0;
|
||||
accumulateCaller(byCaller, r.caller, r);
|
||||
}
|
||||
return {
|
||||
totalTokens,
|
||||
totalCached,
|
||||
totalCacheMiss,
|
||||
byCaller,
|
||||
last: records[0],
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage {
|
||||
return {
|
||||
totalTokens: record.totalTokens,
|
||||
promptTokens: record.promptTokens,
|
||||
completionTokens: record.completionTokens,
|
||||
cachedTokens: record.cachedTokens,
|
||||
cacheMissTokens: record.cacheMissTokens,
|
||||
caller: record.caller,
|
||||
model: record.model,
|
||||
recordId: record.id,
|
||||
};
|
||||
}
|
||||
29
src/types/blackboard.ts
Normal file
29
src/types/blackboard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** worker 读取多条同 tag 时的合并策略 */
|
||||
export type BlackboardInputMerge = "latest" | "concat";
|
||||
|
||||
export type BlackboardItem = {
|
||||
id: string;
|
||||
tag: string;
|
||||
content: string;
|
||||
source: string;
|
||||
scope?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
dependencies?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** 总管可见:无 content */
|
||||
export type BlackboardTagIndex = Pick<
|
||||
BlackboardItem,
|
||||
"id" | "tag" | "source" | "scope" | "updatedAt"
|
||||
>;
|
||||
|
||||
export type BlackboardWriteInput = {
|
||||
tag: string;
|
||||
content: string;
|
||||
source: string;
|
||||
scope?: string;
|
||||
dependencies?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
34
src/types/book-session.ts
Normal file
34
src/types/book-session.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { BlackboardItem } from "../types/blackboard.js";
|
||||
import type { RuntimeSession } from "../types/runtime.js";
|
||||
|
||||
/** 磁盘上保存的 Book 绑定的 Session 快照(用于续作) */
|
||||
export type PersistedBookSession = {
|
||||
version: 1;
|
||||
sessionId: string;
|
||||
bookId: string;
|
||||
orchestratorId?: string;
|
||||
runtimeSession: RuntimeSession;
|
||||
blackboardItems: BlackboardItem[];
|
||||
messages: PersistedChatMessage[];
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
/** 与 session-manager ChatMessage 同形,独立类型避免循环依赖 */
|
||||
export type PersistedChatMessage = {
|
||||
id: string;
|
||||
role: "system" | "user";
|
||||
text: string;
|
||||
createdAt: string;
|
||||
kind?: string;
|
||||
actor?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
thinking?: string;
|
||||
tokenUsage?: {
|
||||
totalTokens: number;
|
||||
cachedTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
caller?: string;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
37
src/types/book.ts
Normal file
37
src/types/book.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type BookProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
/** 用户选定或 agent 确认的 skill 包 */
|
||||
activeSkillId?: string;
|
||||
activeSkillName?: string;
|
||||
preview: string;
|
||||
sessionIds: string[];
|
||||
/** 最近一次持久化的 sessionId,用于续作 */
|
||||
activeSessionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** @deprecated 旧字段,等同 activeSkillId */
|
||||
orchestratorId?: string;
|
||||
/** @deprecated 旧字段,等同 activeSkillName */
|
||||
orchestratorName?: string;
|
||||
};
|
||||
|
||||
export type BookSummary = Pick<
|
||||
BookProject,
|
||||
| "id"
|
||||
| "title"
|
||||
| "activeSkillId"
|
||||
| "activeSkillName"
|
||||
| "preview"
|
||||
| "updatedAt"
|
||||
| "orchestratorId"
|
||||
| "orchestratorName"
|
||||
>;
|
||||
|
||||
export type SkillPackInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
bookKind?: string;
|
||||
};
|
||||
26
src/types/intake.ts
Normal file
26
src/types/intake.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** 启动「填空题」字段定义(来自 SKILL ## 启动询问) */
|
||||
export type IntakeFieldDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/** 单字段展示状态 */
|
||||
export type IntakeFieldStatus = {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
value?: string;
|
||||
filled: boolean;
|
||||
};
|
||||
|
||||
/** 填空进度(供 Web 展示) */
|
||||
export type IntakeProgress = {
|
||||
fields: IntakeFieldStatus[];
|
||||
requiredTotal: number;
|
||||
requiredFilled: number;
|
||||
optionalTotal: number;
|
||||
optionalFilled: number;
|
||||
/** 全部必要项已填 */
|
||||
ready: boolean;
|
||||
};
|
||||
66
src/types/preset.ts
Normal file
66
src/types/preset.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export type PresetPromptRole = "system" | "user" | "assistant";
|
||||
|
||||
export type PresetPromptEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
role: PresetPromptRole;
|
||||
content: string;
|
||||
marker: boolean;
|
||||
sourceIdentifier: string;
|
||||
injection?: {
|
||||
position?: number;
|
||||
depth?: number;
|
||||
order?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PresetPromptOrderItem = {
|
||||
promptId: string;
|
||||
enabled: boolean;
|
||||
orderIndex: number;
|
||||
};
|
||||
|
||||
export type GenerationParameters = {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
topK?: number;
|
||||
minP?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
repetitionPenalty?: number;
|
||||
maxContextTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
stream?: boolean;
|
||||
reasoningEffort?: string;
|
||||
verbosity?: string;
|
||||
seed?: number;
|
||||
variants?: number;
|
||||
};
|
||||
|
||||
export type UnsupportedPresetSection = {
|
||||
path: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PresetPackage = {
|
||||
id: string;
|
||||
name: string;
|
||||
source: "native" | "sillytavern";
|
||||
prompts: PresetPromptEntry[];
|
||||
promptOrder: PresetPromptOrderItem[];
|
||||
generation: GenerationParameters;
|
||||
unsupported: UnsupportedPresetSection[];
|
||||
importedAt: string;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
export type PresetImportReport = {
|
||||
preset: PresetPackage;
|
||||
promptCount: number;
|
||||
enabledCount: number;
|
||||
unreferencedCount: number;
|
||||
missingIdentifiers: string[];
|
||||
generationFields: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
56
src/types/run-snapshot.ts
Normal file
56
src/types/run-snapshot.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BlackboardItem } from "./blackboard.js";
|
||||
import type { PersistedChatMessage } from "./book-session.js";
|
||||
import type { RuntimeSession } from "./runtime.js";
|
||||
|
||||
/** 实例化完成后的对象,或 run 中某一时刻的完整进度 */
|
||||
export type SnapshotKind = "instance" | "run";
|
||||
|
||||
/**
|
||||
* Book 快照(手动保存,可多档)
|
||||
* - instance:实例化后的对象(情境、规则、角色设定等),用于复用思想实验框架、换角色重跑
|
||||
* - run:运行存档,含轮次进度,读档后续玩
|
||||
*/
|
||||
export type RunSnapshot = {
|
||||
version: 1;
|
||||
id: string;
|
||||
bookId: string;
|
||||
label: string;
|
||||
kind: SnapshotKind;
|
||||
/** skill 包 id(兼容旧字段名) */
|
||||
orchestratorId?: string;
|
||||
runtimeSession: RuntimeSession;
|
||||
blackboardItems: BlackboardItem[];
|
||||
/** 保存时的对话;实例快照通常较短(到 setup 验收为止) */
|
||||
messages: PersistedChatMessage[];
|
||||
createdAt: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** 列表展示用,不含正文 payload */
|
||||
export type RunSnapshotMeta = {
|
||||
id: string;
|
||||
bookId: string;
|
||||
label: string;
|
||||
kind: SnapshotKind;
|
||||
/** skill 包 id(兼容旧字段名) */
|
||||
orchestratorId?: string;
|
||||
createdAt: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta {
|
||||
return {
|
||||
id: snapshot.id,
|
||||
bookId: snapshot.bookId,
|
||||
label: snapshot.label,
|
||||
kind: snapshot.kind,
|
||||
orchestratorId: snapshot.orchestratorId,
|
||||
createdAt: snapshot.createdAt,
|
||||
note: snapshot.note,
|
||||
};
|
||||
}
|
||||
|
||||
export const SNAPSHOT_KIND_LABELS: Record<SnapshotKind, string> = {
|
||||
instance: "实例",
|
||||
run: "进度",
|
||||
};
|
||||
257
src/types/runtime.ts
Normal file
257
src/types/runtime.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 运行阶段机类型契约。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 运行相位(RuntimePhase)只有 5 种,表示「系统在等什么」
|
||||
* - 业务细节不扩 phase,而用 waitingReason、slots、artifacts 承载
|
||||
* - LLM / 总管不能直接改 phase,只能通过 RuntimeEvent 驱动 applyEvent
|
||||
*/
|
||||
|
||||
import type { IntakeFieldDef } from "./intake.js";
|
||||
|
||||
/** 运行相位:系统当前在等什么。只有 5 种。 */
|
||||
export type RuntimePhase =
|
||||
| "idle" // 会话已创建,尚未开始
|
||||
| "running" // 正在推进(总管决策、worker 执行、程序验收)
|
||||
| "waiting_user" // 等待用户介入
|
||||
| "done" // 流程正常结束
|
||||
| "error"; // 不可恢复错误
|
||||
|
||||
/**
|
||||
* waiting_user 时的具体原因。
|
||||
* 用单一 phase + reason 替代多个独立 status,避免「胖状态机」。
|
||||
*/
|
||||
export type WaitingReason =
|
||||
| { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } // 启动:选 SKILL.md
|
||||
| { kind: "intake"; prompt: string } // 启动填空:必要/可选项收集
|
||||
| { kind: "input"; message?: string } // 总管 ask_user / 返工说明(启动完成后)
|
||||
| { kind: "approve_step"; decisionId: string } // 总管建议 run_worker,等用户确认
|
||||
| { kind: "review_artifact"; artifactId: string } // worker 产物待验收
|
||||
| { kind: "worker_questions"; workerId: string; questions: string[] } // worker 中途提问
|
||||
| { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败
|
||||
|
||||
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
|
||||
export type SkillIndexEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Book 存储形态(由 SKILL.md 选定,选 skill 后不可变更)。
|
||||
* 第一版仅两种:小说线性结构 / 多轮多角色对话。
|
||||
*/
|
||||
export type BookKind = "novel" | "dialogue";
|
||||
|
||||
/** 选中的 skill 快照,写入 session.slots.activeSkill,供启动询问与后续流程使用 */
|
||||
export type ActiveSkillSnapshot = {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
/** 选定 skill 时确定,对应 Book 最终产物结构 */
|
||||
bookKind?: BookKind;
|
||||
defaultFlowId?: string;
|
||||
suggestedWorkers: string[];
|
||||
/** 来自 SKILL.md ## 启动询问 的展示文案 */
|
||||
startupPrompt: string;
|
||||
/** 用户首次输入写入的 slots 键,如 book.brief */
|
||||
startupTargetKey: string;
|
||||
/** 启动填空字段(必要 + 可选) */
|
||||
intakeFields: IntakeFieldDef[];
|
||||
};
|
||||
|
||||
/** worker 产物验收方式 */
|
||||
export type AcceptanceMode =
|
||||
| "user_confirmed" // 默认:产物完成后等用户验收
|
||||
| "no_confirmation" // 自动接受,直接回到总管
|
||||
| "programmatic_review"; // 走程序验收规则
|
||||
|
||||
/**
|
||||
* Worker 链推进模式(预留)。
|
||||
*
|
||||
* - manual:默认。每次 run_worker 可要求 approve_step;产物默认 user_confirmed。
|
||||
* - semi_auto:半自动串联 worker;仅在 skill 声明的 pauseCheckpoint 处强制暂停。
|
||||
*
|
||||
* 第一版 Runtime 仅实现 manual;semi_auto 由 orchestrator 文档化,待后续接入。
|
||||
* 见 docs/runtime-state-machine.md §10、docs/orchestrator-skill-format.md §10。
|
||||
*/
|
||||
export type WorkerAdvanceMode = "manual" | "semi_auto";
|
||||
|
||||
/**
|
||||
* semi_auto 下的暂停检查点(由各 skill 在 orchestrator 中声明)。
|
||||
*
|
||||
* `when` 为 skill 自定义的暂停条件描述或未来可解析的表达式;
|
||||
* 具体变量(轮次、token 预算、阶段界等)不由全局 enum 限定。
|
||||
*/
|
||||
export type AdvancePauseCheckpoint = {
|
||||
id: string;
|
||||
description: string;
|
||||
/** 文档化条件;未来可绑定 slot / tag / 计数器,现阶段 Runtime 不解析 */
|
||||
when?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 会话级 worker 推进策略(预留)。
|
||||
* 未设置或未解析时等价 `{ mode: "manual" }`。
|
||||
*/
|
||||
export type AdvancePolicy = {
|
||||
mode: WorkerAdvanceMode;
|
||||
pauseCheckpoints?: AdvancePauseCheckpoint[];
|
||||
};
|
||||
|
||||
/** 总管(Main Agent)可执行的意图,对应 tool call 的 action 字段 */
|
||||
export type MainAgentAction =
|
||||
| "ask_user" // 向用户提问
|
||||
| "run_worker" // 调度已有 worker
|
||||
| "create_temp_worker" // 临时 worker(与 run_worker 共用阶段机分支)
|
||||
| "review_blackboard" // 查看黑板后向用户说明
|
||||
| "finish"; // 结束流程
|
||||
|
||||
/**
|
||||
* 总管的一次决策。
|
||||
* statePatchAllowed 必须为 false,禁止 LLM 直接 patch 会话状态。
|
||||
*/
|
||||
export type MainAgentDecision = {
|
||||
id: string;
|
||||
action: MainAgentAction;
|
||||
reason: string;
|
||||
workerId?: string;
|
||||
/** 调度 role-decide 等时指定当前决策角色,Runtime 写入 世界.当前角色.id */
|
||||
workerContext?: { roleId?: string };
|
||||
/** true 时进入 waiting_user(approve_step),等用户确认后才 run_worker */
|
||||
requiresApproval: boolean;
|
||||
statePatchAllowed: false;
|
||||
};
|
||||
|
||||
/** worker 产物的生命周期状态 */
|
||||
export type ArtifactStatus =
|
||||
| "drafted"
|
||||
| "under_review"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "revision_requested"
|
||||
| "superseded";
|
||||
|
||||
/** worker 产出的一条产物记录 */
|
||||
export type ArtifactRecord = {
|
||||
id: string;
|
||||
workerId: string;
|
||||
stepId?: string;
|
||||
outputTags: string[];
|
||||
status: ArtifactStatus;
|
||||
summary?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** worker 因 ask_user 中途暂停时保存的上下文,用户回复后用于 resume_worker */
|
||||
export type ResumeContext = {
|
||||
workerId: string;
|
||||
stepId?: string;
|
||||
acceptanceMode: AcceptanceMode;
|
||||
questions: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 阶段机唯一合法的状态变更入口。
|
||||
* 所有 phase 转移都必须通过 dispatch → applyEvent 处理某种 RuntimeEvent。
|
||||
*/
|
||||
export type RuntimeEvent =
|
||||
| {
|
||||
type: "session_started";
|
||||
payload: { presetId: string; flowId?: string; availableSkills: SkillIndexEntry[] };
|
||||
}
|
||||
| { type: "skill_selected"; payload: { skill: ActiveSkillSnapshot } }
|
||||
| {
|
||||
type: "user_submitted_input";
|
||||
payload: { text: string; intakeValues?: Record<string, string> };
|
||||
}
|
||||
| { type: "user_confirmed_intake"; payload: Record<string, never> }
|
||||
| {
|
||||
type: "main_agent_decision_created";
|
||||
payload: { decision: MainAgentDecision };
|
||||
}
|
||||
| { type: "user_approved_next_step"; payload: { decisionId: string } }
|
||||
| {
|
||||
type: "user_rejected_next_step";
|
||||
payload: { decisionId: string; reason?: string };
|
||||
}
|
||||
| {
|
||||
type: "worker_started";
|
||||
payload: {
|
||||
workerId: string;
|
||||
stepId?: string;
|
||||
acceptanceMode: AcceptanceMode;
|
||||
};
|
||||
}
|
||||
| { type: "worker_completed"; payload: { artifactId: string } }
|
||||
| {
|
||||
type: "worker_needs_input";
|
||||
payload: { workerId: string; stepId?: string; questions: string[] };
|
||||
}
|
||||
| { type: "user_accepted_artifact"; payload: { artifactId: string } }
|
||||
| {
|
||||
type: "user_rejected_artifact";
|
||||
payload: { artifactId: string; reason?: string };
|
||||
}
|
||||
| {
|
||||
type: "user_requested_revision";
|
||||
payload: { artifactId?: string; instruction: string };
|
||||
}
|
||||
| { type: "programmatic_review_started"; payload: { artifactId: string } }
|
||||
| { type: "programmatic_review_passed"; payload: { artifactId: string } }
|
||||
| {
|
||||
type: "programmatic_review_failed";
|
||||
payload: { artifactId: string; reason: string };
|
||||
}
|
||||
| { type: "flow_completed"; payload: Record<string, never> }
|
||||
| { type: "runtime_failed"; payload: { reason: string } };
|
||||
|
||||
/** 一次会话的完整运行时快照 */
|
||||
export type RuntimeSession = {
|
||||
id: string;
|
||||
phase: RuntimePhase;
|
||||
waitingReason?: WaitingReason;
|
||||
presetId: string;
|
||||
flowId?: string;
|
||||
/** 业务 flow 当前步骤(execution flow 层,与 phase 正交) */
|
||||
currentStepId?: string;
|
||||
currentWorkerId?: string;
|
||||
acceptanceMode?: AcceptanceMode;
|
||||
/** 推进策略(预留)。缺省 = manual,见 WorkerAdvanceMode */
|
||||
advancePolicy?: AdvancePolicy;
|
||||
reviewRequirements?: string[];
|
||||
resumeContext?: ResumeContext;
|
||||
/** 轻量槽位:activeSkill、book.brief、startupCompleted 等 */
|
||||
slots: Record<string, unknown>;
|
||||
artifacts: ArtifactRecord[];
|
||||
/** 待用户确认的总管决策(approve_step 时有效) */
|
||||
pendingDecision?: MainAgentDecision;
|
||||
/** 待验收的产物 id(review_artifact 时有效) */
|
||||
pendingArtifactId?: string;
|
||||
/** 已应用的 RuntimeEvent 日志,便于调试与回放 */
|
||||
history: RuntimeEvent[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* applyEvent 返回的副作用清单。
|
||||
* 纯函数 phase-machine 不执行 IO,由 phase-runtime 的 processEffects 消费。
|
||||
*/
|
||||
export type PhaseEffect =
|
||||
| { type: "invoke_main_agent" }
|
||||
| { type: "run_worker"; workerId: string; workerContext?: { roleId?: string } }
|
||||
| { type: "resume_worker" }
|
||||
| { type: "run_programmatic_review"; artifactId: string }
|
||||
| { type: "emit_message"; message: string };
|
||||
|
||||
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
|
||||
export type ApplyEventResult = {
|
||||
session: RuntimeSession;
|
||||
effects: PhaseEffect[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** @deprecated 使用 RuntimePhase */
|
||||
export type RuntimeStatus = RuntimePhase;
|
||||
61
src/types/tools.ts
Normal file
61
src/types/tools.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/** 总管 tool 名称 */
|
||||
export type MainAgentToolName =
|
||||
| "read_blackboard"
|
||||
| "list_workers"
|
||||
| "list_artifacts"
|
||||
| "ask_user"
|
||||
| "run_worker"
|
||||
| "review_blackboard"
|
||||
| "finish";
|
||||
|
||||
/** 可在 tool loop 内反复调用、不改变运行相位的 tool */
|
||||
export const MAIN_AGENT_LOOP_TOOLS: MainAgentToolName[] = [
|
||||
"read_blackboard",
|
||||
"list_workers",
|
||||
"list_artifacts",
|
||||
];
|
||||
|
||||
/** 调用后应退出 loop、交给阶段机的终止 tool */
|
||||
export const MAIN_AGENT_TERMINAL_TOOLS: MainAgentToolName[] = [
|
||||
"ask_user",
|
||||
"run_worker",
|
||||
"review_blackboard",
|
||||
"finish",
|
||||
];
|
||||
|
||||
export function isMainAgentTerminalTool(name: string): name is MainAgentToolName {
|
||||
return (MAIN_AGENT_TERMINAL_TOOLS as string[]).includes(name);
|
||||
}
|
||||
|
||||
export function isMainAgentLoopTool(name: string): name is MainAgentToolName {
|
||||
return (MAIN_AGENT_LOOP_TOOLS as string[]).includes(name);
|
||||
}
|
||||
|
||||
export type ReadBlackboardParams = {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type ListWorkersParams = Record<string, never>;
|
||||
|
||||
export type ListArtifactsParams = Record<string, never>;
|
||||
|
||||
export type AskUserParams = {
|
||||
reason: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type RunWorkerParams = {
|
||||
workerId: string;
|
||||
reason: string;
|
||||
requiresApproval: boolean;
|
||||
roleId?: string;
|
||||
};
|
||||
|
||||
export type ReviewBlackboardParams = {
|
||||
reason: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type FinishParams = {
|
||||
reason: string;
|
||||
};
|
||||
218
src/worker/executor.ts
Normal file
218
src/worker/executor.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import { loadWorkerSkillWithContext } from "../skills/loader.js";
|
||||
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
|
||||
import { collectUserInputTranscript } from "../intake/intake.js";
|
||||
import { resolveWorkerId } from "./resolve-id.js";
|
||||
import type { BlackboardInputMerge } from "../types/blackboard.js";
|
||||
|
||||
export type WorkerRunParams = {
|
||||
skillName: string;
|
||||
workerId: string;
|
||||
slots: Record<string, unknown>;
|
||||
blackboard: Blackboard;
|
||||
llm: LlmProvider;
|
||||
};
|
||||
|
||||
export type WorkerRunResult = {
|
||||
outputs: Record<string, string>;
|
||||
summary: string;
|
||||
preview: string;
|
||||
askUser?: string[];
|
||||
};
|
||||
|
||||
const WORKER_OUTPUT_INSTRUCTION = `
|
||||
|
||||
---
|
||||
|
||||
## 运行时输出协议(必须遵守)
|
||||
|
||||
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段:
|
||||
{
|
||||
"outputs": { "<outputTag>": "<内容字符串>" },
|
||||
"summary": "50字以内产物摘要",
|
||||
"askUser": null 或 ["需要用户补充的问题"]
|
||||
}
|
||||
|
||||
- outputs 的 key 必须是要求的 outputTags
|
||||
- 若信息不足,outputs 可为空对象,askUser 填入问题
|
||||
- 若 inputs 中 \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
|
||||
- summary 用于界面展示`;
|
||||
|
||||
function slotValueForTag(
|
||||
tag: string,
|
||||
slots: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
const val = slots[tag];
|
||||
if (typeof val === "string" && val.trim()) return val.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function gatherInputs(
|
||||
inputTags: string[],
|
||||
inputMerge: BlackboardInputMerge,
|
||||
slots: Record<string, unknown>,
|
||||
blackboard: Blackboard,
|
||||
): Record<string, string> {
|
||||
const inputs: Record<string, string> = {};
|
||||
const items = blackboard.queryByPatterns(inputTags, inputMerge);
|
||||
|
||||
for (const item of items) {
|
||||
inputs[item.tag] = item.content;
|
||||
}
|
||||
|
||||
for (const pattern of inputTags) {
|
||||
if (pattern.endsWith(".*") || pattern === "**") continue;
|
||||
const slotVal = slotValueForTag(pattern, slots);
|
||||
if (slotVal) inputs[pattern] = slotVal;
|
||||
}
|
||||
|
||||
const workerReply = slotValueForTag("用户.worker答复", slots);
|
||||
if (workerReply && !inputs["用户.worker答复"]) {
|
||||
inputs["用户.worker答复"] = workerReply;
|
||||
}
|
||||
|
||||
if (inputTags.includes("用户.博弈需求") && !inputs["用户.博弈需求"]) {
|
||||
const fromSlot = slotValueForTag("用户.博弈需求", slots);
|
||||
if (fromSlot) {
|
||||
inputs["用户.博弈需求"] = fromSlot;
|
||||
} else {
|
||||
const transcript = collectUserInputTranscript(slots);
|
||||
if (transcript) inputs["用户.博弈需求"] = transcript;
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function parseWorkerResponse(
|
||||
raw: string,
|
||||
outputTags: string[],
|
||||
): WorkerRunResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
const fallback: Record<string, string> = {};
|
||||
if (outputTags.length === 1) {
|
||||
fallback[outputTags[0]] = raw;
|
||||
} else {
|
||||
fallback[outputTags[0] ?? "output.草稿"] = raw;
|
||||
}
|
||||
return {
|
||||
outputs: fallback,
|
||||
summary: raw.slice(0, 80),
|
||||
preview: raw.slice(0, 600),
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Worker 返回了无效 JSON");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const outputsRaw = obj.outputs;
|
||||
const outputs: Record<string, string> = {};
|
||||
|
||||
if (outputsRaw && typeof outputsRaw === "object") {
|
||||
for (const tag of outputTags) {
|
||||
const val = (outputsRaw as Record<string, unknown>)[tag];
|
||||
if (typeof val === "string" && val.trim()) {
|
||||
outputs[tag] = val.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
|
||||
|
||||
let askUser: string[] | undefined;
|
||||
if (Array.isArray(obj.askUser)) {
|
||||
askUser = obj.askUser.filter(
|
||||
(q): q is string =>
|
||||
typeof q === "string" && q.trim().length > 0 && !isMetaAskToken(q),
|
||||
);
|
||||
} else if (typeof obj.askUser === "string" && obj.askUser.trim()) {
|
||||
const q = obj.askUser.trim();
|
||||
askUser = isMetaAskToken(q) ? undefined : [q];
|
||||
}
|
||||
if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) {
|
||||
const summaryText =
|
||||
typeof obj.summary === "string" ? obj.summary.trim() : "";
|
||||
if (
|
||||
summaryText &&
|
||||
!isMetaAskToken(summaryText) &&
|
||||
summaryText.length > 8 &&
|
||||
/[??]/.test(summaryText)
|
||||
) {
|
||||
askUser = [summaryText];
|
||||
} else if (isMetaAskToken(summaryText)) {
|
||||
askUser = ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
|
||||
}
|
||||
}
|
||||
|
||||
const summary =
|
||||
typeof obj.summary === "string" && obj.summary.trim()
|
||||
? obj.summary.trim()
|
||||
: Object.values(outputs)[0]?.slice(0, 80) ?? "Worker 已完成";
|
||||
|
||||
const preview =
|
||||
Object.entries(outputs)
|
||||
.map(([tag, v]) => `### ${tag}\n\n${v}`)
|
||||
.join("\n\n")
|
||||
.slice(0, 4000) || summary;
|
||||
|
||||
return { outputs, summary, preview, askUser: askUser?.length ? askUser : undefined };
|
||||
}
|
||||
|
||||
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
|
||||
const workerId = resolveWorkerId(params.workerId);
|
||||
const { worker, promptBody } = await loadWorkerSkillWithContext(
|
||||
params.skillName,
|
||||
workerId,
|
||||
);
|
||||
|
||||
const inputMerge = worker.inputMerge ?? "latest";
|
||||
let inputs = gatherInputs(
|
||||
worker.inputTags,
|
||||
inputMerge,
|
||||
params.slots,
|
||||
params.blackboard,
|
||||
);
|
||||
|
||||
if (worker.id === "role-decide") {
|
||||
inputs = filterInputsForRolePerspective(inputs, params.slots);
|
||||
}
|
||||
|
||||
const userPayload = {
|
||||
workerId: worker.id,
|
||||
workerName: worker.name,
|
||||
inputTags: worker.inputTags,
|
||||
outputTags: worker.outputTags,
|
||||
inputs,
|
||||
instruction:
|
||||
"根据 SKILL 说明完成创作任务。若 inputs 不足,使用 askUser 提问而非臆造。inputs 中已有用户.博弈需求 / book.brief 时,应直接据此产出,勿重复索要已提供信息。",
|
||||
};
|
||||
|
||||
const result = await params.llm.complete(
|
||||
[
|
||||
{ role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION },
|
||||
{ role: "user", content: JSON.stringify(userPayload, null, 2) },
|
||||
],
|
||||
{
|
||||
responseFormat: "json_object",
|
||||
caller: `worker:${worker.id}`,
|
||||
},
|
||||
);
|
||||
|
||||
return parseWorkerResponse(result.content, worker.outputTags);
|
||||
}
|
||||
|
||||
/** @internal 供单测验证 inputTags → inputs 拼接 */
|
||||
export function gatherWorkerInputs(
|
||||
inputTags: string[],
|
||||
inputMerge: BlackboardInputMerge,
|
||||
slots: Record<string, unknown>,
|
||||
blackboard: Blackboard,
|
||||
): Record<string, string> {
|
||||
return gatherInputs(inputTags, inputMerge, slots, blackboard);
|
||||
}
|
||||
10
src/worker/resolve-id.ts
Normal file
10
src/worker/resolve-id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** 总管/历史命名与 skill 包内 worker id 的映射 */
|
||||
const WORKER_ALIASES: Record<string, string> = {
|
||||
"rules-worker": "write-rules",
|
||||
"outline-worker": "outline",
|
||||
"drafting-worker": "drafting",
|
||||
};
|
||||
|
||||
export function resolveWorkerId(workerId: string): string {
|
||||
return WORKER_ALIASES[workerId] ?? workerId;
|
||||
}
|
||||
Reference in New Issue
Block a user