重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 00:39:32 +08:00
parent 2b74c30d36
commit e670a5129c
167 changed files with 22955 additions and 5659 deletions

View File

@@ -10,8 +10,10 @@ export class Blackboard {
private items = new Map<string, BlackboardItem>();
private writeSeq = 0;
listTagIndex(): BlackboardTagIndex[] {
return [...this.items.values()]
listTagIndex(options?: { includeArchived?: boolean }): BlackboardTagIndex[] {
const includeArchived = options?.includeArchived === true;
return latestItemsByTag([...this.items.values()])
.filter((item) => includeArchived || item.metadata?.role !== "archived")
.map(({ id, tag, source, scope, updatedAt }) => ({
id,
tag,
@@ -43,34 +45,38 @@ export class Blackboard {
queryByPatterns(
patterns: string[],
merge: "latest" | "concat" = "latest",
options?: { includeArchived?: boolean },
): BlackboardItem[] {
const includeArchived = options?.includeArchived === true;
const allItems = [...this.items.values()];
if (patterns.some(isFullAccessPattern)) {
return [...this.items.values()].sort((a, b) =>
a.updatedAt.localeCompare(b.updatedAt),
);
const latestByTag = latestItemsByTag(allItems);
return latestByTag
.filter((item) => includeArchived || item.metadata?.role !== "archived")
.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);
const matchedLatest = latestItemsByTag(
allItems.filter((item) => tagMatchesPattern(item.tag, pattern)),
).filter((item) => includeArchived || item.metadata?.role !== "archived");
if (matched.length === 0) continue;
if (matchedLatest.length === 0) continue;
if (merge === "concat" && matched.length > 1) {
if (merge === "concat" && matchedLatest.length > 1) {
const ordered = matchedLatest.sort((a, b) =>
a.updatedAt.localeCompare(b.updatedAt),
);
result.push({
...matched[0],
...ordered[ordered.length - 1],
id: `merged:${pattern}`,
content: matched
.slice()
.reverse()
.map((m) => m.content)
.join("\n\n---\n\n"),
content: ordered.map((m) => m.content).join("\n\n---\n\n"),
});
} else {
result.push(matched[0]);
result.push(matchedLatest.sort(compareItemsByRecency)[0]);
}
}
@@ -115,3 +121,13 @@ export class Blackboard {
function compareItemsByRecency(a: BlackboardItem, b: BlackboardItem): number {
return b.updatedAt.localeCompare(a.updatedAt);
}
/** 每个 tag 只保留最新一条 */
function latestItemsByTag(items: BlackboardItem[]): BlackboardItem[] {
const byTag = new Map<string, BlackboardItem>();
for (const item of items) {
const prev = byTag.get(item.tag);
if (!prev || item.updatedAt > prev.updatedAt) byTag.set(item.tag, item);
}
return [...byTag.values()];
}

View File

@@ -0,0 +1,141 @@
/**
* 表字段格:扁平 rows + 每格 rev防止用户手改被 worker 覆盖。
*/
export type TableCellSource = `user` | `system` | `worker:${string}`;
export type TableCell = {
key: string;
value: unknown;
rev: number;
updatedAt: string;
source: TableCellSource | string;
visibility?: "visible" | "hidden";
note?: string;
};
export type TableDoc = {
rows: TableCell[];
};
export function parseTableDoc(raw: string | undefined | null): TableDoc | null {
if (!raw?.trim()) return null;
try {
const doc = JSON.parse(raw) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const rowsRaw = (doc as { rows?: unknown }).rows;
if (!Array.isArray(rowsRaw)) return null;
const rows: TableCell[] = [];
for (const item of rowsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const key = typeof row.key === "string" ? row.key.trim() : "";
if (!key) continue;
const rev = typeof row.rev === "number" && row.rev >= 1 ? row.rev : 1;
rows.push({
key,
value: row.value,
rev,
updatedAt:
typeof row.updatedAt === "string" && row.updatedAt
? row.updatedAt
: new Date().toISOString(),
source:
typeof row.source === "string" && row.source
? row.source
: "system",
visibility:
row.visibility === "hidden" || row.visibility === "visible"
? row.visibility
: "visible",
note: typeof row.note === "string" ? row.note : undefined,
});
}
return { rows };
} catch {
return null;
}
}
export function stringifyTableDoc(doc: TableDoc): string {
return JSON.stringify(doc, null, 2);
}
/**
* 合并表更新worker 须带读时的 expectedRev
* source=user 的格默认不覆盖rev 不匹配则跳过该格。
*/
export function mergeTableCells(params: {
current: TableDoc | null;
patch: TableDoc;
actor: TableCellSource | string;
/** 若提供,仅当 current.rev === expectedRev[key] 时才写入 */
expectedRev?: Record<string, number>;
}): { doc: TableDoc; applied: string[]; skipped: Array<{ key: string; reason: string }> } {
const byKey = new Map<string, TableCell>();
for (const row of params.current?.rows ?? []) {
byKey.set(row.key, { ...row });
}
const applied: string[] = [];
const skipped: Array<{ key: string; reason: string }> = [];
const now = new Date().toISOString();
for (const patch of params.patch.rows) {
const key = patch.key.trim();
if (!key) continue;
const existing = byKey.get(key);
if (existing?.source === "user" && !String(params.actor).startsWith("user")) {
skipped.push({ key, reason: "user-owned" });
continue;
}
if (params.expectedRev && existing) {
const expected = params.expectedRev[key];
if (expected != null && existing.rev !== expected) {
skipped.push({
key,
reason: `rev-conflict have=${existing.rev} expected=${expected}`,
});
continue;
}
}
const nextRev = existing ? existing.rev + 1 : patch.rev >= 1 ? patch.rev : 1;
byKey.set(key, {
key,
value: patch.value,
rev: nextRev,
updatedAt: now,
source: params.actor,
visibility: patch.visibility ?? existing?.visibility ?? "visible",
note: patch.note ?? existing?.note,
});
applied.push(key);
}
return {
doc: { rows: [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)) },
applied,
skipped,
};
}
/** 从零创建:全部 source=actorrev=1 */
export function createTableFromValues(
values: Record<string, unknown>,
actor: TableCellSource | string,
meta?: Record<string, { visibility?: "visible" | "hidden"; note?: string }>,
): TableDoc {
const now = new Date().toISOString();
const rows: TableCell[] = Object.entries(values).map(([key, value]) => ({
key,
value,
rev: 1,
updatedAt: now,
source: actor,
visibility: meta?.[key]?.visibility ?? "visible",
note: meta?.[key]?.note,
}));
return { rows: rows.sort((a, b) => a.key.localeCompare(b.key)) };
}

View File

@@ -0,0 +1,275 @@
/**
* 表边沿副作用prev 不满足且 now 满足才触发once 规则记 fired。
* 规则来自 设计.worker集.tables.side_effects。
*/
import type { Blackboard } from "./blackboard.js";
import type { TableDoc } from "./table-cells.js";
export type SideEffectOp = "eq" | "neq" | "gte" | "lte" | "gt" | "lt" | "truthy" | "changed";
export type SideEffectMode = "once" | "edge" | "every_edge";
export type SideEffectAction =
| { type: "write_tag"; tag: string; content: string }
| { type: "replace_tag"; tag: string; content: string }
| { type: "queue_worker"; workerId: string; note?: string };
export type SideEffectRule = {
id: string;
field: string;
op: SideEffectOp;
value?: unknown;
/** once = 边沿 + 记 firededge/every_edge = 仅边沿,可反复跨边沿 */
mode: SideEffectMode;
action: SideEffectAction;
};
export type FiredRegistry = Record<string, { at: string; round?: number }>;
export const SIDE_EFFECT_FIRED_TAG = "运行.表副作用.fired";
export function parseSideEffectRules(tables: unknown): SideEffectRule[] {
if (!tables || typeof tables !== "object" || Array.isArray(tables)) return [];
const raw = (tables as { side_effects?: unknown }).side_effects;
if (!Array.isArray(raw)) return [];
const out: SideEffectRule[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const id = typeof row.id === "string" ? row.id.trim() : "";
const field = typeof row.field === "string" ? row.field.trim() : "";
if (!id || !field) continue;
const op = normalizeOp(row.op);
const mode = normalizeMode(row.mode);
const action = parseAction(row.action);
if (!action) continue;
out.push({ id, field, op, value: row.value, mode, action });
}
return out;
}
function normalizeOp(raw: unknown): SideEffectOp {
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "eq";
const allowed: SideEffectOp[] = [
"eq",
"neq",
"gte",
"lte",
"gt",
"lt",
"truthy",
"changed",
];
return (allowed.includes(s as SideEffectOp) ? s : "eq") as SideEffectOp;
}
function normalizeMode(raw: unknown): SideEffectMode {
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "once";
if (s === "edge" || s === "every_edge") return "every_edge";
return "once";
}
function parseAction(raw: unknown): SideEffectAction | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const a = raw as Record<string, unknown>;
const type = typeof a.type === "string" ? a.type.trim() : "";
if (type === "write_tag" || type === "replace_tag") {
const tag = typeof a.tag === "string" ? a.tag.trim() : "";
const content = typeof a.content === "string" ? a.content : "";
if (!tag) return null;
return { type, tag, content };
}
if (type === "queue_worker") {
const workerId =
typeof a.workerId === "string"
? a.workerId.trim()
: typeof a.worker_id === "string"
? a.worker_id.trim()
: "";
if (!workerId) return null;
return {
type: "queue_worker",
workerId,
note: typeof a.note === "string" ? a.note : undefined,
};
}
return null;
}
export function tableValueMap(doc: TableDoc | null | undefined): Map<string, unknown> {
const map = new Map<string, unknown>();
for (const row of doc?.rows ?? []) {
map.set(row.key, row.value);
}
return map;
}
export function conditionSatisfied(
values: Map<string, unknown>,
rule: SideEffectRule,
prevValues?: Map<string, unknown>,
): boolean {
const now = values.get(rule.field);
if (rule.op === "changed") {
if (!prevValues) return false;
return !sameValue(prevValues.get(rule.field), now);
}
return compareOp(now, rule.op, rule.value);
}
function compareOp(actual: unknown, op: SideEffectOp, expected: unknown): boolean {
switch (op) {
case "eq":
return sameValue(actual, expected);
case "neq":
return !sameValue(actual, expected);
case "truthy":
return Boolean(actual);
case "gte":
case "lte":
case "gt":
case "lt": {
const a = toNumber(actual);
const b = toNumber(expected);
if (a == null || b == null) return false;
if (op === "gte") return a >= b;
if (op === "lte") return a <= b;
if (op === "gt") return a > b;
return a < b;
}
case "changed":
return false;
default:
return false;
}
}
function toNumber(v: unknown): number | null {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() && !Number.isNaN(Number(v))) {
return Number(v);
}
return null;
}
function sameValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a == null && b == null;
if (typeof a === "number" || typeof b === "number") {
const na = toNumber(a);
const nb = toNumber(b);
return na != null && nb != null && na === nb;
}
return String(a) === String(b);
}
export type SideEffectTrigger = {
rule: SideEffectRule;
reason: "edge";
};
/**
* 对本轮 prev→now 快照算边沿;同 round 同 ruleId 最多一次。
* once已 fired 则跳过;触发后写入 nextFired。
* every_edge每次边沿都触发不记 fired。
*/
export function evaluateSideEffects(params: {
prev: TableDoc | null;
next: TableDoc;
rules: SideEffectRule[];
fired: FiredRegistry;
round?: number;
}): {
triggers: SideEffectTrigger[];
nextFired: FiredRegistry;
queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }>;
} {
const prevMap = tableValueMap(params.prev);
const nextMap = tableValueMap(params.next);
const nextFired: FiredRegistry = { ...params.fired };
const triggers: SideEffectTrigger[] = [];
const seen = new Set<string>();
const queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }> =
[];
for (const rule of params.rules) {
if (seen.has(rule.id)) continue;
if (rule.mode === "once" && nextFired[rule.id]) continue;
const was = conditionSatisfied(prevMap, rule, undefined);
const now = conditionSatisfied(nextMap, rule, prevMap);
// 边沿prev 不满足、now 满足changed 用 prev/now 值差)
const edge =
rule.op === "changed"
? conditionSatisfied(nextMap, rule, prevMap)
: !was && now;
if (!edge) continue;
seen.add(rule.id);
triggers.push({ rule, reason: "edge" });
if (rule.mode === "once") {
nextFired[rule.id] = {
at: new Date().toISOString(),
round: params.round,
};
}
if (rule.action.type === "queue_worker") {
queuedWorkers.push({
workerId: rule.action.workerId,
ruleId: rule.id,
note: rule.action.note,
});
}
}
return { triggers, nextFired, queuedWorkers };
}
export function parseFiredRegistry(raw: string | undefined | null): FiredRegistry {
if (!raw?.trim()) return {};
try {
const doc = JSON.parse(raw) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {};
const out: FiredRegistry = {};
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
if (!k.trim()) continue;
if (v && typeof v === "object" && !Array.isArray(v)) {
const row = v as Record<string, unknown>;
out[k] = {
at: typeof row.at === "string" ? row.at : new Date().toISOString(),
round: typeof row.round === "number" ? row.round : undefined,
};
} else if (v === true) {
out[k] = { at: new Date().toISOString() };
}
}
return out;
} catch {
return {};
}
}
export function stringifyFiredRegistry(fired: FiredRegistry): string {
return JSON.stringify(fired, null, 2);
}
/** 执行 write_tag / replace_tagqueue_worker 只返回,由上层调度 */
export function applySideEffectTagActions(params: {
blackboard: Blackboard;
triggers: SideEffectTrigger[];
source?: string;
}): { writtenTags: string[] } {
const writtenTags: string[] = [];
const source = params.source ?? "system:table-side-effect";
for (const { rule } of params.triggers) {
const action = rule.action;
if (action.type !== "write_tag" && action.type !== "replace_tag") continue;
params.blackboard.write({
tag: action.tag,
content: action.content,
source,
});
writtenTags.push(action.tag);
}
return { writtenTags };
}

View File

@@ -1,11 +1,23 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import type { BookProject, BookSummary } from "../types/book.js";
import type { PersistedBookSession } from "../types/book-session.js";
import type { RunSnapshot } from "../types/run-snapshot.js";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
import { deleteBookSession } from "./session-store.js";
import { deleteAllRunSnapshots } from "./run-snapshot-store.js";
const SESSION_FILENAME = "session.json";
function booksDir(): string {
return path.join(getUserDataDir(), "books");
}
@@ -14,6 +26,10 @@ function bookPath(id: string): string {
return path.join(booksDir(), `${id}.json`);
}
function bookDataDir(id: string): string {
return path.join(getUserDataDir(), "books", id);
}
export function listBooks(): BookSummary[] {
ensureUserDataDirs();
mkdirSync(booksDir(), { recursive: true });
@@ -64,7 +80,7 @@ export function createBook(input: { title?: string }): BookProject {
const book: BookProject = {
id: randomUUID(),
title: input.title?.trim() || "未命名作品",
preview: "新建作品,选择 skill 包开始…",
preview: "新建作品,描述你想创作什么…",
sessionIds: [],
createdAt: now,
updatedAt: now,
@@ -84,6 +100,8 @@ export function updateBook(
| "activeSessionId"
| "activeSkillId"
| "activeSkillName"
| "orchestratorId"
| "orchestratorName"
>
>,
): BookProject {
@@ -115,3 +133,69 @@ export function deleteBook(id: string): void {
/* ignore */
}
}
/** 复制作品及其会话、游玩存档为新作品 */
export function duplicateBook(sourceId: string, title?: string): BookProject {
const source = getBook(sourceId);
if (!source) throw new Error("Book 不存在");
const newId = randomUUID();
const newSessionId = randomUUID();
const now = new Date().toISOString();
const dupTitle = title?.trim() || `${source.title} 副本`;
ensureUserDataDirs();
const sourceDir = bookDataDir(sourceId);
const destDir = bookDataDir(newId);
if (existsSync(sourceDir)) {
cpSync(sourceDir, destDir, { recursive: true });
} else {
mkdirSync(destDir, { recursive: true });
}
const sessionFile = path.join(destDir, SESSION_FILENAME);
let sessionIds: string[] = [];
let activeSessionId: string | undefined;
if (existsSync(sessionFile)) {
try {
const snap = JSON.parse(readFileSync(sessionFile, "utf8")) as PersistedBookSession;
snap.bookId = newId;
snap.sessionId = newSessionId;
snap.runtimeSession.id = newSessionId;
snap.savedAt = now;
writeFileSync(sessionFile, JSON.stringify(snap, null, 2), "utf8");
sessionIds = [newSessionId];
activeSessionId = newSessionId;
} catch {
/* ignore broken session */
}
}
const snapDir = path.join(destDir, "run-snapshots");
if (existsSync(snapDir)) {
for (const file of readdirSync(snapDir).filter((f) => f.endsWith(".json"))) {
try {
const full = path.join(snapDir, file);
const raw = JSON.parse(readFileSync(full, "utf8")) as RunSnapshot;
raw.bookId = newId;
writeFileSync(full, JSON.stringify(raw, null, 2), "utf8");
} catch {
/* skip */
}
}
}
const newBook: BookProject = {
...structuredClone(source),
id: newId,
title: dupTitle,
sessionIds,
activeSessionId,
createdAt: now,
updatedAt: now,
};
writeFileSync(bookPath(newId), JSON.stringify(newBook, null, 2), "utf8");
return newBook;
}

View File

@@ -22,10 +22,11 @@ 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 → …
1. /start → 自动进入默认 orchestratorworld-simulator
2. 按启动询问描述创作需求
3. /decide worker design-intake approve → /approve → …
Legacy/start-with world-simulator显式指定包
命令:
/start 开始会话

View File

@@ -0,0 +1,57 @@
import type { SkillIndexEntry, SkillStartupMode, ActiveSkillSnapshot } from "../types/runtime.js";
/** 新建作品时自动加载的能力库(用户不再选 skill 包) */
export const DEFAULT_ORCHESTRATOR_ID = "world-simulator";
/**
* 首屏引导兜底(包内 orchestrator.md `uiPrompt` 优先)。
* 词汇与 design-intake A1/A2 对齐:站位 + 系统扮演/输出/交互。
*/
export const DEFAULT_UI_PROMPT = `请用你自己的话描述想做什么——没有必填项,下面只是帮你找思路的提示。
【你扮演什么】(可对照,也可不按表)
· 单角代入:我就是一个固定角色
· 代理操控:我有角色,但常发 () 指令指挥
· 旁观/实验:我不扮演谁,看或记录推演
· 写手/统筹:我定方向,要成稿或助手式分段(长文 / 爽文也走这条)
· 多角切换:我轮流扮演不同身份
【系统要给你什么】(输出与交互,不是文风问卷)
· 回合对话:你一句,系统回一段可见结果
· 助手分段:先大纲/细纲,你再填表或改设定,再按章/段写正文
· 只要事实摘要 / 要可读叙事 / 要状态表…
【输入约定】可选用括号区分:
· () 圆括号:用户指令/要求,不可写成角色对白
· "" 双引号:角色在世界内说的话
· 【】方括号:角色在世界内的行动
未加标记时默认可视为世界内输入;语义明显是元话语按指令处理。
【核心体验】若愿意可带一句:你最想反复感到的是什么——没有也没关系,我会从描述里察觉。
示例丧尸世界但我不会被感染1v1 网恋;都市爽文先写大纲再按章开写;坠机求生;思想实验旁观三方选择……`;
export function resolveDefaultOrchestratorId(
available: SkillIndexEntry[],
): string {
if (available.some((s) => s.name === DEFAULT_ORCHESTRATOR_ID)) {
return DEFAULT_ORCHESTRATOR_ID;
}
if (available.length === 0) {
throw new Error("registry 中没有可用 orchestrator");
}
return available[0].name;
}
/** 兼容旧快照world-simulator 默认 agent-firstUI 引导 → 用户输入 → Agent 调 Skill */
export function effectiveStartupMode(
skill: Pick<ActiveSkillSnapshot, "name" | "startupMode">,
): SkillStartupMode {
if (skill.startupMode === "agent-first" || skill.startupMode === "intake") {
return skill.startupMode;
}
// 旧 frontmatter design-intake bootstrap 已废弃,等同 agent-first
if (skill.startupMode === "design-intake") return "agent-first";
if (skill.name === DEFAULT_ORCHESTRATOR_ID) return "agent-first";
return "intake";
}

View File

@@ -8,9 +8,15 @@ export type AppSettings = {
version: 1;
activeProfileId: string | null;
activePresetId: string | null;
/**
* 每个会话保留带全量上下文痕迹的消息条数(最新 N 条)。
* 更早的消息仍保留,但去掉 contextTrace 正文。默认 50 表示不存痕迹。
*/
contextTraceKeepLatest?: number;
};
const FILE_NAME = "settings.json";
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
function settingsPath(): string {
return path.join(getUserDataDir(), FILE_NAME);
@@ -21,9 +27,16 @@ function defaultSettings(): AppSettings {
version: 1,
activeProfileId: null,
activePresetId: null,
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
};
}
export function normalizeContextTraceKeepLatest(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT_TRACE_KEEP;
return Math.min(50, Math.floor(n));
}
export function loadAppSettings(): AppSettings {
ensureUserDataDirs();
try {
@@ -34,6 +47,9 @@ export function loadAppSettings(): AppSettings {
version: 1,
activeProfileId: parsed.activeProfileId ?? null,
activePresetId: parsed.activePresetId ?? null,
contextTraceKeepLatest: normalizeContextTraceKeepLatest(
parsed.contextTraceKeepLatest ?? DEFAULT_CONTEXT_TRACE_KEEP,
),
};
} catch {
return defaultSettings();

View File

@@ -1,5 +1,6 @@
import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js";
import { consumeOpenAiToolStream } from "./stream-complete.js";
export type ToolCallPayload = {
id: string;
@@ -71,21 +72,36 @@ export type CompleteWithToolsResult = {
model?: string;
};
export type StreamCallbacks = {
onReasoningDelta?: (delta: string) => void;
onContentDelta?: (delta: string) => void;
};
export type LlmProvider = {
complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult>;
completeStream?(
messages: ChatMessage[],
options?: CompleteOptions,
callbacks?: StreamCallbacks,
): Promise<CompleteResult>;
completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult>;
completeWithToolsStream?(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult>;
};
function buildRequestBody(
config: LlmConfig,
messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[] },
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
): Record<string, unknown> {
const gen = options?.generation ?? {};
const body: Record<string, unknown> = {
@@ -125,6 +141,11 @@ function buildRequestBody(
body.response_format = { type: "json_object" };
}
if (options?.stream) {
body.stream = true;
body.stream_options = { include_usage: true };
}
return body;
}
@@ -255,6 +276,44 @@ export class OpenAiCompatibleProvider implements LlmProvider {
};
}
async completeStream(
messages: ChatMessage[],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): 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, stream: true }),
),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
if (!response.body) {
throw new Error("LLM stream response has no body");
}
const parts = await consumeOpenAiToolStream(response.body, callbacks);
if (!parts.content && !parts.reasoning) {
throw new Error("LLM stream returned empty content");
}
return {
content: parts.content ?? parts.reasoning ?? "",
reasoning: parts.reasoning || undefined,
usage: parts.usage,
model: parts.model ?? this.config.model,
};
}
async completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
@@ -296,6 +355,49 @@ export class OpenAiCompatibleProvider implements LlmProvider {
model: data.model ?? this.config.model,
};
}
async completeWithToolsStream(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): 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,
stream: true,
}),
),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
if (!response.body) {
throw new Error("LLM stream response has no body");
}
const parts = await consumeOpenAiToolStream(response.body, callbacks);
if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) {
throw new Error("LLM stream returned empty content and no tool calls");
}
return {
content: parts.content,
toolCalls: parts.toolCalls,
reasoning: parts.reasoning || undefined,
usage: parts.usage,
model: parts.model ?? this.config.model,
};
}
}
export type MockLlmStep =
@@ -341,6 +443,39 @@ export class MockLlmProvider implements LlmProvider {
};
}
async completeStream(
_messages: ChatMessage[],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
const step = this.nextStep();
const response =
typeof step === "string"
? step
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
for (const ch of response) {
callbacks.onContentDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
const approx = Math.max(1, Math.ceil(response.length / 4));
return {
content: response,
reasoning,
usage: {
promptTokens: approx,
completionTokens: approx,
totalTokens: approx * 2,
},
model: "mock",
...(options?.caller ? {} : {}),
};
}
async completeWithTools(
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
@@ -366,6 +501,20 @@ export class MockLlmProvider implements LlmProvider {
model: "mock",
};
}
async completeWithToolsStream(
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const reasoning =
"用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
return this.completeWithTools(_messages, _options);
}
}
export function createMockMainAgentResponse(

View File

@@ -7,6 +7,7 @@ import type {
CompleteWithToolsOptions,
CompleteWithToolsResult,
LlmProvider,
StreamCallbacks,
} from "./client.js";
/**
@@ -55,4 +56,23 @@ export class PresetLlmProvider implements LlmProvider {
generation,
});
}
async completeWithToolsStream(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const preset = this.getPreset();
if (!this.inner.completeWithToolsStream) {
return this.completeWithTools(messages, options);
}
const presetMessages = preset ? assemblePresetMessages(preset) : [];
const merged = preset ? mergeMessages(presetMessages, messages) : messages;
const generation = options.generation ?? preset?.generation;
return this.inner.completeWithToolsStream(merged, {
...options,
generation,
}, callbacks);
}
}

149
src/llm/stream-complete.ts Normal file
View File

@@ -0,0 +1,149 @@
import type {
ChatMessage,
CompleteWithToolsOptions,
CompleteWithToolsResult,
ParsedToolCall,
StreamCallbacks,
TokenUsage,
} from "./client.js";
import { parseUsage } from "./client.js";
type ToolCallAccumulator = Map<
number,
{ id?: string; name?: string; arguments: string }
>;
function applyToolCallDelta(
acc: ToolCallAccumulator,
raw: unknown,
): void {
if (!Array.isArray(raw)) return;
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const index = Number(row.index ?? 0);
const entry = acc.get(index) ?? { arguments: "" };
if (typeof row.id === "string") entry.id = row.id;
const fn = row.function;
if (fn && typeof fn === "object") {
const f = fn as Record<string, unknown>;
if (typeof f.name === "string") entry.name = f.name;
if (typeof f.arguments === "string") entry.arguments += f.arguments;
}
acc.set(index, entry);
}
}
function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
const out: ParsedToolCall[] = [];
for (const [, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) {
const name = entry.name?.trim();
const id = entry.id?.trim();
if (!name || !id) continue;
out.push({ id, name, arguments: entry.arguments || "{}" });
}
return out;
}
/** 解析 OpenAI 兼容 SSE 流,累积 reasoning / content / tool_calls */
export async function consumeOpenAiToolStream(
body: ReadableStream<Uint8Array>,
callbacks: StreamCallbacks,
): Promise<{
content: string | null;
reasoning: string;
toolCalls: ParsedToolCall[];
usage?: TokenUsage;
model?: string;
}> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let content = "";
let reasoning = "";
const toolAcc: ToolCallAccumulator = new Map();
let usage: TokenUsage | undefined;
let model: string | undefined;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch {
continue;
}
if (typeof parsed.model === "string") model = parsed.model;
const u = parseUsage(parsed.usage);
if (u) usage = u;
const choice = (parsed.choices as unknown[])?.[0];
if (!choice || typeof choice !== "object") continue;
const delta = (choice as Record<string, unknown>).delta;
if (!delta || typeof delta !== "object") continue;
const d = delta as Record<string, unknown>;
if (typeof d.reasoning_content === "string" && d.reasoning_content) {
reasoning += d.reasoning_content;
callbacks.onReasoningDelta?.(d.reasoning_content);
}
if (typeof d.content === "string" && d.content) {
content += d.content;
callbacks.onContentDelta?.(d.content);
}
if (d.tool_calls) applyToolCallDelta(toolAcc, d.tool_calls);
}
}
return {
content: content.trim() || null,
reasoning: reasoning.trim(),
toolCalls: toolCallsFromAccumulator(toolAcc),
usage,
model,
};
}
export type StreamableLlm = {
completeWithToolsStream?(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult>;
};
export function supportsToolStream(llm: unknown): llm is StreamableLlm {
return (
typeof llm === "object" &&
llm != null &&
typeof (llm as StreamableLlm).completeWithToolsStream === "function"
);
}
export type ContentStreamableLlm = {
completeStream?(
messages: ChatMessage[],
options?: import("./client.js").CompleteOptions,
callbacks?: StreamCallbacks,
): Promise<import("./client.js").CompleteResult>;
};
export function supportsContentStream(llm: unknown): llm is ContentStreamableLlm {
return (
typeof llm === "object" &&
llm != null &&
typeof (llm as ContentStreamableLlm).completeStream === "function"
);
}

View File

@@ -4,12 +4,17 @@ import type {
CompleteWithToolsOptions,
CompleteWithToolsResult,
LlmProvider,
StreamCallbacks,
} from "./client.js";
import {
recordTokenUsage,
toMessageTokenUsage,
type MessageTokenUsage,
} from "../stats/token-store.js";
import {
buildContextTrace,
type LlmContextTrace,
} from "../types/context-trace.js";
export type LlmTrackingContext = {
sessionId?: string;
@@ -19,8 +24,23 @@ export type LlmTrackingContext = {
/** Set after each LLM call; consumed when the next system chat message is created */
pendingUsage?: MessageTokenUsage;
pendingReasoning?: string;
/** 全量请求上下文;挂到下一条「结果向」系统消息 */
pendingContextTrace?: LlmContextTrace;
};
function capturePendingTrace(
ctx: LlmTrackingContext,
messages: Array<{ role: string; content: string }>,
caller: string | undefined,
model?: string,
): void {
ctx.pendingContextTrace = buildContextTrace({
caller: caller ?? "unknown",
messages,
model,
});
}
export class TokenTrackingProvider implements LlmProvider {
constructor(
private readonly inner: LlmProvider,
@@ -33,6 +53,53 @@ export class TokenTrackingProvider implements LlmProvider {
): Promise<CompleteResult> {
const result = await this.inner.complete(messages, options);
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
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 completeStream(
messages: Parameters<LlmProvider["complete"]>[0],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
const inner = this.inner;
if (!inner.completeStream) {
const result = await this.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
let reasoningBuf = "";
const result = await inner.completeStream(messages, options, {
onReasoningDelta: (delta) => {
reasoningBuf += delta;
const ctx = this.getContext();
ctx.pendingReasoning = reasoningBuf;
callbacks.onReasoningDelta?.(delta);
},
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
if (result.usage) {
const record = recordTokenUsage({
sessionId: ctx.sessionId,
@@ -61,6 +128,50 @@ export class TokenTrackingProvider implements LlmProvider {
): Promise<CompleteWithToolsResult> {
const result = await this.inner.completeWithTools(messages, options);
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
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 completeWithToolsStream(
messages: Parameters<LlmProvider["completeWithTools"]>[0],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const inner = this.inner;
if (!inner.completeWithToolsStream) {
return this.completeWithTools(messages, options);
}
let reasoningBuf = "";
const result = await inner.completeWithToolsStream(messages, options, {
onReasoningDelta: (delta) => {
reasoningBuf += delta;
const ctx = this.getContext();
ctx.pendingReasoning = reasoningBuf;
callbacks.onReasoningDelta?.(delta);
},
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
if (result.usage) {
const record = recordTokenUsage({
sessionId: ctx.sessionId,

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { LlmProvider } from "../llm/client.js";
import { normalizeQuestions } from "../skills/question-protocol.js";
import type { BlackboardTagIndex } from "../types/blackboard.js";
import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js";
import { runMainAgentToolLoop, type ToolLoopHandlers } from "./tool-loop.js";
@@ -22,27 +23,46 @@ function buildMainAgentSystemPrompt(
? workers.map((w) => `- ${w.id}${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是写作系统的总管Main Agent。你的职责是调度 worker而不是直接创作正文。
return `你是写作系统的导演Main Agent。你的职责是调度演员(worker,而不是直接创作正文。
规则:
1. 你不能直接生成小说/文章正文。
2. 你不能修改运行状态statePatchAllowed 必须始终为 false。
3. 你只能建议下一步动作ask_user、run_worker、create_temp_worker、review_blackboard、finish。
4. 当信息不足时,使用 ask_user 向用户提问
4. 当信息不足时,使用 ask_userassessment 是主内容完备度评价questions 挂在其下且用户可跳过;一次 12 题
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。总管只在调度层提问。
9. 向用户提问是 worker 的能力ask_user tool不是独立 worker。导演只在调度层提问。
10. blackboardIndex 只有 tag 索引,不含正文 content。
## 调度思维
用需求正推:「用户需要 [具体体验/能力] → 调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」
禁止否定式路由:「某模式 / 背景形态 → 不需要某步骤 / 跳过某 worker」。
反例(禁止):「背景为单一角色,无需世界构建」「不是规则怪谈,跳过 write-rules」「默认上世界模拟套件」
正例:「用户已选世界模拟器导演且要网恋对话 → design-flow 排出近期增量步骤(可调味、可后补)→ 用户认可后反复 design-step需要再补规则/实例 → 再 design-flow 追加同能力 → 收成后若需开局 → opening-generator用户手动进 play」
当前 skill 可用 workerworkerId 必须与下列 id 完全一致):
${workerLines}
## design 优先顺序
- 尚无「设计.创作流程」→ **design-flow**(近期 horizonstatus=open未选导演则先请用户选
- 流程已验收且还有未完成步骤 → **design-step**(一次一步;能力由程序注入)
- 已列步骤都验收但流程仍 **status=open** → 再 **design-flow**(追加反复步或 closed
- 禁止调度已废弃的 design-core / design-fixed / design-worker / design-refine
- 终稿已 accept 且可用 opening-generator、尚无开场产物 → opening-generator
- 进 play 由用户手动决定
- **禁止**替用户猜测或改选导演
- **禁止**一次编排排死全程固定 DAG
输出必须是 JSON 对象,字段:
{
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
"reason": "string",
"assessment": "string | null",
"questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null,
"workerId": "string | null",
"requiresApproval": boolean,
"workerContext": { "roleId": "string" } | null
@@ -109,7 +129,7 @@ export function buildMainAgentUserPrompt(context: MainAgentContext): string {
blackboardIndex,
availableWorkers,
instruction:
"根据当前状态决定下一步。若 collecting_input 或 revision_requested优先理解用户最新输入。若 planning 且已有足够信息,建议 run_worker仅 workerId。",
"根据当前状态决定下一步。尚无设计.创作流程 → design-flow有未完成步骤 → design-stepsteps 做完但 status=open → 再 design-flow禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。",
},
null,
2,
@@ -160,10 +180,20 @@ export function parseMainAgentDecision(raw: string): MainAgentDecision {
if (roleId) workerContext = { roleId };
}
const assessmentRaw =
typeof obj.assessment === "string"
? obj.assessment.trim()
: typeof obj.message === "string"
? obj.message.trim()
: "";
const questions = normalizeQuestions(obj.questions);
return {
id: randomUUID(),
action,
reason,
assessment: assessmentRaw || undefined,
questions: questions.length ? questions : undefined,
workerId,
workerContext,
requiresApproval: Boolean(obj.requiresApproval),

View File

@@ -1,4 +1,5 @@
import type { ChatMessage, LlmProvider, ParsedToolCall } from "../llm/client.js";
import type { ChatMessage, LlmProvider, ParsedToolCall, StreamCallbacks } from "../llm/client.js";
import { supportsToolStream } from "../llm/stream-complete.js";
import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js";
import type { MainAgentDecision } from "../types/runtime.js";
import { isMainAgentTerminalTool } from "../types/tools.js";
@@ -17,6 +18,10 @@ export type ToolLoopHandlers = {
outputTags: string[];
}>;
onToolCall?: (name: string, detail: string) => void;
/** 思维链 / reasoning 流式增量(供 UI 实时展示) */
onThinkingDelta?: (delta: string) => void;
/** 一轮 LLM 调用结束后的完整思考文本 */
onThinkingDone?: (text: string) => void;
};
export type ToolLoopResult = {
@@ -37,20 +42,84 @@ function buildToolLoopSystemPrompt(
return `你是写作系统的总管Main Agent。你在 running 相位通过 **tool call** 推进流程。
规则:
## 能力边界
1. 你不能直接生成小说/文章正文。
2. 你不能修改运行状态。
3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。
4. 终止动作只能用 toolask_user、run_worker、review_blackboard、finish。
- ask_user**主内容是 assessment内容完备度评价**questions 挂在其下且可被用户跳过。
· assessment必填给用户看的评价正文。以【核心体验】为首按需列维度内容/人物关系/感官/背景规则/意义主题…),不必凑齐。每维写完备度%、已知、待探;待探用【】标互斥或可组合方向。
· questions必填数组语义可选只针对 assessment「待探」里想确认的点一次 12 题。用户可不答、直接让你基于现有信息继续。prompt=明确题干options=24 条**建议示范**required 默认 false。
· reason调度短句正推勿塞长文。
· 能推断就别问;已写在 用户.需求 里的不要重复问。
5. run_worker 只传 workerIdinputTags/outputTags 由 Runtime 从 Worker Skill 读取。
6. requiresApproval=true 时 run_worker 需用户确认后再执行。
7. run_worker 可选 roleId用于 role-decide 等指定当前决策角色
8. 不能把未验收产物当作已定事实。
7. 不能把未验收产物当作已定事实
## 调度思维(必须遵守)
用 **需求正推**,禁止 **模式否定式** 表述。
**要这样思考(正推):**
「用户需要 [具体体验/能力/产物] → 因此调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」
**不要这样思考(反例):**
「这是 xxx 模式 / 形态 → 不需要 xxx 步骤 / 跳过 xxx。」
「背景为单一角色,无需世界构建。」
「不是规则怪谈,跳过 write-rules。」
示例(好):
- 「用户已选导演且要 1v1 网恋 → design-flow 排出近期增量步骤(可调味)→ 认可后反复 design-step不够再追加。」
- 「尚无设计.创作流程 → run_worker(design-flow);有未完成步骤 → design-stepsteps 完但 status=open → 再 design-flow。」
- 「Worker 集已 accept 且声明需要开局 → 调用 opening-generator 写开场白。」
示例(坏):
- 「调度 design-core / design-fixed已废弃。」
- 「一次 design-flow 排死全程固定 DAG。」
- 「跳过流程编排直接写满 Worker 集。」
- 「默认先上世界模拟全套再问用户要什么。」
- 「替用户改选 / 猜测导演。」
在 reasoning / 可见 content 中请用 **正推句式** 写出调度理由;终止 tool 的 reason 字段同样用正推表述。
## design 阶段提示
- 尚无「设计.创作流程」→ **优先 design-flow**(近期 horizonstatus=open未选则先请用户选
- 流程已验收、还有未完成步骤 → **design-step**(一次一步)。
- 已列步骤全验收但 **status=open** → 再 **design-flow**(追加生成规则/具体实例等,或设 closed
- 禁止 run_worker(design-core|design-fixed|design-worker|design-refine)——已废弃。
- 不要重复问用户「想做什么」——意图已在 用户.需求。
- Worker 集已 accept且可用 worker 含 opening-generator尚无开场产物 → opening-generator。
- 进 play 由用户手动决定。
- 未声明开局、用户也未要求开场时,不要硬调 opening-generator。
- **禁止**替用户猜测或改选导演。
- **禁止**一次编排排死全程固定长链。
当前 skill 可用 worker
${workerLines}
信息足够前可多次调用 read_blackboard;一旦调用终止 tool本轮循环结束。`;
信息不足时可多次 read_blackboard一旦调用终止 tool本轮循环结束。`;
}
async function completeToolsPreferStream(
llm: LlmProvider,
messages: ChatMessage[],
callbacks: StreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["completeWithTools"]>>> {
if (supportsToolStream(llm)) {
return llm.completeWithToolsStream!(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
}, callbacks);
}
const result = await llm.completeWithTools(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
});
if (result.reasoning) {
callbacks.onReasoningDelta?.(result.reasoning);
}
if (result.content) {
callbacks.onContentDelta?.(result.content);
}
return result;
}
function executeLoopTool(
@@ -94,6 +163,16 @@ function assistantMessageFromToolCalls(
};
}
function emitThinkingDone(handlers: ToolLoopHandlers, parts: {
reasoning?: string;
content?: string | null;
}): void {
const reasoning = parts.reasoning?.trim() ?? "";
const content = parts.content?.trim() ?? "";
const combined = [reasoning, content].filter(Boolean).join("\n\n").trim();
if (combined) handlers.onThinkingDone?.(combined);
}
/**
* 总管 tool loop在 running 相位内可多轮调用 read_blackboard 等,
* 直到调用终止 tool 并返回 MainAgentDecision。
@@ -112,12 +191,14 @@ export async function runMainAgentToolLoop(
];
const toolTrace: string[] = [];
const streamCallbacks: StreamCallbacks = {
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
onContentDelta: (delta) => handlers.onThinkingDelta?.(delta),
};
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
const result = await llm.completeWithTools(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
});
const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
emitThinkingDone(handlers, result);
if (result.toolCalls.length === 0) {
if (result.content?.trim()) {

View File

@@ -50,14 +50,80 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
type: "function",
function: {
name: "ask_user",
description: "信息不足时向用户提问,将暂停 agent 循环等待用户输入。",
description:
"向用户追问。assessment 是给用户看的主内容完备度评价questions 挂在其下,用户可跳过并请你基于现有信息继续。将暂停等待用户。",
parameters: {
type: "object",
properties: {
reason: { type: "string", description: "为何需要用户输入" },
message: { type: "string", description: "展示给用户的问题或说明" },
reason: {
type: "string",
description:
"调度层短理由(正推:用户需要澄清 X → 因此追问)。勿把长文评价写这里。",
},
assessment: {
type: "string",
description:
"给用户看的内容完备度评价Markdown。以【核心体验】为首要按需列维度不必凑齐每维完备度%、已知、待探。待探用【】标出互斥/可组合方向。评价只服务「用户想要什么」。",
},
message: {
type: "string",
description:
"兼容旧字段:等同 assessment。优先传 assessment。",
},
questions: {
type: "array",
description:
"挂在 assessment 下的可选追问(用户可不答)。基于「待探」出题,一次 12 题。prompt=明确选择题干options=建议示范(可改写后采用)。",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "稳定 id如 q1 / stance",
},
prompt: {
type: "string",
description:
"题干:引导用户选定一种详细体验倾向(例:「你更倾向于哪种皇帝的享受?」)。",
},
options: {
type: "array",
description:
"24 个建议示范。label 写完整可采纳文案(可先一句场景钩子再点题),不是标签词。",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "A/B/C…",
},
label: {
type: "string",
description:
"建议正文:用户点选后可直接当答案,也可改写。例:「冰冷的、主宰一切的权力感——九重宫阙一言定生死…」",
},
editable: {
type: "boolean",
description: "默认 true点文案可改写点字母才选中",
},
},
required: ["label"],
},
},
allowOther: {
type: "boolean",
description: "默认 true允许 Other 自拟",
},
required: {
type: "boolean",
description: "默认 false可选追问仅关键阻塞题才 true",
},
},
required: ["prompt", "options"],
},
},
},
required: ["reason"],
required: ["reason", "assessment", "questions"],
additionalProperties: false,
},
},

View File

@@ -1,46 +1,167 @@
import type { PresetPackage, PresetPromptRole } from "../types/preset.js";
import type {
PresetPackage,
PresetPromptEntry,
PresetPromptRole,
} from "../types/preset.js";
import { savePreset } from "./store.js";
export type PresetEnabledEntryView = {
export type PresetEntryView = {
orderIndex: number;
id: string;
name: string;
role: PresetPromptRole;
marker: boolean;
content: string;
/** 实际会注入 LLM 请求(有非空 content */
/** prompt_order 与条目自身均启用 */
enabled: boolean;
orderEnabled: boolean;
entryEnabled: boolean;
/** 启用且有非空 content → 会注入 LLM */
willInject: boolean;
};
/** 按 prompt_order 列出所有启用条目及其内容 */
export function listEnabledPresetEntries(
preset: PresetPackage,
): PresetEnabledEntryView[] {
/** @deprecated 名称保留:现为全部条目;过滤启用请用 filter */
export type PresetEnabledEntryView = PresetEntryView;
function sortOrder(preset: PresetPackage) {
return [...preset.promptOrder].sort((a, b) => a.orderIndex - b.orderIndex);
}
function toView(
orderIndex: number,
entry: PresetPromptEntry,
orderEnabled: boolean,
): PresetEntryView {
const content = entry.content ?? "";
const enabled = orderEnabled && entry.enabled;
const trimmed = content.trim();
return {
orderIndex,
id: entry.id,
name: entry.name,
role: entry.role,
marker: entry.marker,
content,
enabled,
orderEnabled,
entryEnabled: entry.enabled,
willInject: enabled && trimmed.length > 0,
};
}
/** 按 prompt_order 列出全部条目(含未启用),便于设置页编辑 */
export function listAllPresetEntries(preset: PresetPackage): PresetEntryView[] {
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[] = [];
const seen = new Set<string>();
const entries: PresetEntryView[] = [];
for (const orderItem of ordered) {
if (!orderItem.enabled) continue;
for (const orderItem of sortOrder(preset)) {
const entry = promptById.get(orderItem.promptId);
if (!entry || !entry.enabled) continue;
if (!entry) continue;
seen.add(entry.id);
entries.push(toView(orderItem.orderIndex, entry, orderItem.enabled));
}
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,
});
// 未进 order 的条目附在末尾
let nextIndex =
entries.reduce((m, e) => Math.max(m, e.orderIndex), -1) + 1;
for (const entry of preset.prompts) {
if (seen.has(entry.id)) continue;
entries.push(toView(nextIndex++, entry, false));
}
return entries;
}
export function countInjectingEntries(entries: PresetEnabledEntryView[]): number {
/** 仅启用中的条目(装配 / 旧 API 兼容) */
export function listEnabledPresetEntries(
preset: PresetPackage,
): PresetEntryView[] {
return listAllPresetEntries(preset).filter((e) => e.enabled);
}
export function countInjectingEntries(
entries: Array<{ willInject: boolean }>,
): number {
return entries.filter((e) => e.willInject).length;
}
export function countEnabledEntries(
entries: Array<{ enabled: boolean }>,
): number {
return entries.filter((e) => e.enabled).length;
}
export type PresetEntryPatch = {
id: string;
enabled?: boolean;
content?: string;
name?: string;
role?: PresetPromptRole;
};
/**
* 纯函数更新(不写盘)。启用状态同步 order + entry。
*/
export function applyPresetEntryPatches(
preset: PresetPackage,
patches: PresetEntryPatch[],
): PresetPackage {
if (!patches.length) return preset;
const prompts = preset.prompts.map((p) => ({ ...p }));
const promptById = new Map(prompts.map((p) => [p.id, p]));
let order = preset.promptOrder.map((o) => ({ ...o }));
for (const patch of patches) {
const entry = promptById.get(patch.id);
if (!entry) {
throw new Error(`条目不存在: ${patch.id}`);
}
if (typeof patch.content === "string") {
entry.content = patch.content;
if (entry.content.trim()) entry.marker = false;
}
if (typeof patch.name === "string" && patch.name.trim()) {
entry.name = patch.name.trim();
}
if (
patch.role === "system" ||
patch.role === "user" ||
patch.role === "assistant"
) {
entry.role = patch.role;
}
if (typeof patch.enabled === "boolean") {
entry.enabled = patch.enabled;
const orderItem = order.find((o) => o.promptId === patch.id);
if (orderItem) {
orderItem.enabled = patch.enabled;
} else if (patch.enabled) {
const orderIndex =
order.reduce((m, o) => Math.max(m, o.orderIndex), -1) + 1;
order.push({
promptId: patch.id,
enabled: true,
orderIndex,
});
}
}
}
return {
...preset,
prompts,
promptOrder: order,
};
}
/**
* 更新一条或多条并落盘。
*/
export function patchPresetEntries(
preset: PresetPackage,
patches: PresetEntryPatch[],
): PresetPackage {
return savePreset(applyPresetEntryPatches(preset, patches));
}

View File

@@ -0,0 +1,235 @@
import type { Blackboard } from "../blackboard/blackboard.js";
/** 下一 worker / 总管可读的定稿摘要 tag */
export const CONTEXT_BRIEF_TAG = "上下文.定稿摘要";
export type CompressWorkerResult = {
archivedTags: string[];
finals: Array<{ tag: string; chars: number; preview: string }>;
briefText: string;
};
const ALWAYS_ARCHIVE_AFTER_ACCEPT = ["用户.worker答复", "用户.修订说明"];
/**
* Worker 产物经用户验收(或自动完工)后:
* - 终产物打 final
* - 过程/草稿 tag 归档(不再进入后续 worker 取数)
* - 写入定稿摘要,供下一阶段「指点」用
*/
export function compressAfterWorkerAccept(params: {
blackboard: Blackboard;
workerId: string;
outputTags: string[];
summary?: string;
/**
* final = 终稿验收(归档草稿、写定稿摘要)
* unit = 创作单位验收(只归档过程答复,保留 设计.worker集.草稿)
*/
mode?: "final" | "unit";
}): CompressWorkerResult {
const { blackboard, workerId, outputTags, summary } = params;
const mode = params.mode ?? "final";
const finals: CompressWorkerResult["finals"] = [];
const archivedTags: string[] = [];
const uniqueOutputs = [...new Set(outputTags.map((t) => t.trim()).filter(Boolean))];
if (mode === "final") {
for (const tag of uniqueOutputs) {
const content = blackboard.getContentByTag(tag);
if (content == null) continue;
blackboard.write({
tag,
content,
source: workerId,
metadata: {
role: "final",
acceptedAt: new Date().toISOString(),
workerId,
},
});
finals.push({
tag,
chars: content.length,
preview: previewText(content, 120),
});
}
} else {
// 单位验收:草稿保持活跃,终产物列表仅作面板提示
for (const tag of uniqueOutputs) {
if (tag === "用户.需求" || tag === "创作.当前单位") continue;
const content = blackboard.getContentByTag(tag);
if (content == null) continue;
finals.push({
tag,
chars: content.length,
preview: previewText(content, 120),
});
}
}
const toArchive = new Set<string>(ALWAYS_ARCHIVE_AFTER_ACCEPT);
if (mode === "final") {
if (blackboard.getContentByTag("设计.worker集")?.trim()) {
toArchive.add("设计.worker集.草稿");
}
for (const tag of uniqueOutputs) {
if (tag.endsWith(".草稿")) continue;
const draft = `${tag}.草稿`;
if (blackboard.getContentByTag(draft) != null) toArchive.add(draft);
}
}
for (const tag of toArchive) {
if (uniqueOutputs.includes(tag) && mode === "final") continue;
if (mode === "unit" && tag.endsWith(".草稿")) continue;
const content = blackboard.getContentByTag(tag);
if (content == null) continue;
blackboard.write({
tag,
content: `(已压缩归档)原过程内容已折叠。定稿见:${
finals.map((f) => f.tag).join("、") || "(无)"
}\n\n---\n${previewText(content, 400)}`,
source: "compress",
metadata: {
role: "archived",
archivedAt: new Date().toISOString(),
fromWorker: workerId,
compressMode: mode,
},
});
archivedTags.push(tag);
}
const briefText =
mode === "unit"
? buildUnitBriefText({ workerId, summary, finals, archivedTags })
: buildBriefText({
workerId,
summary,
finals,
archivedTags,
});
if (mode === "final") {
blackboard.write({
tag: CONTEXT_BRIEF_TAG,
content: briefText,
source: "compress",
metadata: { role: "final", workerId },
});
}
return { archivedTags, finals, briefText };
}
function buildUnitBriefText(params: {
workerId: string;
summary?: string;
finals: CompressWorkerResult["finals"];
archivedTags: string[];
}): string {
return [
`## 创作单位已验收(${params.workerId}`,
"",
params.summary?.trim() ? `摘要:${params.summary.trim()}` : "摘要:(无)",
"",
"草稿仍保留在 `设计.worker集.草稿`;下一单位继续增量,勿依赖已删的过程对话。",
].join("\n");
}
function buildBriefText(params: {
workerId: string;
summary?: string;
finals: CompressWorkerResult["finals"];
archivedTags: string[];
}): string {
const lines = [
`## 上一阶段定稿(${params.workerId}`,
"",
params.summary?.trim()
? `摘要:${params.summary.trim()}`
: "摘要:(无)",
"",
"### 保留的终产物 tag",
];
if (params.finals.length === 0) {
lines.push("- (无)");
} else {
for (const f of params.finals) {
lines.push(`- \`${f.tag}\`${f.chars} 字)`);
lines.push(` ${f.preview}`);
}
}
if (params.archivedTags.length) {
lines.push("", "### 已压缩的过程 tag");
for (const t of params.archivedTags) {
lines.push(`- \`${t}\`(归档,后续 worker 默认不读)`);
}
}
lines.push(
"",
"下一 worker 应以以上终产物为准继续;勿依赖已归档的过程讨论。",
);
return lines.join("\n");
}
export function previewText(content: string, max: number): string {
const t = content.replace(/\s+/g, " ").trim();
if (t.length <= max) return t;
return `${t.slice(0, max)}`;
}
/** 供 UI黑板可读条目默认隐藏 archived 全文,只给索引) */
export function buildBoardPanel(blackboard: Blackboard): {
finals: Array<{ tag: string; source: string; preview: string; updatedAt: string }>;
active: Array<{ tag: string; source: string; preview: string; updatedAt: string }>;
archivedCount: number;
brief?: string;
} {
const items = latestItemsByTag(blackboard.exportItems());
const finals: Array<{
tag: string;
source: string;
preview: string;
updatedAt: string;
}> = [];
const active: typeof finals = [];
let archivedCount = 0;
let brief: string | undefined;
for (const item of items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))) {
const role = String(item.metadata?.role ?? "");
if (item.tag === CONTEXT_BRIEF_TAG) {
brief = item.content;
continue;
}
if (role === "archived") {
archivedCount += 1;
continue;
}
const row = {
tag: item.tag,
source: item.source,
preview: previewText(item.content, 160),
updatedAt: item.updatedAt,
};
if (role === "final") finals.push(row);
else active.push(row);
}
return { finals, active, archivedCount, brief };
}
function latestItemsByTag(
items: import("../types/blackboard.js").BlackboardItem[],
): import("../types/blackboard.js").BlackboardItem[] {
const byTag = new Map<string, (typeof items)[0]>();
for (const item of items) {
const prev = byTag.get(item.tag);
if (!prev || item.updatedAt > prev.updatedAt) byTag.set(item.tag, item);
}
return [...byTag.values()];
}

View File

@@ -60,7 +60,7 @@ export class RuntimeOrchestrator {
payload: {
presetId: this.session.presetId,
flowId: this.session.flowId,
availableSkills: [{ name: "basic", description: "fallback", category: "novel" }],
availableSkills: [{ name: "world-simulator", description: "fallback", category: "dialogue" }],
},
});
return this.session;

View File

@@ -19,22 +19,56 @@ import type {
WaitingReason,
} from "../types/runtime.js";
import type { ActiveSkillSnapshot } from "../types/runtime.js";
import { effectiveStartupMode } from "../config/default-orchestrator.js";
import {
buildIntakeProgress,
buildIntakeFollowUpMessage,
readIntakeValues,
synthesizeDemandText,
} from "../intake/intake.js";
import { normalizeQuestions } from "../skills/question-protocol.js";
import type { QuestionItem } from "../types/questions.js";
function nowIso(): string {
return new Date().toISOString();
}
/** 挂在产物下的追问一律可选required=false用户可直接 Accept */
function asOptionalSidecarQuestions(
raw: QuestionItem[] | string[] | undefined,
): QuestionItem[] | undefined {
const normalized = normalizeQuestions(raw ?? []);
if (!normalized.length) return undefined;
return normalized.map((q) => ({ ...q, required: false }));
}
/** 更新 updatedAt 时间戳 */
function touch(session: RuntimeSession): RuntimeSession {
return { ...session, updatedAt: nowIso() };
}
/** 用户每条输入写入 用户.最新输入;首句同时写入需求 tag */
function applyUserTextToSlots(
slots: Record<string, unknown>,
text: string,
demandKey: string,
opts: { initial?: boolean },
): void {
if (!text) return;
slots["用户.最新输入"] = text;
if (opts.initial) {
slots[demandKey] = text;
slots.startupCompleted = true;
return;
}
if (!slots.startupCompleted) {
slots[demandKey] = text;
slots.startupCompleted = true;
return;
}
mergeSlotText(slots, demandKey, text);
}
/** 将用户补充文本追加到 slot用于合并多轮 ask_user / worker 答复到需求 tag */
function mergeSlotText(
slots: Record<string, unknown>,
@@ -136,6 +170,7 @@ export function getAllowedEvents(
"user_accepted_artifact",
"user_rejected_artifact",
"user_requested_revision",
"user_resolved_sidecar_questions",
"main_agent_decision_created",
"runtime_failed",
];
@@ -179,6 +214,11 @@ export function canApplyEvent(
case "user_accepted_artifact":
case "user_rejected_artifact":
return reason?.kind === "review_artifact";
case "user_resolved_sidecar_questions":
return (
reason?.kind === "review_artifact" &&
Boolean(reason.questions?.length)
);
case "user_requested_revision":
return (
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
@@ -272,7 +312,7 @@ export function applyEvent(
}
switch (event.type) {
// ── 启动:选 skill ──
// ── 启动:默认 orchestrator 或 legacy 选包 ──
case "session_started": {
const next = appendHistory(
{
@@ -283,40 +323,22 @@ export function applyEvent(
event,
);
const skills = event.payload.availableSkills;
const initialSkill = event.payload.initialSkill;
if (initialSkill) {
return applySkillBinding(next, initialSkill, event);
}
return {
session: waiting(next, {
kind: "skill_selection",
availableSkills: skills,
}),
effects: [
{
type: "emit_message",
message: formatSkillSelectionPrompt(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 }],
};
return applySkillBinding(session, skill, event);
}
case "user_confirmed_intake": {
@@ -366,6 +388,21 @@ export function applyEvent(
if (session.waitingReason?.kind === "intake") {
const skill = activeSkill as ActiveSkillSnapshot | undefined;
if (skill && effectiveStartupMode(skill) === "agent-first" && text) {
const key = skill.startupTargetKey || "用户.需求";
applyUserTextToSlots(slots, text, key, { initial: true });
slots.lastUserInput = text;
const next = appendHistory(
{ ...session, slots, resumeContext: undefined },
event,
);
return {
session: touch(running(next)),
effects: [{ type: "invoke_main_agent" }],
};
}
const intakeValues =
event.payload.intakeValues ??
readIntakeValues(session.slots);
@@ -419,16 +456,17 @@ export function applyEvent(
if (session.waitingReason?.kind === "worker_questions") {
slots["用户.worker答复"] = text;
if (text) slots["用户.最新输入"] = 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);
}
} else if (session.waitingReason?.kind === "input" && text) {
const key = demandKey || "用户.需求";
applyUserTextToSlots(slots, text, key, {
initial: !session.slots.startupCompleted,
});
} else if (text) {
slots["用户.最新输入"] = text;
}
const next = appendHistory(
{
@@ -465,14 +503,44 @@ export function applyEvent(
switch (decision.action) {
case "ask_user":
case "review_blackboard":
case "review_blackboard": {
const questions = decision.questions?.length
? decision.questions.map((q) => ({ ...q, required: false }))
: undefined;
const assessment =
decision.action === "ask_user"
? decision.assessment?.trim() || undefined
: undefined;
const effects: PhaseEffect[] = [];
if (assessment) {
effects.push({
type: "emit_message",
message: `[Agent] 内容评价:\n${assessment}`,
});
}
if (questions?.length) {
effects.push({
type: "emit_message",
message: `[Agent] 可选追问(可跳过):\n${questions.map((q) => `- ${q.prompt}`).join("\n")}`,
});
}
const inputMessage = assessment
? assessment
: decision.action === "review_blackboard" || !questions?.length
? decision.reason
: undefined;
return {
session: waiting(
{ ...next, pendingDecision: undefined },
{ kind: "input", message: decision.reason },
{
kind: "input",
message: inputMessage,
questions,
},
),
effects: [],
effects,
};
}
case "finish":
return {
session: touch({
@@ -580,13 +648,12 @@ export function applyEvent(
}
case "worker_needs_input": {
const questions = event.payload.questions
.map((q) => q.trim())
.filter(Boolean);
const normalized =
questions.length > 0
? questions
: ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
let normalized = normalizeQuestions(event.payload.questions);
if (normalized.length === 0) {
normalized = normalizeQuestions([
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。",
]);
}
const ctx: ResumeContext = {
workerId: event.payload.workerId,
stepId: event.payload.stepId ?? session.currentStepId,
@@ -608,7 +675,7 @@ export function applyEvent(
effects: [
{
type: "emit_message",
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q}`).join("\n")}`,
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`,
},
],
};
@@ -618,23 +685,64 @@ export function applyEvent(
const result = handleWorkerCompleted(session, event);
const mode = session.acceptanceMode ?? "user_confirmed";
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
const sidecar = asOptionalSidecarQuestions(event.payload.questions);
const effects = [...result.effects];
if (sidecar?.length) {
effects.push({
type: "emit_message",
message: `[Worker] 可选追问(可跳过,直接接受目前产物):\n${sidecar.map((q) => `- ${q.prompt}`).join("\n")}`,
});
}
return {
...result,
effects,
session: waiting(result.session, {
kind: "review_artifact",
artifactId: result.session.pendingArtifactId,
questions: sidecar,
}),
};
}
return result;
}
case "user_resolved_sidecar_questions": {
const reason = session.waitingReason;
if (reason?.kind !== "review_artifact") {
return fail(session, "sidecar questions require review_artifact");
}
const answersText = event.payload.answersText?.trim();
const slots: Record<string, unknown> = { ...session.slots };
const effects: PhaseEffect[] = [];
if (answersText) {
slots["用户.worker答复"] = answersText;
slots["用户.最新输入"] = answersText;
effects.push({
type: "emit_message",
message: `[用户] 已补充可选追问(产物仍待验收)`,
});
}
const next = appendHistory({ ...session, slots }, event);
return {
session: waiting(touch(next), {
kind: "review_artifact",
artifactId: reason.artifactId,
}),
effects,
};
}
// ── 产物验收 ──
case "user_accepted_artifact": {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const slots: Record<string, unknown> = { ...session.slots };
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
if (artifact.outputTags.some((tag) => tag === "设计.worker集")) {
slots.designInstanceReady = true;
}
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "accepted" }),
event,
@@ -643,6 +751,7 @@ export function applyEvent(
session: touch(
running({
...next,
slots,
pendingArtifactId: undefined,
pendingDecision: undefined,
}),
@@ -791,3 +900,62 @@ function formatSkillSelectionPrompt(
const lines = skills.map((s, i) => ` ${i + 1}. ${s.name}${s.description}`);
return ["请选择创作 skill输入 name 或编号):", ...lines].join("\n");
}
/** agent-first仅 UI 引导,等用户首句后再 invoke 总管 */
function enterAwaitFirstInput(
session: RuntimeSession,
skill: ActiveSkillSnapshot,
event: RuntimeEvent,
): ApplyEventResult {
const next = appendHistory(
{
...session,
flowId: skill.defaultFlowId ?? session.flowId,
slots: {
...session.slots,
activeSkill: skill,
},
},
event,
);
return {
session: waiting(next, { kind: "input" }),
effects: [],
};
}
function enterIntakeWaiting(
session: RuntimeSession,
skill: ActiveSkillSnapshot,
event: RuntimeEvent,
): ApplyEventResult {
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 }],
};
}
function applySkillBinding(
session: RuntimeSession,
skill: ActiveSkillSnapshot,
event: RuntimeEvent,
): ApplyEventResult {
if (effectiveStartupMode(skill) === "agent-first") {
return enterAwaitFirstInput(session, skill, event);
}
return enterIntakeWaiting(session, skill, event);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { ParsedToolCall } from "../llm/client.js";
import type { MainAgentDecision } from "../types/runtime.js";
import { normalizeQuestions } from "../skills/question-protocol.js";
import {
isMainAgentLoopTool,
isMainAgentTerminalTool,
@@ -60,11 +61,15 @@ export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision {
switch (name as MainAgentToolName) {
case "ask_user": {
const reason = requireString(args, "reason");
const message = optionalString(args, "message");
const assessment =
optionalString(args, "assessment") ?? optionalString(args, "message");
const questions = normalizeQuestions(args.questions);
return {
id: randomUUID(),
action: "ask_user",
reason: message ? `${reason}\n${message}` : reason,
reason,
assessment: assessment || undefined,
questions: questions.length ? questions : undefined,
requiresApproval: false,
statePatchAllowed: false,
};

View File

@@ -8,11 +8,18 @@ import {
type LifecycleStage,
type SkillCatalogEntry,
} from "./skill-catalog.js";
import {
displayWorkerLabel,
formatAgentDisplayTitle,
formatWorkerDisplayTitle,
} from "./display-labels.js";
export type AgentMessageKind =
| "user_input"
| "orchestrator_decision"
| "orchestrator_thinking"
| "orchestrator_prompt"
| "orchestrator_assessment"
| "agent_tool"
| "worker_running"
| "worker_output"
@@ -77,18 +84,29 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
return {
kind: "agent_tool",
actor: "orchestrator",
title: `Tool · ${name}`,
title: `工具 · ${name}`,
body: detail || "(无输出)",
text: trimmed,
};
}
const agentThink = trimmed.match(/^\[总管 思考\]\s*\n?\n?([\s\S]*)$/);
if (agentThink) {
return {
kind: "orchestrator_thinking",
actor: "orchestrator",
title: formatAgentDisplayTitle("思考"),
body: agentThink[1]?.trim() || "(无内容)",
text: trimmed,
};
}
const orchestrator = trimmed.match(/^\[总管\]\s*(\w+):\s*([\s\S]+)$/);
if (orchestrator) {
return {
kind: "orchestrator_decision",
actor: "orchestrator",
title: `Agent · ${orchestrator[1]}`,
title: formatAgentDisplayTitle(orchestrator[1]),
body: orchestrator[2].trim(),
text: trimmed,
};
@@ -99,8 +117,30 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
return {
kind: "worker_running",
actor: workerRunning[1],
title: `Worker · ${workerRunning[1]}`,
body: "正在调用模型执行 SKILL…",
title: formatWorkerDisplayTitle(workerRunning[1], "running"),
body: "正在调用模型执行…",
text: trimmed,
};
}
const compressed = trimmed.match(/^\[上下文已压缩\]\s*([\s\S]+)$/);
if (compressed) {
return {
kind: "system_info",
actor: "system",
title: "上下文已压缩",
body: compressed[1].trim(),
text: trimmed,
};
}
const unitAccepted = trimmed.match(/^\[创作单位已验收\]\s*([\s\S]+)$/);
if (unitAccepted) {
return {
kind: "system_info",
actor: "system",
title: "创作单位已验收",
body: unitAccepted[1].trim(),
text: trimmed,
};
}
@@ -110,7 +150,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
return {
kind: "worker_output",
actor: workerDone[1],
title: `Worker · ${workerDone[1]} 产出`,
title: formatWorkerDisplayTitle(workerDone[1], "output"),
body: workerDone[2]?.trim() || "(无正文)",
text: trimmed,
};
@@ -121,7 +161,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
return {
kind: "worker_stub",
actor: stub?.[1],
title: `占位 Worker · ${stub?.[1] ?? "?"}`,
title: formatWorkerDisplayTitle(stub?.[1], "stub"),
body: trimmed,
text: trimmed,
};
@@ -135,7 +175,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
return {
kind: "worker_questions",
actor: workerAskTagged[1],
title: `Worker · ${workerAskTagged[1]} 提问`,
title: formatWorkerDisplayTitle(workerAskTagged[1], "questions"),
body,
text: trimmed,
};
@@ -170,6 +210,44 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
};
}
if (trimmed.startsWith("[Agent] 内容评价")) {
return {
kind: "orchestrator_assessment",
actor: "orchestrator",
title: "总管 · 内容评价",
body: trimmed.replace(/^\[Agent\]\s*内容评价[:]\s*/, "").trim() || trimmed,
text: trimmed,
};
}
if (trimmed.startsWith("[Agent] 提问") || trimmed.startsWith("[Agent] 可选追问")) {
return {
kind: "worker_questions",
actor: "orchestrator",
title: "总管 · 可选追问",
body: formatWorkerQuestionBody(
trimmed
.replace(/^\[Agent\]\s*可选追问(可跳过)[:]\s*/, "")
.replace(/^\[Agent\]\s*提问[:]\s*/, ""),
),
text: trimmed,
};
}
if (trimmed.match(/^\[Worker\]\s*可选追问/)) {
return {
kind: "worker_questions",
title: "可选追问",
body: formatWorkerQuestionBody(
trimmed.replace(
/^\[Worker\]\s*可选追问(可跳过,直接接受(?:目前)?产物)[:]\s*/,
"",
),
),
text: trimmed,
};
}
if (
trimmed.includes("请告诉我") ||
trimmed.includes("启动询问") ||
@@ -178,7 +256,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
return {
kind: "orchestrator_prompt",
actor: "orchestrator",
title: "Agent · 启动询问",
title: "总管 · 启动询问",
body: trimmed,
text: trimmed,
};
@@ -268,16 +346,37 @@ export function buildFocus(
const detail =
intake && intake.requiredTotal > 0
? `必要项 ${intake.requiredFilled}/${intake.requiredTotal}`
: "完成必要项后可进入实例化";
: "总管将根据描述推理 Worker 集";
return {
actorType: "user",
actorLabel: "你",
action: "填写创作信息",
action: "描述创作需求",
detail,
};
}
if (reason?.kind === "input" && !session.slots.startupCompleted) {
return {
actorType: "user",
actorLabel: "你",
action: "描述创作需求",
detail: "发送后总管将开始:创作 · 核心",
};
}
if (reason?.kind === "input") {
if (reason.questions?.length) {
const q = reason.questions
.map((item) => item.prompt)
.filter((s) => s?.trim())
.join("");
return {
actorType: "user",
actorLabel: "你",
action: "回答追问",
detail: q.slice(0, 200) || reason.message,
};
}
return {
actorType: "user",
actorLabel: "你",
@@ -291,30 +390,37 @@ export function buildFocus(
return {
actorType: "orchestrator",
actorId: "orchestrator",
actorLabel: "Agent",
action: `建议 invoke ${worker}`,
actorLabel: "总管",
action: `建议调用 ${displayWorkerLabel(worker)}`,
detail: session.pendingDecision?.reason,
};
}
if (reason?.kind === "worker_questions") {
const q = reason.questions?.filter((s) => s?.trim()).join("") ?? "";
const q =
reason.questions
?.map((item) => item.prompt)
.filter((s) => s?.trim())
.join("") ?? "";
return {
actorType: "user",
actorId: reason.workerId,
actorLabel: "你",
action: `回答 · ${reason.workerId}`,
detail: q.slice(0, 200) || "请在下框补充",
action: `回答 · ${displayWorkerLabel(reason.workerId)}`,
detail: q.slice(0, 200) || "请在询问卡作答",
};
}
if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
const optionalQs = reason.questions?.length
? `;另有 ${reason.questions.length} 道可选追问`
: "";
return {
actorType: "user",
actorLabel: "你",
action: "验收产物",
detail: art?.summary ?? art?.workerId,
detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`,
};
}
@@ -322,9 +428,9 @@ export function buildFocus(
return {
actorType: "worker",
actorId: session.currentWorkerId,
actorLabel: `Skill · ${session.currentWorkerId}`,
actorLabel: displayWorkerLabel(session.currentWorkerId),
action: "执行中",
detail: "模型按 SKILL 产出…",
detail: "模型正在产出…",
};
}
@@ -332,9 +438,9 @@ export function buildFocus(
return {
actorType: "orchestrator",
actorId: "orchestrator",
actorLabel: "Agent",
action: stage === "design" ? "设计 burst" : "游玩 burst",
detail: "tool loop读黑板 → 选 skill",
actorLabel: "总管",
action: stage === "design" ? "创作调度" : "游玩调度",
detail: "读黑板 → 选下一步",
};
}
@@ -342,7 +448,8 @@ export function buildFocus(
return {
actorType: "user",
actorLabel: "你",
action: "选择 Skill 包",
action: "恢复中的旧会话",
detail: "请发送任意消息继续,或联系维护者",
};
}

View File

@@ -1,190 +1,559 @@
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) {
import type { IncomingMessage, ServerResponse } from "node:http";
import { listSkills } from "../skills/loader.js";
import { createBook, deleteBook, duplicateBook, 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));
}
type ModuleStatus = "ready" | "partial" | "skeleton" | "missing";
function moduleStatusFromSections(
hasPrompt: boolean,
present: string[],
taskBody?: string,
): ModuleStatus {
if (!hasPrompt) return "missing";
const hasTask = present.includes("task");
const hasOutput = present.includes("output");
if (!hasTask || !hasOutput) return "skeleton";
const task = taskBody?.trim() ?? "";
const stub =
!task ||
/待作者细写|待完善|(待/.test(task) ||
task.length < 120;
if (stub) return "skeleton";
const depth = ["principles", "probe", "checklist", "examples"].filter((id) =>
present.includes(id),
).length;
return depth >= 2 ? "ready" : "partial";
}
async function loadModulesPayload(skillId: string): Promise<{
skillPackId: string;
modules: Array<{
id: string;
name: string;
declaration: string;
artifact: string;
hasPrompt: boolean;
sectionsPresent: string[];
status: ModuleStatus;
}>;
}> {
const { loadSkill } = await import("../skills/loader.js");
const {
loadModuleCatalog,
loadModulePrompt,
parseModulePromptSections,
MODULE_SECTION_IDS,
} = await import("../skills/creation-flow.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
return { skillPackId: skillId, modules: [] };
}
const catalog = await loadModuleCatalog(skill.skillPackRoot);
const modules = [];
for (const m of catalog?.modules ?? []) {
const prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
const sections = prompt
? parseModulePromptSections(prompt)
: { raw: "", blocks: {} };
const sectionsPresent = MODULE_SECTION_IDS.filter((id) =>
Boolean(sections.blocks[id]?.trim()),
);
const hasPrompt = Boolean(prompt?.trim());
const taskBody = sections.blocks.task?.trim();
modules.push({
id: m.id,
name: m.name,
declaration: m.declaration,
artifact: m.artifact,
hasPrompt,
sectionsPresent: [...sectionsPresent],
status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody),
});
}
return { skillPackId: skillId, modules };
}
async function loadModuleDetailPayload(
skillId: string,
moduleId: string,
): Promise<{
skillPackId: string;
id: string;
name: string;
declaration: string;
artifact: string;
hasPrompt: boolean;
sectionsPresent: string[];
status: ModuleStatus;
sections: Record<string, string>;
meta: Record<string, unknown> | null;
raw: string;
} | null> {
const { loadSkill } = await import("../skills/loader.js");
const {
loadModuleCatalog,
loadModulePrompt,
parseModulePromptSections,
MODULE_SECTION_IDS,
} = await import("../skills/creation-flow.js");
const { parse: parseYaml } = await import("yaml");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) return null;
const catalog = await loadModuleCatalog(skill.skillPackRoot);
const entry = catalog?.modules.find((m) => m.id === moduleId) ?? null;
if (!entry) return null;
const prompt = await loadModulePrompt(skill.skillPackRoot, moduleId);
const parsed = prompt
? parseModulePromptSections(prompt)
: { raw: "", blocks: {} };
const sections: Record<string, string> = {};
for (const id of MODULE_SECTION_IDS) {
const body = parsed.blocks[id]?.trim();
if (body) sections[id] = body;
}
const sectionsPresent = Object.keys(sections);
const hasPrompt = Boolean(prompt?.trim());
let meta: Record<string, unknown> | null = null;
const metaRaw = sections.meta;
if (metaRaw) {
try {
const doc = parseYaml(metaRaw);
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
meta = doc as Record<string, unknown>;
}
} catch {
meta = null;
}
}
return {
skillPackId: skillId,
id: entry.id,
name: entry.name,
declaration: entry.declaration,
artifact: entry.artifact,
hasPrompt,
sectionsPresent,
status: moduleStatusFromSections(
hasPrompt,
sectionsPresent,
sections.task,
),
sections,
meta,
raw: prompt ?? "",
};
}
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;
}
/** 新建作品:导演一层选型(内部 = recipes catalog */
if (pathname === "/api/directors" && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
);
const skillId = DEFAULT_ORCHESTRATOR_ID;
try {
const { loadSkill } = await import("../skills/loader.js");
const { loadRecipeCatalog } = await import("../skills/creation-flow.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
json(res, 200, { directors: [], skillPackId: skillId });
return true;
}
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
json(res, 200, {
skillPackId: skillId,
directors: (catalog?.recipes ?? []).map((r) => ({
id: r.id,
name: r.name,
declaration: r.declaration,
})),
});
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到导演列表",
});
}
return true;
}
const recipesMatch = pathname.match(/^\/api\/skills\/([^/]+)\/recipes$/);
if (recipesMatch && req.method === "GET") {
const skillId = decodeURIComponent(recipesMatch[1]);
try {
const { loadSkill } = await import("../skills/loader.js");
const { loadRecipeCatalog } = await import("../skills/creation-flow.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
json(res, 200, { recipes: [] });
return true;
}
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
json(res, 200, {
recipes: (catalog?.recipes ?? []).map((r) => ({
id: r.id,
name: r.name,
declaration: r.declaration,
})),
});
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力包",
});
}
return true;
}
/** 默认导演包的能力池(编排备选) */
if (pathname === "/api/modules" && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
);
try {
const payload = await loadModulesPayload(DEFAULT_ORCHESTRATOR_ID);
json(res, 200, payload);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力目录",
});
}
return true;
}
const moduleDetailMatch = pathname.match(/^\/api\/modules\/([^/]+)$/);
if (moduleDetailMatch && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
);
const moduleId = decodeURIComponent(moduleDetailMatch[1]);
try {
const detail = await loadModuleDetailPayload(
DEFAULT_ORCHESTRATOR_ID,
moduleId,
);
if (!detail) {
json(res, 404, { error: `未找到能力:${moduleId}` });
return true;
}
json(res, 200, detail);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力",
});
}
return true;
}
const skillModulesMatch = pathname.match(
/^\/api\/skills\/([^/]+)\/modules$/,
);
if (skillModulesMatch && req.method === "GET") {
const skillId = decodeURIComponent(skillModulesMatch[1]);
try {
const payload = await loadModulesPayload(skillId);
json(res, 200, payload);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力目录",
});
}
return true;
}
const skillModuleDetailMatch = pathname.match(
/^\/api\/skills\/([^/]+)\/modules\/([^/]+)$/,
);
if (skillModuleDetailMatch && req.method === "GET") {
const skillId = decodeURIComponent(skillModuleDetailMatch[1]);
const moduleId = decodeURIComponent(skillModuleDetailMatch[2]);
try {
const detail = await loadModuleDetailPayload(skillId, moduleId);
if (!detail) {
json(res, 404, { error: `未找到能力:${moduleId}` });
return true;
}
json(res, 200, detail);
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "未找到能力",
});
}
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;
/** 导演 Skill / 能力包 idregistry name */
orchestratorId?: string;
/** 用户手动选定的导演 id内部 recipe */
recipeId?: string;
};
const skills = await listSkills();
const requested = body.orchestratorId?.trim();
const director =
(requested && skills.find((s) => s.name === requested)) ||
skills.find((s) => s.name === "world-simulator") ||
skills[0];
if (!director) {
json(res, 400, { error: "没有可用的导演 Skill能力包" });
return true;
}
const book = createBook({ title: body.title });
updateBook(book.id, {
activeSkillId: director.name,
activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name,
orchestratorId: director.name,
orchestratorName: director.name,
});
const session = await sessionManager.createForBook(
book.id,
director.name,
body.recipeId?.trim(),
);
json(res, 201, { book: getBook(book.id) ?? book, session });
return true;
}
if (pathname === "/api/books/batch" && req.method === "DELETE") {
const body = JSON.parse(await readBody(req)) as { ids?: string[] };
const ids = (body.ids ?? []).filter(Boolean);
const deleted: string[] = [];
for (const id of ids) {
const book = getBook(id);
if (!book) continue;
sessionManager.dropBookSessions(id);
deleteBook(id);
deleted.push(id);
}
json(res, 200, { deleted });
return true;
}
const duplicateMatch = pathname.match(/^\/api\/books\/([^/]+)\/duplicate$/);
if (duplicateMatch && req.method === "POST") {
const bookId = decodeURIComponent(duplicateMatch[1]);
const body = JSON.parse(await readBody(req)) as { title?: string };
try {
const book = duplicateBook(bookId, body.title);
json(res, 201, { book });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "复制失败",
});
}
return true;
}
const playNewMatch = pathname.match(/^\/api\/books\/([^/]+)\/play\/new$/);
if (playNewMatch && req.method === "POST") {
const bookId = decodeURIComponent(playNewMatch[1]);
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
const active = sessionManager.getActiveSessionForBook(bookId);
if (!active?.id) {
json(res, 400, { error: "请先打开该作品" });
return true;
}
try {
const session = await sessionManager.startNewPlayRun(active.id);
json(res, 200, { session });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "无法新建游玩",
});
}
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).map((s) => ({
...s,
kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度",
}));
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;
/** instance=创作定稿截面run=游玩进度(默认) */
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 =
kind === "run"
? sessionManager.savePlaySnapshot(sessionId, body.label ?? "", body.note)
: sessionManager.saveGameSnapshot(
sessionId,
body.label ?? "",
"instance",
body.note,
);
json(res, 201, {
save: {
...save,
kindLabel: save.kind === "instance" ? "创作定稿" : "游玩进度",
},
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "存档失败",
});
}
return true;
}
}
const saveItemMatch = pathname.match(
/^\/api\/books\/([^/]+)\/saves\/([^/]+)(\/load)?$/,
);
if (saveItemMatch) {
const bookId = decodeURIComponent(saveItemMatch[1]);
const saveId = decodeURIComponent(saveItemMatch[2]);
const isLoad = saveItemMatch[3] === "/load";
if (isLoad && req.method === "POST") {
try {
const session = await sessionManager.loadGameSnapshot(bookId, saveId);
json(res, 200, { session });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "读档失败",
});
}
return true;
}
if (req.method === "DELETE") {
try {
sessionManager.deleteGameSnapshot(bookId, saveId);
json(res, 200, { ok: true });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "删除失败",
});
}
return true;
}
}
const bookMatch = pathname.match(/^\/api\/books\/([^/]+)(\/open)?$/);
if (bookMatch) {
const bookId = decodeURIComponent(bookMatch[1]);
const isOpen = bookMatch[2] === "/open";
if (isOpen && req.method === "POST") {
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
const session = await sessionManager.openBook(book.id);
json(res, 200, { book, session });
return true;
}
if (req.method === "GET") {
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
json(res, 200, { book });
return true;
}
if (req.method === "PUT") {
const body = JSON.parse(await readBody(req)) as { title?: string };
try {
const book = updateBook(bookId, { title: body.title?.trim() });
json(res, 200, { book });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "更新失败",
});
}
return true;
}
if (req.method === "DELETE") {
const book = getBook(bookId);
if (!book) {
json(res, 404, { error: "Book 不存在" });
return true;
}
sessionManager.dropBookSessions(bookId);
deleteBook(bookId);
json(res, 200, { ok: true });
return true;
}
}
return false;
}

View File

@@ -0,0 +1,121 @@
/**
* 用户可见中文标签(与 docs/ui-glossary.md 同步)。
* 内部 id 不变;仅展示层映射。
*/
const STAGE_LABELS: Record<string, string> = {
design: "创作",
play: "游玩",
done: "已完成",
idle: "待命",
running: "执行中",
waiting_user: "等待你",
error: "出错",
};
const WORKER_LABELS: Record<string, string> = {
"design-core": "创作 · 核心",
"design-worker": "创作 · 演员规格",
"design-fixed": "创作 · 固定上下文",
"design-refine": "创作 · 细化与终稿",
"design-intake": "创作 · 综合收口",
"design-flow": "创作 · 流程编排",
"design-step": "创作 · 执行步骤",
"opening-generator": "开局 · 开场白",
orchestrator: "导演",
"agent-burst": "导演调度",
narrator: "叙事转述",
"role-decide": "角色决策",
"world-simulator": "世界推演",
"round-present": "回合呈现",
outline: "大纲 / 细纲",
"chapter-writer": "章节正文",
};
const FIXED_TOPIC_LABELS: Record<string, string> = {
"aesthetics-interaction": "美学纲领与交互范式",
interaction: "交互范式",
narrative_guide: "叙事指南",
input_protocol: "输入协议",
core_premise: "核心前提",
aesthetics: "美学纲领",
};
const PHASE_UNIT_LABELS: Record<string, string> = {
core: "核心",
refine: "细化",
};
const SKILL_PACK_LABELS: Record<string, string> = {
"world-simulator": "世界模拟器",
"expand-assistant": "扩写助手",
};
/** 生命周期 / 相位 */
export function displayStageLabel(id: string | undefined | null): string {
if (!id) return "";
return STAGE_LABELS[id] ?? id;
}
/** 导演选项 / skill pack 展示名 */
export function displaySkillPackLabel(id: string | undefined | null): string {
if (!id) return "";
return SKILL_PACK_LABELS[id] ?? id;
}
/**
* 演员 / 单位 / 能力 id → 用户可见名(不含动作后缀)。
*/
export function displayWorkerLabel(id: string | undefined | null): string {
if (!id) return "";
const trimmed = id.trim();
if (!trimmed) return "";
if (WORKER_LABELS[trimmed]) return WORKER_LABELS[trimmed]!;
if (trimmed.startsWith("phase:")) {
const key = trimmed.slice("phase:".length);
const name = PHASE_UNIT_LABELS[key] ?? key;
return `单位 · ${name}`;
}
if (trimmed.startsWith("worker:")) {
const ref = trimmed.slice("worker:".length);
return `演员 · ${displayWorkerLabel(ref)}`;
}
if (trimmed.startsWith("fixed:")) {
const topic = trimmed.slice("fixed:".length);
return `能力 · ${FIXED_TOPIC_LABELS[topic] ?? topic}`;
}
if (trimmed.startsWith("resident:")) {
return `常驻 · ${trimmed.slice("resident:".length)}`;
}
return trimmed;
}
export type WorkerTitleAction = "running" | "output" | "questions" | "stub" | null;
/** 气泡 / 流式标题:中文名 + 可选动作 */
export function formatWorkerDisplayTitle(
workerId: string | undefined | null,
action: WorkerTitleAction = null,
): string {
const base = displayWorkerLabel(workerId) || "演员";
switch (action) {
case "output":
return `${base} · 产出`;
case "questions":
return `${base} · 提问`;
case "stub":
return `${base} · 占位`;
case "running":
return `${base} · 执行中`;
default:
return base;
}
}
/** 导演(调度 Agent相关标题 */
export function formatAgentDisplayTitle(detail?: string): string {
if (detail?.trim()) return `导演 · ${detail.trim()}`;
return "导演";
}

View File

@@ -0,0 +1,223 @@
import { randomUUID } from "node:crypto";
import type { BlackboardItem } from "../types/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
export type BranchableMessage = {
id: string;
role: "system" | "user";
text: string;
createdAt: string;
kind?: string;
actor?: string;
title?: string;
body?: string;
branchGroupId?: string;
branchIndex?: number;
branchTotal?: number;
};
export type SessionCheckpoint = {
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
};
export type MessageBranchVariant = {
/** 从分支点起的消息链(含分支点消息本身) */
messages: BranchableMessage[];
checkpoint: SessionCheckpoint;
};
export type MessageBranch = {
anchorIndex: number;
groupId: string;
variants: MessageBranchVariant[];
activeIndex: number;
};
export type MessageBranchState = {
branches: Record<string, MessageBranch>;
/** 下标 i = 追加 messages[i] 之前的 runtime 快照 */
preMessageCheckpoints: Record<number, SessionCheckpoint>;
};
export function createMessageBranchState(): MessageBranchState {
return { branches: {}, preMessageCheckpoints: {} };
}
export function cloneCheckpoint(cp: SessionCheckpoint): SessionCheckpoint {
return {
runtimeSession: structuredClone(cp.runtimeSession),
blackboardItems: structuredClone(cp.blackboardItems),
};
}
export function recordPreMessageCheckpoint(
state: MessageBranchState,
index: number,
checkpoint: SessionCheckpoint,
): void {
state.preMessageCheckpoints[index] = cloneCheckpoint(checkpoint);
}
export function attachBranchMeta(messages: BranchableMessage[], groupId: string, activeIndex: number): void {
const count = messages.filter((m) => m.branchGroupId === groupId).length || 1;
const total = Math.max(count, activeIndex + 1);
for (const m of messages) {
if (m.branchGroupId === groupId && m.branchIndex === activeIndex) {
m.branchTotal = total;
}
}
}
export function syncBranchTotals(
messages: BranchableMessage[],
branch: MessageBranch,
): void {
const total = branch.variants.length;
const active = branch.activeIndex;
const head = branch.variants[active]?.messages[0];
if (!head) return;
for (const m of messages) {
if (m.id === head.id || (m.branchGroupId === branch.groupId && m.branchIndex === active)) {
m.branchGroupId = branch.groupId;
m.branchIndex = active;
m.branchTotal = total;
}
}
}
function cloneMessages(msgs: BranchableMessage[]): BranchableMessage[] {
return msgs.map((m) => ({ ...m }));
}
export function ensureBranchForEdit(
state: MessageBranchState,
messages: BranchableMessage[],
messageIndex: number,
checkpoint: SessionCheckpoint,
): MessageBranch {
const msg = messages[messageIndex];
const groupId = msg.branchGroupId ?? msg.id;
let branch = state.branches[groupId];
if (!branch) {
branch = {
anchorIndex: messageIndex,
groupId,
activeIndex: 0,
variants: [
{
messages: cloneMessages(messages.slice(messageIndex)),
checkpoint: cloneCheckpoint(checkpoint),
},
],
};
state.branches[groupId] = branch;
for (const m of messages.slice(messageIndex)) {
m.branchGroupId = groupId;
m.branchIndex = 0;
m.branchTotal = 1;
}
}
return branch;
}
export function ensureBranchForRefresh(
state: MessageBranchState,
messages: BranchableMessage[],
messageIndex: number,
checkpoint: SessionCheckpoint,
): MessageBranch {
const msg = messages[messageIndex];
const groupId = msg.branchGroupId ?? msg.id;
let branch = state.branches[groupId];
if (!branch) {
branch = {
anchorIndex: messageIndex,
groupId,
activeIndex: 0,
variants: [
{
messages: cloneMessages(messages.slice(messageIndex)),
checkpoint: cloneCheckpoint(checkpoint),
},
],
};
state.branches[groupId] = branch;
for (const m of messages.slice(messageIndex)) {
m.branchGroupId = groupId;
m.branchIndex = 0;
m.branchTotal = 1;
}
}
return branch;
}
export function appendBranchVariant(
branch: MessageBranch,
headMessage: BranchableMessage,
checkpoint: SessionCheckpoint,
): number {
const index = branch.variants.length;
branch.variants.push({
messages: [{ ...headMessage, branchGroupId: branch.groupId, branchIndex: index }],
checkpoint: cloneCheckpoint(checkpoint),
});
branch.activeIndex = index;
return index;
}
export function updateActiveBranchVariant(
branch: MessageBranch,
tailMessages: BranchableMessage[],
checkpoint: SessionCheckpoint,
): void {
const variant = branch.variants[branch.activeIndex];
if (!variant) return;
variant.messages = cloneMessages(tailMessages);
variant.checkpoint = cloneCheckpoint(checkpoint);
}
export function switchBranchVariant(
state: MessageBranchState,
messages: BranchableMessage[],
groupId: string,
delta: -1 | 1,
): { messages: BranchableMessage[]; checkpoint: SessionCheckpoint } | null {
const branch = state.branches[groupId];
if (!branch) return null;
const next = branch.activeIndex + delta;
if (next < 0 || next >= branch.variants.length) return null;
branch.activeIndex = next;
const variant = branch.variants[next];
const prefix = messages.slice(0, branch.anchorIndex);
const merged = [...prefix, ...cloneMessages(variant.messages)];
syncBranchTotals(merged, branch);
return { messages: merged, checkpoint: cloneCheckpoint(variant.checkpoint) };
}
export function createUserVariantMessage(text: string, groupId: string, branchIndex: number): BranchableMessage {
return {
id: randomUUID(),
role: "user",
text,
createdAt: new Date().toISOString(),
kind: "user_input",
title: "你的输入",
body: text,
branchGroupId: groupId,
branchIndex,
};
}
export function isRefreshableMessage(msg: BranchableMessage): boolean {
if (msg.role === "user") return false;
const kind = msg.kind ?? "system_info";
return kind === "worker_questions" || kind === "worker_output";
}
export function findPrecedingUserIndex(messages: BranchableMessage[], fromIndex: number): number {
for (let i = fromIndex - 1; i >= 0; i--) {
if (messages[i].role === "user") return i;
}
return -1;
}

View File

@@ -0,0 +1,162 @@
/**
* 创作过程对话策略(与黑板固定上下文正交):
* - session.messages全量保留供用户浏览编辑分支只暴露当前认可版本
* - 拼给 AI 的「创作.对话」:用户全量 + AI 永远只留最后一次(含未完成提问)
*
* 「隐藏 AI 历史」只作用于拼接,不删、不藏用户可见消息。
*/
export type DialogueChatMessage = {
id: string;
role: "system" | "user";
text: string;
createdAt?: string;
kind?: string;
actor?: string;
title?: string;
body?: string;
compressed?: boolean;
};
/** @deprecated 用 DialogueChatMessage */
export type PrunableChatMessage = DialogueChatMessage;
/** 写入黑板、注入 design worker 的对话 transcript tag */
export const CREATION_DIALOGUE_TAG = "创作.对话";
const AI_CONTENT_KINDS = new Set([
"worker_output",
"worker_questions",
"orchestrator_thinking",
"orchestrator_decision",
]);
const AI_EPHEMERAL_KINDS = new Set(["worker_running", "worker_stub", "agent_tool"]);
function isAiContent(m: DialogueChatMessage): boolean {
const kind = m.kind ?? "";
if (AI_CONTENT_KINDS.has(kind)) return true;
if (m.role === "system" && kind.startsWith("worker_")) return true;
return false;
}
function isAiEphemeral(m: DialogueChatMessage): boolean {
return AI_EPHEMERAL_KINDS.has(m.kind ?? "");
}
function isUserMessage(m: DialogueChatMessage): boolean {
return m.role === "user" || m.kind === "user_input";
}
/**
* 从全量 messages 选出「拼给 AI」的视图全部用户 + 最后一次 AI 内容。
* 不修改原数组。
*/
export function selectCreationDialogueForAi<T extends DialogueChatMessage>(
messages: readonly T[],
): T[] {
let lastAiIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (isAiContent(messages[i]!)) {
lastAiIdx = i;
break;
}
}
const out: T[] = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i]!;
if (m.compressed) continue;
if (isUserMessage(m)) {
out.push(m);
continue;
}
if (isAiEphemeral(m)) continue;
if (isAiContent(m) && i === lastAiIdx) out.push(m);
}
return out;
}
/**
* @deprecated 勿再改写 session.messages改用 selectCreationDialogueForAi / buildCreationDialogueTranscript。
* 保留空操作兼容旧调用点。
*/
export function trimCreationDialogueMessages<T extends DialogueChatMessage>(
_messages: T[],
): { removedCount: number } {
return { removedCount: 0 };
}
function displayText(m: DialogueChatMessage): string {
const body = (m.body ?? m.text ?? "").trim();
return body || "(空)";
}
/** 拼给 design worker 的对话前情(用户全量 + 最后一次 AI不改 messages */
export function buildCreationDialogueTranscript(
messages: readonly DialogueChatMessage[],
): string {
const selected = selectCreationDialogueForAi(messages);
const lines: string[] = [
"以下为创作过程对话用户发言全部保留AI 仅保留最后一次输出,含未完成提问)。",
"",
];
for (const m of selected) {
if (isUserMessage(m)) {
lines.push(`### 用户`);
lines.push(displayText(m));
lines.push("");
continue;
}
if (isAiContent(m)) {
const who =
m.kind === "worker_questions"
? `AI · ${m.actor ?? "worker"} 提问`
: m.kind === "worker_output"
? `AI · ${m.actor ?? "worker"} 产出`
: m.kind === "orchestrator_thinking"
? "AI · 总管思考"
: `AI · ${m.title ?? m.kind ?? "系统"}`;
lines.push(`### ${who}`);
lines.push(displayText(m));
lines.push("");
}
}
const text = lines.join("\n").trim();
return text || "(尚无创作对话)";
}
/**
* @deprecated 创作验收后不再删 messages用户可继续浏览。保留空操作兼容。
*/
export function pruneCreationUnitMessages<T extends DialogueChatMessage>(
_messages: T[],
_workerId: string,
_options: { afterCreatedAt?: string | null } = {},
): { removedCount: number; productKept: boolean } {
return { removedCount: 0, productKept: false };
}
/** Run 验收:过程消息标 compressed主 feed 隐藏),不删除。 */
export function foldRunProcessMessages<T extends DialogueChatMessage>(
messages: T[],
workerId: string,
): void {
for (const m of messages) {
if (m.compressed) continue;
if (
(m.kind === "worker_questions" || m.kind === "worker_running") &&
(m.actor === workerId || (m.text ?? "").includes(workerId))
) {
m.compressed = true;
continue;
}
if (
m.kind === "worker_output" &&
m.actor === workerId &&
!String(m.text ?? "").includes("[上下文已压缩]")
) {
m.compressed = true;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,20 +10,24 @@ import {
import {
ensureActiveProfileDefault,
loadAppSettings,
normalizeContextTraceKeepLatest,
saveAppSettings,
setActivePresetId,
setActiveProfileId,
} from "../config/settings.js";
import {
countEnabledEntries,
countInjectingEntries,
listAllPresetEntries,
patchPresetEntries,
type PresetEntryPatch,
} from "../preset/entries.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> {
@@ -49,13 +53,14 @@ export async function handleSettingsApi(
const settings = loadAppSettings();
const profiles = listApiProfiles();
const presets = listPresets().map((p) => {
const entries = listEnabledPresetEntries(p);
const all = listAllPresetEntries(p);
return {
id: p.id,
name: p.name,
source: p.source,
enabledCount: p.promptOrder.filter((o) => o.enabled).length,
injectingCount: countInjectingEntries(entries),
enabledCount: countEnabledEntries(all),
injectingCount: countInjectingEntries(all),
entryCount: all.length,
importedAt: p.importedAt,
};
});
@@ -67,6 +72,7 @@ export async function handleSettingsApi(
const body = JSON.parse(await readBody(req)) as {
activeProfileId?: string | null;
activePresetId?: string | null;
contextTraceKeepLatest?: number;
};
const settings = loadAppSettings();
if (body.activeProfileId !== undefined) {
@@ -75,11 +81,28 @@ export async function handleSettingsApi(
if (body.activePresetId !== undefined) {
settings.activePresetId = body.activePresetId;
}
if (body.contextTraceKeepLatest !== undefined) {
settings.contextTraceKeepLatest = normalizeContextTraceKeepLatest(
body.contextTraceKeepLatest,
);
}
saveAppSettings(settings);
json(res, 200, { settings });
return true;
}
if (pathname === "/api/settings/context-traces/prune" && req.method === "POST") {
const result = sessionManager.pruneAllOpenContextTraces();
json(res, 200, result);
return true;
}
if (pathname === "/api/settings/context-traces/clear" && req.method === "POST") {
const result = sessionManager.clearAllOpenContextTraces();
json(res, 200, result);
return true;
}
if (pathname === "/api/profiles" && req.method === "GET") {
ensureActiveProfileDefault();
json(res, 200, { profiles: listApiProfiles() });
@@ -206,22 +229,65 @@ export async function handleSettingsApi(
const presetEntriesMatch = pathname.match(
/^\/api\/presets\/([^/]+)\/entries$/,
);
if (presetEntriesMatch && req.method === "GET") {
if (presetEntriesMatch) {
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;
if (req.method === "GET") {
const entries = listAllPresetEntries(preset);
json(res, 200, {
presetId: preset.id,
presetName: preset.name,
generation: preset.generation,
entries,
enabledCount: countEnabledEntries(entries),
injectingCount: countInjectingEntries(entries),
});
return true;
}
if (req.method === "PATCH" || req.method === "PUT") {
const body = JSON.parse(await readBody(req)) as {
entries?: PresetEntryPatch[];
entry?: PresetEntryPatch;
};
const patches = body.entries?.length
? body.entries
: body.entry
? [body.entry]
: [];
if (!patches.length) {
json(res, 400, { error: "缺少 entries 或 entry" });
return true;
}
try {
const next = patchPresetEntries(preset, patches);
const entries = listAllPresetEntries(next);
const settings = loadAppSettings();
let reloadedSessions = 0;
if (settings.activePresetId === id) {
reloadedSessions = sessionManager.reloadAllLlms();
}
json(res, 200, {
presetId: next.id,
presetName: next.name,
generation: next.generation,
entries,
enabledCount: countEnabledEntries(entries),
injectingCount: countInjectingEntries(entries),
reloadedSessions,
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : String(err),
});
}
return true;
}
}
const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/);
@@ -246,8 +312,13 @@ export async function handleSettingsApi(
json(res, 404, { error: "预设不存在" });
return true;
}
const entries = listEnabledPresetEntries(preset);
json(res, 200, { preset, entries, injectingCount: countInjectingEntries(entries) });
const entries = listAllPresetEntries(preset);
json(res, 200, {
preset,
entries,
enabledCount: countEnabledEntries(entries),
injectingCount: countInjectingEntries(entries),
});
return true;
}

View File

@@ -1,6 +1,20 @@
import type { RuntimeSession } from "../types/runtime.js";
import {
deriveDesignStageScope,
deriveRunWorkerScope,
instantiateMeta,
parseWorkerSetYaml,
runWorkerMeta,
} from "../skills/worker-set-parse.js";
import {
canEnterPlay,
hasAcceptedWorkerSet,
inferLifecycleStage,
type LifecycleStage,
} from "../skills/worker-declaration.js";
export type LifecycleStage = "design" | "play";
export type { LifecycleStage };
export { canEnterPlay, hasAcceptedWorkerSet, inferLifecycleStage };
export type SkillCatalogEntry = {
id: string;
@@ -9,6 +23,8 @@ export type SkillCatalogEntry = {
/** 这一步要干嘛(占位说明,详细设计后续补充) */
purpose: string;
status: "pending" | "active" | "done" | "skipped";
/** 同一 skill 被 invoke 的次数design 阶段 instantiate 可多次) */
runCount?: number;
};
type CatalogTemplate = {
@@ -18,242 +34,172 @@ type CatalogTemplate = {
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 上下文上半)。",
},
{
export type BuildSkillCatalogOptions = {
/** `设计.worker集` 或 `.草稿` 的 YAML 正文 */
workerSetYaml?: string;
};
function templatesFor(_skillPackId?: string): CatalogTemplate[] {
return [];
}
type ArtifactRunStats = {
total: number;
accepted: number;
pending: number;
rejected: number;
};
function collectArtifactRuns(session: RuntimeSession): Map<string, ArtifactRunStats> {
const map = new Map<string, ArtifactRunStats>();
for (const art of session.artifacts) {
if (!art.workerId) continue;
const prev = map.get(art.workerId) ?? {
total: 0,
accepted: 0,
pending: 0,
rejected: 0,
};
prev.total += 1;
if (art.status === "accepted") prev.accepted += 1;
else if (art.status === "rejected") prev.rejected += 1;
else prev.pending += 1;
map.set(art.workerId, prev);
}
return map;
}
function statusForWorkerId(
id: string,
session: RuntimeSession,
runs: Map<string, ArtifactRunStats>,
skipped: Set<string>,
): SkillCatalogEntry["status"] {
if (skipped.has(id)) return "skipped";
const stats = runs.get(id);
if (stats?.accepted) return "done";
if (session.currentWorkerId === id) return "active";
if (stats?.pending) return "active";
return "pending";
}
function buildWorldSimulatorCatalog(
session: RuntimeSession,
lifecycle: LifecycleStage,
workerSetYaml?: string,
): SkillCatalogEntry[] {
const workerSet = parseWorkerSetYaml(workerSetYaml);
const runs = collectArtifactRuns(session);
const skipped = new Set(workerSet?.instantiate_hints?.skip ?? []);
if (lifecycle === "play") {
const runIds = deriveRunWorkerScope(workerSet);
const entries: SkillCatalogEntry[] = [
{
id: "agent-burst",
stage: "run",
label: "总管调度",
purpose: "总管 tool loop读黑板 → 选择下一步 Worker。",
status:
session.phase === "running" && !session.currentWorkerId
? "active"
: "pending",
},
];
for (const id of runIds) {
const meta = runWorkerMeta(id);
const stats = runs.get(id);
entries.push({
id,
stage: "run",
label: meta.label,
purpose: meta.purpose,
status: statusForWorkerId(id, session, runs, new Set()),
runCount: stats?.total || undefined,
});
}
return entries;
}
const designSteps: Array<{ id: string; label: string; purpose: string }> = [
{
id: "design-flow",
label: "创作 · 流程编排",
purpose: "编排/增量修订可变 DAG → 设计.创作流程(可反复编入同能力)。",
},
{
id: "design-step",
label: "创作 · 执行步骤",
purpose: "按已认可流程执行当前一步模块。",
},
];
const entries: SkillCatalogEntry[] = designSteps.map((step) => ({
id: step.id,
stage: "design" as const,
label: step.label,
purpose: step.purpose,
status: statusForWorkerId(step.id, session, runs, skipped),
runCount: runs.get(step.id)?.total || undefined,
}));
const planned = deriveDesignStageScope(workerSet);
for (const id of planned) {
const meta = instantiateMeta(id);
const stats = runs.get(id);
entries.push({
id,
stage: "design",
label: meta.label,
purpose: meta.purpose,
status: statusForWorkerId(id, session, runs, skipped),
runCount: stats?.total || undefined,
});
}
const designIds = new Set(designSteps.map((s) => s.id));
for (const [id, stats] of runs) {
if (designIds.has(id) || id === "design-intake") continue;
if (planned.includes(id) || skipped.has(id)) continue;
const meta = instantiateMeta(id);
entries.push({
id,
stage: "design",
label: meta.label,
purpose: meta.purpose,
status: statusForWorkerId(id, session, runs, skipped),
runCount: stats.total || undefined,
});
}
entries.push({
id: "declare-ready",
stage: "design",
label: "实例就绪",
purpose: "agent 确认设计够开跑,进入游玩阶段。",
},
];
purpose: "Worker 集已 accept 即可进游玩;若声明了开局,建议先验收开场白(非强制锁定)。",
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
});
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);
return entries;
}
export function buildSkillCatalog(
session: RuntimeSession,
skillPackId?: string,
lifecycle: LifecycleStage = inferLifecycleStage(session),
options?: BuildSkillCatalogOptions,
): SkillCatalogEntry[] {
if (skillPackId === "world-simulator") {
return buildWorldSimulatorCatalog(session, lifecycle, options?.workerSetYaml);
}
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),
);
const runs = collectArtifactRuns(session);
return filtered.map((t) => {
let status: SkillCatalogEntry["status"] = "pending";
@@ -267,18 +213,25 @@ export function buildSkillCatalog(
status = "active";
}
} else if (t.id === "declare-ready") {
if (session.slots.startupCompleted) status = "done";
if (session.slots.startupCompleted || hasAcceptedWorkerSet(session)) {
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 (runs.has(t.id)) {
status = statusForWorkerId(t.id, session, runs, new Set());
} else if (session.currentWorkerId === t.id) {
status = "active";
}
return { ...t, status };
const runCount = runs.get(t.id)?.total;
return {
...t,
status,
runCount: runCount || undefined,
};
});
}

View File

@@ -65,6 +65,8 @@ const server = createServer(async (req, res) => {
"GET /api/presets",
"GET /api/books",
"GET /api/skills",
"GET /api/directors",
"GET /api/modules",
"GET /api/stats/tokens",
],
});
@@ -130,6 +132,104 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && sub === "/answers") {
const body = JSON.parse(await readBody(req)) as {
answers?: Array<{
questionId?: string;
optionId?: string;
text?: string;
}>;
note?: string;
};
const answers = (body.answers ?? [])
.filter(
(a) =>
typeof a?.questionId === "string" &&
a.questionId.trim() &&
typeof a?.text === "string" &&
a.text.trim(),
)
.map((a) => ({
questionId: a.questionId!.trim(),
optionId:
typeof a.optionId === "string" && a.optionId.trim()
? a.optionId.trim()
: undefined,
text: a.text!.trim(),
}));
if (!answers.length) {
json(res, 400, { error: "answers 不能为空" });
return;
}
const note =
typeof body.note === "string" && body.note.trim()
? body.note.trim()
: undefined;
try {
const view = await sessionManager.answerQuestions(
sessionId,
answers,
note,
);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "提交失败",
});
}
return;
}
const messageActionMatch = sub.match(
/^\/messages\/([^/]+)\/(edit|refresh|variant|delete)$/,
);
if (req.method === "POST" && messageActionMatch) {
const messageId = decodeURIComponent(messageActionMatch[1]);
const action = messageActionMatch[2];
const body = JSON.parse(await readBody(req).catch(() => "{}")) as {
text?: string;
direction?: string;
};
let view;
try {
if (action === "edit") {
if (!body.text?.trim()) {
json(res, 400, { error: "text 不能为空" });
return;
}
view = await sessionManager.editMessage(
sessionId,
messageId,
body.text.trim(),
);
} else if (action === "refresh") {
view = await sessionManager.refreshMessage(sessionId, messageId);
} else if (action === "variant") {
if (body.direction !== "prev" && body.direction !== "next") {
json(res, 400, { error: "direction 须为 prev 或 next" });
return;
}
view = await sessionManager.switchMessageVariant(
sessionId,
messageId,
body.direction,
);
} else if (action === "delete") {
view = await sessionManager.deleteMessage(sessionId, messageId);
} else {
json(res, 400, { error: "未知 action" });
return;
}
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "操作失败",
});
return;
}
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") {
@@ -147,6 +247,74 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && sub === "/recipe") {
const body = JSON.parse(await readBody(req)) as { recipeId?: string };
if (!body.recipeId?.trim()) {
json(res, 400, { error: "缺少 recipeId" });
return;
}
try {
const view = await sessionManager.setSelectedRecipe(
sessionId,
body.recipeId.trim(),
);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "选定配方失败",
});
}
return;
}
if (req.method === "POST" && sub === "/board") {
const body = JSON.parse(await readBody(req)) as {
tag?: string;
content?: string;
};
if (!body.tag?.trim()) {
json(res, 400, { error: "缺少 tag" });
return;
}
try {
const view = sessionManager.writeUserBoardTag(
sessionId,
body.tag,
body.content ?? "",
);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "写入失败",
});
}
return;
}
if (req.method === "POST" && sub === "/context-traces/prune") {
try {
const result = sessionManager.pruneSessionContextTraces(sessionId);
json(res, 200, { ...result, session: sessionManager.get(sessionId) });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "修剪失败",
});
}
return;
}
if (req.method === "POST" && sub === "/context-traces/clear") {
try {
const result = sessionManager.clearSessionContextTraces(sessionId);
json(res, 200, { ...result, session: sessionManager.get(sessionId) });
} 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;
@@ -160,6 +328,9 @@ const server = createServer(async (req, res) => {
case "accept":
view = await sessionManager.accept(sessionId);
break;
case "skip_questions":
view = await sessionManager.skipQuestions(sessionId);
break;
case "reject":
view = await sessionManager.reject(sessionId);
break;

View File

@@ -0,0 +1,165 @@
/**
* Worker contextSegments 拼装:按 tier 顺序把黑板 tag 拼成 Markdown。
* 见 docs/context-assembly.md
*/
import type { Blackboard } from "../blackboard/blackboard.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
import {
CREATION_ACCEPTED_CONTENT_TAG,
formatAcceptedContentForPrompt,
} from "./creation-units.js";
export type ContextSegmentTier = "static" | "dynamic";
export type ContextSegmentDef = {
id: string;
tier: ContextSegmentTier;
tags: string[];
label?: string;
/** latest | concat | tail_lines_N */
policy?: string;
};
export function parseContextSegments(raw: unknown): ContextSegmentDef[] {
if (!Array.isArray(raw)) return [];
const out: ContextSegmentDef[] = [];
for (const row of raw) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>;
const id = typeof r.id === "string" ? r.id.trim() : "";
const tier = r.tier === "dynamic" ? "dynamic" : "static";
const tags = Array.isArray(r.tags)
? r.tags.filter((t): t is string => typeof t === "string" && t.trim()).map((t) => t.trim())
: [];
if (!id || tags.length === 0) continue;
out.push({
id,
tier,
tags,
label: typeof r.label === "string" ? r.label.trim() : undefined,
policy: typeof r.policy === "string" ? r.policy.trim() : undefined,
});
}
return out;
}
function applyPolicy(content: string, policy?: string): string {
if (!policy || policy === "latest" || policy === "concat") return content;
const m = /^tail_lines_(\d+)$/.exec(policy);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0) {
const lines = content.split(/\r?\n/);
return lines.slice(-n).join("\n");
}
}
return content;
}
function readTagContent(
tag: string,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
): string {
if (inputs[tag]?.trim()) return inputs[tag]!.trim();
const items = blackboard.queryByPatterns([tag], inputMerge);
if (items.length === 0) return "";
if (inputMerge === "concat") {
return items.map((i) => i.content).filter(Boolean).join("\n\n");
}
return items[items.length - 1]?.content?.trim() ?? "";
}
function formatSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
): string {
const parts: string[] = [];
for (const tag of segment.tags) {
let content = readTagContent(tag, inputs, blackboard, inputMerge);
if (!content) continue;
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
content = formatAcceptedContentForPrompt(content);
}
content = applyPolicy(content, segment.policy);
if (!content.trim()) continue;
parts.push(content.trim());
}
return parts.join("\n\n");
}
/**
* 按 contextSegments 拼装 user 侧上下文。
* 无 segments 时回退为 JSON inputs兼容旧 skill
*/
export function assembleWorkerContext(params: {
inputs: Record<string, string>;
segments?: ContextSegmentDef[] | null;
blackboard: Blackboard;
inputMerge?: BlackboardInputMerge;
workerId: string;
workerName: string;
outputTags: string[];
}): string {
const inputMerge = params.inputMerge ?? "latest";
const segments = params.segments ?? [];
if (segments.length === 0) {
return JSON.stringify(
{
workerId: params.workerId,
workerName: params.workerName,
outputTags: params.outputTags,
inputs: params.inputs,
instruction:
"根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
"设计.worker集 / 草稿须为 JSON 对象文本。",
},
null,
2,
);
}
const staticSegs = segments.filter((s) => s.tier === "static");
const dynamicSegs = segments.filter((s) => s.tier === "dynamic");
const blocks: string[] = [];
const render = (seg: ContextSegmentDef) => {
const body = formatSegmentBody(seg, params.inputs, params.blackboard, inputMerge);
if (!body) return;
if (seg.label) {
blocks.push(`${seg.label}\n\n${body}`);
} else {
blocks.push(body);
}
};
for (const seg of staticSegs) render(seg);
for (const seg of dynamicSegs) render(seg);
// 定稿摘要:若未在 segments 中声明,仍附在末尾
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
if (brief && !briefInSegments) {
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
}
blocks.push(
[
"## 本步任务",
"",
`- workerId: \`${params.workerId}\``,
`- workerName: ${params.workerName}`,
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
"",
"按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。",
"设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。",
].join("\n"),
);
return blocks.join("\n\n---\n\n");
}

964
src/skills/creation-flow.ts Normal file
View File

@@ -0,0 +1,964 @@
/**
* 创作流程:编排产物(设计.创作流程)。
* 可变增量 DAG有序 steps + 每步 id/中文名 + depends_on可追加、可同能力多次。
*
* 两层内容(作者细写,运行时只搭骨架):
* - recipes/:初始配方(给总管 / design-flow 的参考起点,可调味)
* - modules/:共用组件池(步骤名与方法正文;配方与总管都从这里选型)
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
export const CREATION_FLOW_TAG = "设计.创作流程";
export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤";
/** 用户手动选定的初始配方(存 recipe id或 JSON {id,name} */
export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方";
export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml";
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
export const DESIGN_STEP_WORKER_ID = "design-step";
export const DESIGN_FLOW_WORKER_ID = "design-flow";
const DEFAULT_SKILLS_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../skills",
);
export type CreationFlowStep = {
/**
* 本局步骤唯一 id验收与 depends_on 用这个)。
* 同能力可多次出现时必须不同;缺省时程序按 name / name#n 补齐。
*/
id: string;
/** 固定中文名,须 ∈ 模块目录(可重复) */
name: string;
/** 依赖的其它步骤 id旧稿若 name 唯一也可写 name */
depends_on: string[];
};
export type CreationFlowStatus = "open" | "closed";
export type CreationFlow = {
version: 1;
/** 可选一句体验复述(给人看) */
brief?: string;
/**
* open = 当前只是近期 horizon还可增量追加 / 反复编排同能力;
* closed = 不再扩步(可走收成)。缺省按 closed兼容旧固定 DAG
*/
status?: CreationFlowStatus;
steps: CreationFlowStep[];
};
export type ModuleCatalogEntry = {
/** 目录文件夹名,如 aesthetics-interaction */
id: string;
name: string;
/** 给 agent 的短声明:用来决定要不要调度这一步 */
declaration: string;
/** 执行期产物 tag流程 JSON 不写;程序映射) */
artifact: string;
/**
* 可选:默认可反复编排进流程(如生成规则、具体实例)。
* 程序不硬拦;给编排与校验提示。
*/
repeatable?: boolean;
/**
* 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块;
* catalog 写了则作覆盖。程序发出,不经 LLM。
*/
opening?: string;
};
/** 本步程序开场白正文design-step 发出后写入,供 LLM 看见) */
export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白";
/** JSON{ [步骤中文名]: "shown" | "answered" } */
export const CREATION_MODULE_OPENING_STATE_TAG = "创作.能力开场状态";
export const SLOT_CREATION_MODULE_OPENING_STATE = "creationModuleOpeningState";
export type ModuleOpeningState = Record<string, "shown" | "answered">;
export type ModuleCatalog = {
modules: ModuleCatalogEntry[];
};
/** 初始配方目录条目(短声明,给选型) */
export type RecipeCatalogEntry = {
id: string;
name: string;
declaration: string;
};
export type RecipeCatalog = {
recipes: RecipeCatalogEntry[];
};
/**
* 单份初始配方详情。
* seed = 建议步骤可为空name 须 ∈ 模块池);编排时允许增删改。
*/
export type RecipeDetail = {
id: string;
name: string;
declaration: string;
when?: string;
hint?: string;
seed: CreationFlow | null;
};
export type CreationFlowValidation = {
ok: boolean;
errors: string[];
};
export type CreationFlowUserView = {
brief?: string;
status?: CreationFlowStatus;
steps: Array<{
order: number;
id: string;
name: string;
depends_on: string[];
/** 同能力第几次(>1 时 UI 可标「再来」) */
occurrence?: number;
/** 目录里的短声明(有则展示) */
declaration?: string;
repeatable?: boolean;
}>;
parseError?: string;
};
/** 从任意正文抽取 JSON 对象 */
export function extractJsonObject(raw: string): unknown | null {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
/* try slice */
}
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start >= 0 && end > start) {
try {
return JSON.parse(trimmed.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
/** 为缺 id 的步骤补齐唯一 id同 name 多次 → name#2、name#3… */
export function ensureCreationFlowStepIds(
steps: Array<{ id?: string; name: string; depends_on: string[] }>,
): CreationFlowStep[] {
const used = new Set<string>();
const nameCount = new Map<string, number>();
const out: CreationFlowStep[] = [];
for (const raw of steps) {
const name = raw.name.trim();
const n = (nameCount.get(name) ?? 0) + 1;
nameCount.set(name, n);
let id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
id = n === 1 ? name : `${name}#${n}`;
}
if (used.has(id)) {
let i = 2;
while (used.has(`${id}#${i}`)) i++;
id = `${id}#${i}`;
}
used.add(id);
out.push({
id,
name,
depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean),
});
}
return out;
}
/** 按 id 或唯一name 解析步骤引用 */
export function findStepByRef(
flow: CreationFlow,
ref: string,
): CreationFlowStep | null {
const key = ref.trim();
if (!key) return null;
const byId = flow.steps.find((s) => s.id === key);
if (byId) return byId;
const byName = flow.steps.filter((s) => s.name === key);
return byName.length === 1 ? byName[0]! : null;
}
/** 验收 / 当前步骤用的单位 id优先 step.id */
export function stepUnitId(step: CreationFlowStep): string {
return step.id || step.name;
}
export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null {
if (!raw?.trim()) return null;
const doc = extractJsonObject(raw);
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const row = doc as Record<string, unknown>;
const stepsRaw = row.steps;
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null;
const drafted: Array<{ id?: string; name: string; depends_on: string[] }> = [];
for (const item of stepsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const s = item as Record<string, unknown>;
const name = typeof s.name === "string" ? s.name.trim() : "";
if (!name) return null;
const id = typeof s.id === "string" && s.id.trim() ? s.id.trim() : undefined;
const depsRaw = s.depends_on ?? s.dependsOn ?? [];
const depends_on = Array.isArray(depsRaw)
? depsRaw.map((d) => String(d).trim()).filter(Boolean)
: [];
drafted.push({ id, name, depends_on });
}
const steps = ensureCreationFlowStepIds(drafted);
const brief =
typeof row.brief === "string" && row.brief.trim() ? row.brief.trim() : undefined;
const statusRaw = typeof row.status === "string" ? row.status.trim() : "";
const status: CreationFlowStatus | undefined =
statusRaw === "open" || statusRaw === "closed" ? statusRaw : undefined;
return { version: 1, brief, status, steps };
}
export function parseModuleCatalog(raw: string): ModuleCatalog | null {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return null;
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const modulesRaw = (doc as Record<string, unknown>).modules;
if (!Array.isArray(modulesRaw)) return null;
const modules: ModuleCatalogEntry[] = [];
for (const item of modulesRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const m = item as Record<string, unknown>;
const name = typeof m.name === "string" ? m.name.trim() : "";
const declaration =
typeof m.declaration === "string" ? m.declaration.trim() : "";
const artifact = typeof m.artifact === "string" ? m.artifact.trim() : "";
const id =
typeof m.id === "string" && m.id.trim()
? m.id.trim()
: name
? slugFromName(name)
: "";
if (!name || !declaration || !artifact || !id) continue;
const opening =
typeof m.opening === "string" && m.opening.trim()
? m.opening.trim()
: undefined;
const repeatable = m.repeatable === true;
modules.push({
id,
name,
declaration,
artifact,
...(repeatable ? { repeatable: true } : {}),
...(opening ? { opening } : {}),
});
}
if (modules.length === 0) return null;
return { modules };
}
/**
* 能力 prompt.md 可切割块fence 语言标签 = 块 id
* 标准块见 MODULE_SECTION_IDS程序只认 ```id … ```,不认散文标题 alone。
*/
export const MODULE_SECTION_IDS = [
"meta",
"opening",
"task",
"principles",
"probe",
"output",
"checklist",
"examples",
] as const;
export type ModuleSectionId = (typeof MODULE_SECTION_IDS)[number];
export type ModulePromptSections = {
/** 原文 */
raw: string;
/** 按 fence 标签切出的块;缺块则为空串 */
blocks: Partial<Record<ModuleSectionId, string>> & Record<string, string>;
};
/**
* 切割能力文档:抽取全部 ```lang … ``` 块。
* 同一 lang 多次出现时拼接(中间空行)。
*/
export function parseModulePromptSections(promptMd: string): ModulePromptSections {
const blocks: Record<string, string> = {};
if (!promptMd?.trim()) return { raw: promptMd ?? "", blocks };
const re = /```([a-zA-Z][\w-]*)\s*\r?\n([\s\S]*?)```/g;
let m: RegExpExecArray | null;
while ((m = re.exec(promptMd)) !== null) {
const id = m[1]!.toLowerCase();
const body = m[2]!.trim();
if (!body) continue;
blocks[id] = blocks[id] ? `${blocks[id]}\n\n${body}` : body;
}
return { raw: promptMd, blocks };
}
export function getModuleSection(
sections: ModulePromptSections,
id: ModuleSectionId | string,
): string | null {
const body = sections.blocks[id.toLowerCase()]?.trim();
return body || null;
}
/**
* 从能力 prompt.md 抽取默认问题(开场白)。
* 只认 ```opening … ```(能力标准块)。
*/
export function extractModuleOpening(promptMd: string): string | null {
return getModuleSection(parseModulePromptSections(promptMd), "opening");
}
/**
* 拼给 LLM 的方法正文:标准块按固定顺序;无标准块时回退全文。
* 不含 opening开场已由程序发出
*/
export function formatModulePromptForLlm(promptMd: string): string {
const { blocks } = parseModulePromptSections(promptMd);
const order: ModuleSectionId[] = [
"meta",
"task",
"principles",
"probe",
"output",
"checklist",
"examples",
];
const parts: string[] = [];
for (const id of order) {
const body = blocks[id]?.trim();
if (body) parts.push(`## ${id}\n\n\`\`\`${id}\n${body}\n\`\`\``);
}
if (parts.length === 0) return promptMd.trim();
return parts.join("\n\n");
}
export function parseModuleOpeningState(
raw: string | unknown | null | undefined,
): ModuleOpeningState {
if (raw == null) return {};
const text =
typeof raw === "string"
? raw.trim()
: typeof raw === "object"
? JSON.stringify(raw)
: String(raw);
if (!text) return {};
try {
const doc = JSON.parse(text) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {};
const out: ModuleOpeningState = {};
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
if (v === "shown" || v === "answered") out[k] = v;
}
return out;
} catch {
return {};
}
}
export function stringifyModuleOpeningState(state: ModuleOpeningState): string {
return JSON.stringify(state);
}
/** 无 id 时的兜底(目录仍应显式写 id */
function slugFromName(name: string): string {
const map: Record<string, string> = {
: "aesthetics-interaction",
: "interaction",
: "aesthetics",
: "narrative",
: "mechanism",
: "world-blueprint",
: "generation-rules",
: "concrete-instances",
: "topology",
: "status-bar",
: "variable-design",
: "variable-context",
: "reply-format",
"Worker 规格": "worker-spec",
稿: "refine",
};
return map[name] ?? name;
}
export async function loadModuleCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<ModuleCatalog | null> {
const fullPath = path.join(skillsRoot, skillPackRoot, MODULE_CATALOG_FILENAME);
try {
const raw = await readFile(fullPath, "utf8");
return parseModuleCatalog(raw);
} catch {
return null;
}
}
/** 注入 design-flow 的短目录(非全文 prompt */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const flags = m.repeatable ? "〔可反复〕" : "";
return `- ${m.name}${flags}${m.declaration}`;
});
return `【能力 · 可选工序】(按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入)\n${lines.join("\n")}`;
}
export function parseRecipeCatalog(raw: string): RecipeCatalog | null {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return null;
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const recipesRaw = (doc as Record<string, unknown>).recipes;
if (!Array.isArray(recipesRaw)) return null;
const recipes: RecipeCatalogEntry[] = [];
for (const item of recipesRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const r = item as Record<string, unknown>;
const name = typeof r.name === "string" ? r.name.trim() : "";
const declaration =
typeof r.declaration === "string" ? r.declaration.trim() : "";
const id =
typeof r.id === "string" && r.id.trim()
? r.id.trim()
: name
? slugFromName(name)
: "";
if (!name || !declaration || !id) continue;
recipes.push({ id, name, declaration });
}
if (recipes.length === 0) return null;
return { recipes };
}
export async function loadRecipeCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeCatalog | null> {
const fullPath = path.join(skillsRoot, skillPackRoot, RECIPE_CATALOG_FILENAME);
try {
const raw = await readFile(fullPath, "utf8");
return parseRecipeCatalog(raw);
} catch {
return null;
}
}
/**
* 解析单份 recipe.yamlwhen / hint / brief / steps
* steps 空或缺失 → seed 为 null仍可作选型参考
*/
export function parseRecipeYaml(
raw: string,
meta: RecipeCatalogEntry,
): RecipeDetail {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return {
id: meta.id,
name: meta.name,
declaration: meta.declaration,
seed: null,
};
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return {
id: meta.id,
name: meta.name,
declaration: meta.declaration,
seed: null,
};
}
const row = doc as Record<string, unknown>;
const when =
typeof row.when === "string" && row.when.trim()
? row.when.trim()
: undefined;
const hint =
typeof row.hint === "string" && row.hint.trim()
? row.hint.trim()
: undefined;
const name =
typeof row.name === "string" && row.name.trim()
? row.name.trim()
: meta.name;
const seedParsed = parseCreationFlow(JSON.stringify({
brief: typeof row.brief === "string" ? row.brief : undefined,
status: "open",
steps: Array.isArray(row.steps) ? row.steps : [],
}));
const seed = seedParsed;
return {
id: meta.id,
name,
declaration: meta.declaration,
when,
hint,
seed,
};
}
export async function loadRecipeDetail(
skillPackRoot: string,
entry: RecipeCatalogEntry,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeDetail> {
const fullPath = path.join(
skillsRoot,
skillPackRoot,
"recipes",
entry.id,
"recipe.yaml",
);
try {
const raw = await readFile(fullPath, "utf8");
return parseRecipeYaml(raw, entry);
} catch {
return {
id: entry.id,
name: entry.name,
declaration: entry.declaration,
seed: null,
};
}
}
export async function loadAllRecipeDetails(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeDetail[]> {
const catalog = await loadRecipeCatalog(skillPackRoot, skillsRoot);
if (!catalog) return [];
const out: RecipeDetail[] = [];
for (const entry of catalog.recipes) {
out.push(await loadRecipeDetail(skillPackRoot, entry, skillsRoot));
}
return out;
}
/**
* 解析用户选定的配方引用。
* 接受纯 id / 中文名,或 JSON `{ "id": "…" }` / `{ "name": "…" }`。
*/
export function parseSelectedRecipeRef(
raw: string | null | undefined,
): string | null {
if (!raw?.trim()) return null;
const trimmed = raw.trim();
try {
const doc = JSON.parse(trimmed) as unknown;
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
const row = doc as Record<string, unknown>;
const id = typeof row.id === "string" ? row.id.trim() : "";
const name = typeof row.name === "string" ? row.name.trim() : "";
return id || name || null;
}
} catch {
/* plain string */
}
return trimmed;
}
export function findRecipeCatalogEntry(
catalog: RecipeCatalog | null | undefined,
ref: string,
): RecipeCatalogEntry | null {
if (!catalog || !ref.trim()) return null;
const key = ref.trim();
return (
catalog.recipes.find((r) => r.id === key || r.name === key) ?? null
);
}
/** 给人 / API 看的配方目录短列表(不是给 agent 选型) */
export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string {
const lines = catalog.recipes.map(
(r) => `- ${r.name}${r.declaration}`,
);
return `【可选导演】(须由用户手动选择)\n${lines.join("\n")}`;
}
/** 注入 design-flow用户已选导演内部 recipe */
export function formatSelectedRecipeForAgent(detail: RecipeDetail): string {
const lines: string[] = [
`【用户已选导演 · ${detail.name}`,
"这是用户手动选定的方法起点,不是锁死流水线。",
"产出**增量 DAG**:只排近期要做的步骤;已验收步保留,可追加同能力多次调用(如生成规则 / 具体实例)。",
"按用户表述增删改未验收步骤与依赖(像现场改戏 / 调味);步骤名只能从【能力】选。",
"禁止改选其它导演;若用户要换导演,须等用户重新选定后再编排。",
];
if (detail.declaration) lines.push(`简介:${detail.declaration}`);
if (detail.when) lines.push(`适用:${detail.when}`);
if (detail.hint) lines.push(`调味提示:${detail.hint}`);
if (detail.seed?.steps.length) {
const stepsJson = JSON.stringify(
{
brief: detail.seed.brief,
status: detail.seed.status ?? "open",
steps: detail.seed.steps,
},
null,
2,
);
lines.push("建议近期 steps增量起点可改勿一次排完全程");
lines.push("```json");
lines.push(stepsJson);
lines.push("```");
} else {
lines.push("建议 steps待作者完善 recipe.yaml可从【能力】自行编排近期 horizon");
}
return lines.join("\n");
}
/** design-flow 一次注入:已选配方 + 组件池 */
export function formatDesignFlowContentBlocks(params: {
selectedRecipe?: RecipeDetail | null;
modules?: ModuleCatalog | null;
missingSelection?: boolean;
}): string[] {
const blocks: string[] = [];
if (params.missingSelection) {
blocks.push(
[
"【导演】用户尚未手动选择。",
"禁止自行猜测或替用户选定导演。",
"请 askUser 请用户从可用导演中选择,或等待用户在界面选定后再编排。",
].join("\n"),
);
} else if (params.selectedRecipe) {
blocks.push(formatSelectedRecipeForAgent(params.selectedRecipe));
}
if (params.modules) {
blocks.push(formatModuleCatalogForAgent(params.modules));
}
return blocks;
}
/** 解析并加载用户已选配方详情 */
export async function resolveSelectedRecipeDetail(params: {
skillPackRoot: string;
selectedRecipeRef?: string | null;
skillsRoot?: string;
}): Promise<RecipeDetail | null> {
const ref = parseSelectedRecipeRef(params.selectedRecipeRef);
if (!ref) return null;
const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT;
const catalog = await loadRecipeCatalog(params.skillPackRoot, skillsRoot);
const entry = findRecipeCatalogEntry(catalog, ref);
if (!entry) return null;
return loadRecipeDetail(params.skillPackRoot, entry, skillsRoot);
}
export function validateCreationFlow(
flow: CreationFlow,
catalog: ModuleCatalog | null,
): CreationFlowValidation {
const errors: string[] = [];
const ids = flow.steps.map((s) => s.id);
const seenIds = new Set<string>();
const allowed = catalog
? new Set(catalog.modules.map((m) => m.name))
: null;
const nameCount = new Map<string, number>();
for (const step of flow.steps) {
nameCount.set(step.name, (nameCount.get(step.name) ?? 0) + 1);
}
for (let i = 0; i < flow.steps.length; i++) {
const step = flow.steps[i]!;
if (seenIds.has(step.id)) {
errors.push(`步骤 id「${step.id}」重复`);
}
seenIds.add(step.id);
if (allowed && !allowed.has(step.name)) {
errors.push(`${step.name}」不在模块目录中`);
}
const mod = catalog?.modules.find((m) => m.name === step.name);
if (
catalog &&
(nameCount.get(step.name) ?? 0) > 1 &&
mod &&
mod.repeatable !== true
) {
errors.push(
`${step.name}」出现多次,但目录未标 repeatable仅可反复能力可同名多次`,
);
}
for (const dep of step.depends_on) {
const depStep = findStepByRef(flow, dep);
if (!depStep) {
errors.push(`${step.id}」依赖「${dep}」,但流程中没有该步骤`);
continue;
}
const depIndex = flow.steps.findIndex((s) => s.id === depStep.id);
if (depIndex >= i) {
errors.push(
`${step.id}」依赖「${depStep.id}」,但「${depStep.id}」未排在其前面`,
);
}
}
}
return { ok: errors.length === 0, errors };
}
export function artifactTagForStep(
name: string,
catalog: ModuleCatalog | null,
): string | null {
return catalog?.modules.find((m) => m.name === name)?.artifact ?? null;
}
export function formatCreationFlowForUser(
flow: CreationFlow,
catalog?: ModuleCatalog | null,
): CreationFlowUserView {
const decl = new Map(
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
);
const seenName = new Map<string, number>();
return {
brief: flow.brief,
status: flow.status,
steps: flow.steps.map((s, i) => {
const n = (seenName.get(s.name) ?? 0) + 1;
seenName.set(s.name, n);
const mod = decl.get(s.name);
return {
order: i + 1,
id: s.id,
name: s.name,
depends_on: s.depends_on,
occurrence: n,
declaration: mod?.declaration,
repeatable: mod?.repeatable,
};
}),
};
}
export function findModuleByName(
catalog: ModuleCatalog | null | undefined,
name: string,
): ModuleCatalogEntry | null {
if (!catalog) return null;
return catalog.modules.find((m) => m.name === name) ?? null;
}
/** 已验收步骤名列表JSON 数组或换行文本) */
export function parseAcceptedSteps(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((x) => String(x).trim()).filter(Boolean);
}
if (typeof raw !== "string" || !raw.trim()) return [];
try {
const doc = JSON.parse(raw);
if (Array.isArray(doc)) {
return doc.map((x) => String(x).trim()).filter(Boolean);
}
} catch {
/* fall through */
}
return raw
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* 某步是否已验收:认 step.id兼容旧会话只记了中文 name且当时 name 唯一)。
*/
export function isStepAccepted(
step: CreationFlowStep,
acceptedStepIds: readonly string[],
): boolean {
const done = new Set(acceptedStepIds);
if (done.has(step.id)) return true;
// 旧稿:验收列表里是中文名,且 id 就是 name
if (step.id === step.name && done.has(step.name)) return true;
return false;
}
/**
* 流程中下一个待做步骤:未验收,且 depends_on 均已验收。
*/
export function nextPendingStep(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): CreationFlowStep | null {
if (!flow?.steps.length) return null;
for (const step of flow.steps) {
if (isStepAccepted(step, acceptedStepIds)) continue;
const depsOk = step.depends_on.every((dep) => {
const depStep = findStepByRef(flow, dep);
if (!depStep) return false;
return isStepAccepted(depStep, acceptedStepIds);
});
if (depsOk) return step;
}
return null;
}
/** 当前已列出的步骤是否都已验收(不管 status */
export function areListedStepsAccepted(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): boolean {
if (!flow?.steps.length) return false;
return flow.steps.every((s) => isStepAccepted(s, acceptedStepIds));
}
/**
* 流程是否收束完毕listed steps 全验收,且 status 非 open。
* status=open 或缺省但还要扩步 → 应再调 design-flow。
* 缺省 status兼容旧固定 DAG视为 closed。
*/
export function isCreationFlowComplete(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): boolean {
if (!areListedStepsAccepted(flow, acceptedStepIds)) return false;
if (flow?.status === "open") return false;
return true;
}
/**
* 当前步骤做完、但 DAG 仍 open → 需要再编排(追加 / 关闭)。
*/
export function needsFlowExpansion(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): boolean {
if (!flow?.steps.length) return true;
if (nextPendingStep(flow, acceptedStepIds)) return false;
return flow.status === "open";
}
export async function loadModulePrompt(
skillPackRoot: string,
moduleId: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<string | null> {
const fullPath = path.join(
skillsRoot,
skillPackRoot,
"modules",
moduleId,
"prompt.md",
);
try {
return await readFile(fullPath, "utf8");
} catch {
return null;
}
}
/** 依赖步骤 → 产物 tag执行期注入按依赖步的能力 name 映射) */
export function dependencyArtifactTags(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
flow?: CreationFlow | null,
): string[] {
if (!catalog) return [];
const tags: string[] = [];
for (const dep of step.depends_on) {
const depName = flow
? findStepByRef(flow, dep)?.name ?? dep
: dep;
const art = artifactTagForStep(depName, catalog);
if (art) tags.push(art);
}
return [...new Set(tags)];
}
export type DesignStepBinding = {
step: CreationFlowStep;
module: ModuleCatalogEntry;
depTags: string[];
modulePrompt: string;
/** 程序开场白;无则本步直接调 LLM */
opening: string | null;
};
/**
* 解析 design-step 本轮绑定:当前步骤、模块 prompt、依赖 tag、产物 tag。
*/
export async function resolveDesignStepBinding(params: {
skillPackRoot: string;
flowRaw: string | null | undefined;
currentStepName?: string | null;
acceptedStepNames?: readonly string[];
skillsRoot?: string;
}): Promise<DesignStepBinding | null> {
const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT;
const catalog = await loadModuleCatalog(params.skillPackRoot, skillsRoot);
const flow = parseCreationFlow(params.flowRaw);
if (!catalog || !flow) return null;
const accepted = params.acceptedStepNames ?? [];
let step: CreationFlowStep | null = null;
const named = params.currentStepName?.trim();
if (named) {
step = findStepByRef(flow, named);
}
if (!step) {
step = nextPendingStep(flow, accepted);
}
if (!step) return null;
const module = findModuleByName(catalog, step.name);
if (!module) return null;
const modulePromptRaw =
(await loadModulePrompt(params.skillPackRoot, module.id, skillsRoot)) ??
`# ${module.name}\n\n模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md`;
const opening =
module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
return {
step,
module,
depTags: dependencyArtifactTags(step, catalog, flow),
modulePrompt: formatModulePromptForLlm(modulePromptRaw),
opening,
};
}

View File

@@ -0,0 +1,694 @@
/**
* 创作单位:与 run worker 调度正交。
*
* 两大族(同级):
* - worker独立 LLM 工序
* - fixed已写入规格的固定上下文不上 worker ≠ 不重要)
*
* FIXED_CONTEXT_CATALOG = 给 design 的「可向用户询问的示例话题」提示目录,
* 不是填空表;默认 listCreationUnits 只列出**已有内容**的固定块。
*
* 见 docs/design-orchestrator-guide.md §6、§7.2
*/
import type { ParsedWorkerSet } from "./worker-set-parse.js";
import { parseResidentContext } from "./resident-context.js";
export const WORKER_SET_FINAL_TAG = "设计.worker集";
export const WORKER_SET_DRAFT_TAG = "设计.worker集.草稿";
export const CREATION_CURRENT_UNIT_TAG = "创作.当前单位";
export const CREATION_ACCEPTED_UNITS_TAG = "创作.已验收单位";
/** 各单位最后一次验收时的内容切片JSON store → 拼装时格式化为前情提要) */
export const CREATION_ACCEPTED_CONTENT_TAG = "创作.已验收内容";
export const SLOT_CREATION_CURRENT_UNIT = "creationCurrentUnitId";
export const SLOT_CREATION_ACCEPTED_UNITS = "creationAcceptedUnits";
export const SLOT_CREATION_UNIT_ANCHOR_AT = "creationUnitAnchorAt";
/** 创作阶段磁盘 skill新流程编排 + 按步执行) */
export const DESIGN_DISK_WORKERS = [
"design-flow",
"design-step",
] as const;
export type DesignDiskWorkerId = (typeof DESIGN_DISK_WORKERS)[number];
/** @deprecated 旧分步 skill已废弃仅兼容读旧会话 */
const LEGACY_DESIGN_DISK_WORKERS = [
"design-core",
"design-worker",
"design-fixed",
"design-refine",
"design-intake",
] as const;
/** 含历史 id便于读旧会话 */
export function isDesignDiskWorker(workerId: string): boolean {
const id = workerId.trim();
return (
(DESIGN_DISK_WORKERS as readonly string[]).includes(
id as (typeof DESIGN_DISK_WORKERS)[number],
) ||
(LEGACY_DESIGN_DISK_WORKERS as readonly string[]).includes(
id as (typeof LEGACY_DESIGN_DISK_WORKERS)[number],
)
);
}
/** 新流程不再注入 design-common.md */
export function usesDesignCommon(_workerId: string): boolean {
return false;
}
/** worker = 工序fixed = 固定上下文phase = A/C 阶段单位 */
export type CreationUnitKind = "worker" | "fixed" | "phase";
/**
* 固定上下文示例话题 → 规格落点(提示用,不是必填问卷)。
* 仅当草稿里已有对应内容时,才作为创作单位列出(除非显式 includeCatalogFixed
*/
export type FixedContextFlavor =
| "interaction"
| "narrative_guide"
| "aesthetics"
| "input_protocol"
| "core_premises"
| "resident";
export type CreationUnitView = {
id: string;
kind: CreationUnitKind;
label: string;
/** 固定上下文族内的细分worker / phase 无此字段 */
flavor?: FixedContextFlavor;
/** 规格里是否已有实质内容 */
filled: boolean;
/** session 是否已验收本单位 */
accepted?: boolean;
/** 是否为当前正在谈的单位 */
current?: boolean;
/**
* 创作时是否应优先谈清(纲领/范式类默认真)。
* 杂项 resident 可为 false但不代表可忽略——由体验决定。
*/
weighty?: boolean;
detail?: string;
};
/** 示例话题目录:告诉 design 可以问用户类似内容;非 UI 填空项 */
export const FIXED_CONTEXT_CATALOG: Array<{
id: string;
flavor: FixedContextFlavor;
label: string;
weighty: boolean;
hint: string;
}> = [
{
id: "fixed:interaction",
flavor: "interaction",
label: "交互范式",
weighty: true,
hint: "站位、系统扮演、输出形态、与用户怎么轮转(旧称交互骨架;现多由 phase:core 收)",
},
{
id: "fixed:narrative_guide",
flavor: "narrative_guide",
label: "叙事指南",
weighty: true,
hint: "世界态度与体验边界(残酷/不有求必应/随机危险等);≠ 文风",
},
{
id: "fixed:aesthetics",
flavor: "aesthetics",
label: "美学纲领",
weighty: true,
hint: "可读终稿的呈现气质;转述 presentation 或常驻美学块",
},
{
id: "fixed:input_protocol",
flavor: "input_protocol",
label: "输入协议",
weighty: true,
hint: "() 元要求、\"\" 对白、无包裹=事实等",
},
{
id: "fixed:core_premises",
flavor: "core_premises",
label: "核心实现前提",
weighty: true,
hint: "不能瞎发挥又关键的硬前提",
},
];
/** @deprecated 旧 id读进度时兼容 */
const LEGACY_UNIT_ALIASES: Record<string, string> = {
"skeleton:interaction": "fixed:interaction",
"fixed:interaction": "phase:core",
};
export function normalizeCreationUnitId(id: string): string {
let cur = id;
const seen = new Set<string>();
while (LEGACY_UNIT_ALIASES[cur] && !seen.has(cur)) {
seen.add(cur);
cur = LEGACY_UNIT_ALIASES[cur]!;
}
return cur;
}
/** 验收判定:旧 fixed:interaction 与 phase:core 互通 */
function unitIdMatches(candidate: string, target: string): boolean {
const a = normalizeCreationUnitId(candidate);
const b = normalizeCreationUnitId(target);
if (a === b) return true;
// 双向:未规范化的旧 id 也要对上
if (
(candidate === "fixed:interaction" || candidate === "phase:core") &&
(target === "fixed:interaction" || target === "phase:core")
) {
return true;
}
return false;
}
function workerLabel(
ref: string | null,
name?: string,
role?: string,
duty?: string,
): string {
if (name?.trim()) return name.trim();
// role 若是 taxonomycore/auxiliary…不当作展示名
const roleTrim = role?.trim();
if (roleTrim && !/^(core|auxiliary|transcription)$/i.test(roleTrim)) {
return roleTrim;
}
if (ref?.trim()) return ref.trim();
if (duty?.trim()) return duty.trim().slice(0, 40);
return "(未命名 worker";
}
function workerFilled(entry: {
ref: string | null;
duty?: string;
rationale?: string;
gap?: string | null;
}): boolean {
if (entry.gap) return false;
return Boolean(entry.ref?.trim() && (entry.duty?.trim() || entry.rationale?.trim()));
}
function interactionFilled(parsed: ParsedWorkerSet): boolean {
const i = parsed.interaction;
if (!i) return false;
return Boolean(
String(i.user_stance ?? "").trim() &&
String(i.system_role ?? "").trim() &&
String(i.output ?? "").trim(),
);
}
function experienceCheckFilled(parsed: ParsedWorkerSet): boolean {
const e = parsed.experience_check;
if (!e || typeof e !== "object") return false;
return Object.values(e).some((v) => typeof v === "string" && v.trim());
}
function corePhaseFilled(parsed: ParsedWorkerSet): boolean {
return interactionFilled(parsed) || experienceCheckFilled(parsed);
}
function refinePhaseFilled(parsed: ParsedWorkerSet): boolean {
const tables = parsed.tables;
if (!tables || typeof tables !== "object") return false;
const schemas = (tables as { schemas?: unknown }).schemas;
const effects = (tables as { side_effects?: unknown }).side_effects;
return (
(Array.isArray(schemas) && schemas.length > 0) ||
(Array.isArray(effects) && effects.length > 0)
);
}
function textFilled(v: unknown): boolean {
return typeof v === "string" && v.trim().length > 0;
}
function aestheticsFilled(parsed: ParsedWorkerSet): boolean {
for (const w of parsed.workers) {
const p = w.presentation;
if (!p || typeof p !== "object") continue;
if (Object.values(p).some((x) => (typeof x === "string" ? x.trim() : x != null))) {
return true;
}
}
// 仅显式美学 id不把普通 tone/文风常驻当成「美学纲领」填空项
const residents = parseResidentContext(parsed.resident_context);
return residents.some((r) => /^(美学|aesthetics|presentation)$/i.test(r.id));
}
function inputProtocolFilled(parsed: ParsedWorkerSet): boolean {
const p = parsed.input_protocol;
if (!p || typeof p !== "object") return false;
return Object.values(p).some((v) => typeof v === "string" && v.trim());
}
function corePremisesFilled(parsed: ParsedWorkerSet): boolean {
return (parsed.core_premises?.length ?? 0) > 0;
}
function fixedFilled(flavor: FixedContextFlavor, parsed: ParsedWorkerSet): boolean {
switch (flavor) {
case "interaction":
return interactionFilled(parsed);
case "narrative_guide":
return textFilled(parsed.narrative_guide);
case "aesthetics":
return aestheticsFilled(parsed);
case "input_protocol":
return inputProtocolFilled(parsed);
case "core_premises":
return corePremisesFilled(parsed);
default:
return false;
}
}
function annotate(
unit: CreationUnitView,
accepted: Set<string>,
current: string | null,
): CreationUnitView {
const acceptedHit = [...accepted].some((id) => unitIdMatches(id, unit.id));
const currentHit =
current != null &&
(unitIdMatches(current, unit.id) || current === unit.id);
return {
...unit,
accepted: acceptedHit,
current: currentHit,
};
}
export type ListCreationUnitsOptions = {
acceptedUnitIds?: string[];
currentUnitId?: string | null;
/**
* true列出全部示例话题调试用
* 默认 false只列草稿里**已有内容**的固定块 + workers + resident——避免当成填空表。
*/
includeCatalogFixed?: boolean;
};
/**
* 列出创作单位:已写出的固定上下文 + workers + resident同级
* 空的示例话题默认不出现在列表里。
*/
export function listCreationUnits(
parsed: ParsedWorkerSet | null | undefined,
options: ListCreationUnitsOptions = {},
): CreationUnitView[] {
if (!parsed) return [];
const accepted = new Set(
(options.acceptedUnitIds ?? []).map(normalizeCreationUnitId),
);
const currentRaw = options.currentUnitId?.trim() || null;
const current = currentRaw ? normalizeCreationUnitId(currentRaw) : null;
const includeCatalog = options.includeCatalogFixed === true;
const units: CreationUnitView[] = [];
const coreFilled = corePhaseFilled(parsed);
if (includeCatalog || coreFilled) {
units.push(
annotate(
{
id: "phase:core",
kind: "phase",
label: "A · 核心",
filled: coreFilled,
weighty: true,
detail: "站位 / 系统扮演 / 体验骨架",
},
accepted,
current,
),
);
}
for (const cat of FIXED_CONTEXT_CATALOG) {
// interaction 已并入 phase:core避免重复列出
if (cat.id === "fixed:interaction") continue;
const filled = fixedFilled(cat.flavor, parsed);
if (!includeCatalog && !filled) continue;
units.push(
annotate(
{
id: cat.id,
kind: "fixed",
flavor: cat.flavor,
label: cat.label,
filled,
weighty: cat.weighty,
detail: cat.hint,
},
accepted,
current,
),
);
}
for (let i = 0; i < parsed.workers.length; i++) {
const w = parsed.workers[i]!;
const ref = w.ref?.trim() || null;
const id = ref ? `worker:${ref}` : `worker:#${i + 1}`;
units.push(
annotate(
{
id,
kind: "worker",
label: workerLabel(ref, w.name, w.role, w.duty),
filled: workerFilled(w),
weighty: true,
detail: w.duty?.trim() || w.rationale?.trim(),
},
accepted,
current,
),
);
}
const residents = parseResidentContext(parsed.resident_context);
for (const r of residents) {
// 已由 aesthetics 启发式覆盖的 tone 类仍单独列出(挂载/正文可单独验收)
const id = `resident:${r.id}`;
units.push(
annotate(
{
id,
kind: "fixed",
flavor: "resident",
label: r.id,
filled: Boolean(r.content.trim()),
weighty: false,
detail: r.content.trim().slice(0, 80),
},
accepted,
current,
),
);
}
const refineFilled = refinePhaseFilled(parsed);
if (includeCatalog || refineFilled) {
units.push(
annotate(
{
id: "phase:refine",
kind: "phase",
label: "C · 细化",
filled: refineFilled,
weighty: true,
detail: "表 / 副作用 / 数据拓扑",
},
accepted,
current,
),
);
}
return units;
}
/**
* 下一个未验收单位(软启发,非硬闸):
* 核心 → 已开写的固定块 → 纲领类固定上下文 → worker → 其余 → 细化
*
* 固定上下文先于 worker便于同一份风格/叙事/美学挂到多个 worker
* 避免「先写完 worker 再逐个填上下文」。
* 已开写的 fixed/phase 仍优先续完filled pending
*/
export function nextCreationUnitId(
parsed: ParsedWorkerSet | null | undefined,
acceptedUnitIds: string[] = [],
): string | null {
if (!parsed) return "phase:core";
const units = listCreationUnits(parsed, {
acceptedUnitIds,
includeCatalogFixed: true,
});
const core = units.find((u) => u.id === "phase:core");
if (core && !core.accepted) return "phase:core";
const filledFixedPending = units.find(
(u) =>
(u.kind === "fixed" || u.kind === "phase") &&
u.id !== "phase:core" &&
!u.accepted &&
u.filled,
);
if (filledFixedPending) return filledFixedPending.id;
const foundationFixed = units.find(
(u) => u.kind === "fixed" && u.weighty && !u.accepted,
);
if (foundationFixed) return foundationFixed.id;
const filledWorkerPending = units.find(
(u) => u.kind === "worker" && !u.accepted && u.filled,
);
if (filledWorkerPending) return filledWorkerPending.id;
const worker = units.find((u) => u.kind === "worker" && !u.accepted);
if (worker) return worker.id;
const otherFixed = units.find(
(u) => u.kind === "fixed" && !u.weighty && !u.accepted,
);
if (otherFixed) return otherFixed.id;
const refine = units.find((u) => u.id === "phase:refine");
if (refine && !refine.accepted) return "phase:refine";
return null;
}
export function parseAcceptedUnits(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((x) => String(x).trim()).filter(Boolean).map(normalizeCreationUnitId);
}
if (typeof raw === "string" && raw.trim()) {
try {
const doc = JSON.parse(raw) as unknown;
if (Array.isArray(doc)) {
return doc.map((x) => String(x).trim()).filter(Boolean).map(normalizeCreationUnitId);
}
} catch {
return raw
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean)
.map(normalizeCreationUnitId);
}
}
return [];
}
export function isFinalWorkerSetArtifact(artifact: {
workerId: string;
outputTags: string[];
}): boolean {
return artifact.outputTags.includes(WORKER_SET_FINAL_TAG);
}
/** 创作磁盘 skill 产出但尚未写终稿 tag → 单位验收 */
export function isDesignUnitArtifact(artifact: {
workerId: string;
outputTags: string[];
}): boolean {
return (
isDesignDiskWorker(artifact.workerId) && !isFinalWorkerSetArtifact(artifact)
);
}
/** 总管选 skill有流程待执行 → design-step否则 design-flow */
export function designWorkerForUnit(unitId: string | null | undefined): DesignDiskWorkerId {
const id = (unitId ?? "").trim();
if (id === "flow" || !id) return "design-flow";
return "design-step";
}
export type AcceptedUnitContentEntry = {
unitId: string;
acceptedAt: string;
summary?: string;
/** 该单位验收时的内容切片 */
content: unknown;
};
export type AcceptedContentStore = {
version: 1;
units: Record<string, AcceptedUnitContentEntry>;
};
export function parseAcceptedContentStore(raw: unknown): AcceptedContentStore {
if (typeof raw === "string" && raw.trim()) {
try {
return parseAcceptedContentStore(JSON.parse(raw) as unknown);
} catch {
return { version: 1, units: {} };
}
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { version: 1, units: {} };
}
const row = raw as Record<string, unknown>;
const unitsRaw = row.units;
const units: Record<string, AcceptedUnitContentEntry> = {};
if (unitsRaw && typeof unitsRaw === "object" && !Array.isArray(unitsRaw)) {
for (const [k, v] of Object.entries(unitsRaw as Record<string, unknown>)) {
if (!v || typeof v !== "object" || Array.isArray(v)) continue;
const e = v as Record<string, unknown>;
const unitId = normalizeCreationUnitId(
typeof e.unitId === "string" ? e.unitId : k,
);
units[unitId] = {
unitId,
acceptedAt:
typeof e.acceptedAt === "string" ? e.acceptedAt : new Date().toISOString(),
summary: typeof e.summary === "string" ? e.summary : undefined,
content: e.content,
};
}
}
return { version: 1, units };
}
/** 从草稿抽出某一创作单位在验收时的内容 */
export function extractUnitContentFromDraft(
parsed: ParsedWorkerSet | null | undefined,
unitId: string,
): unknown | null {
if (!parsed) return null;
const id = normalizeCreationUnitId(unitId.trim());
if (!id) return null;
if (id === "phase:core") {
const slice: Record<string, unknown> = {};
if (parsed.interaction) slice.interaction = parsed.interaction;
if (parsed.experience_check) slice.experience_check = parsed.experience_check;
return Object.keys(slice).length ? slice : null;
}
if (id === "phase:refine") {
return parsed.tables ? { tables: parsed.tables } : null;
}
if (id.startsWith("worker:")) {
const ref = id.slice("worker:".length);
const entry = parsed.workers.find((w) => (w.ref?.trim() || "") === ref);
return entry ?? null;
}
if (id === "fixed:narrative_guide") {
return textFilled(parsed.narrative_guide)
? { narrative_guide: parsed.narrative_guide }
: null;
}
if (id === "fixed:input_protocol") {
return parsed.input_protocol ? { input_protocol: parsed.input_protocol } : null;
}
if (id === "fixed:core_premises") {
return (parsed.core_premises?.length ?? 0) > 0
? { core_premises: parsed.core_premises }
: null;
}
if (id === "fixed:aesthetics") {
const presentations = parsed.workers
.filter((w) => w.presentation)
.map((w) => ({ ref: w.ref, presentation: w.presentation }));
return presentations.length ? { presentations } : null;
}
if (id === "fixed:interaction") {
return parsed.interaction ? { interaction: parsed.interaction } : null;
}
if (id.startsWith("resident:")) {
const rid = id.slice("resident:".length);
const residents = parseResidentContext(parsed.resident_context);
const hit = residents.find((r) => r.id === rid);
return hit ?? null;
}
return null;
}
/** 写入/覆盖某一单位的最后验收内容 */
export function upsertAcceptedUnitContent(
existingRaw: unknown,
entry: {
unitId: string;
content: unknown;
summary?: string;
acceptedAt?: string;
},
): string {
const store = parseAcceptedContentStore(existingRaw);
const unitId = normalizeCreationUnitId(entry.unitId);
store.units[unitId] = {
unitId,
acceptedAt: entry.acceptedAt ?? new Date().toISOString(),
summary: entry.summary,
content: entry.content,
};
return JSON.stringify(store, null, 2);
}
/** 前情提要:把已验收单位内容格式化为只读 Markdown */
export function formatAcceptedContentForPrompt(raw: unknown): string {
const store = parseAcceptedContentStore(raw);
const entries = Object.values(store.units).sort((a, b) =>
a.acceptedAt.localeCompare(b.acceptedAt),
);
if (entries.length === 0) {
return "(尚无已验收单位)";
}
return entries
.map((e) => {
const head = `### ${e.unitId}${e.summary ? ` · ${e.summary}` : ""}`;
const meta = `验收于 ${e.acceptedAt} · **只读,勿擅自改写**`;
const body =
typeof e.content === "string"
? e.content
: JSON.stringify(e.content ?? null, null, 2);
return `${head}\n${meta}\n\n\`\`\`json\n${body}\n\`\`\``;
})
.join("\n\n");
}
/**
* 完整 Worker 集是否可交终稿。
* 具名 weighty 固定单位:已填的必须已验收;至少 1 个 filled worker 已验收。
*/
export function isWorkerSetReadyForFinal(
parsed: ParsedWorkerSet | null | undefined,
acceptedUnitIds: string[],
): { ready: boolean; missing: string[] } {
if (!parsed) return { ready: false, missing: ["(无草稿)"] };
const accepted = acceptedUnitIds.map(normalizeCreationUnitId);
const units = listCreationUnits(parsed, { acceptedUnitIds: accepted });
const missing = units
.filter((u) => u.weighty && u.filled && !u.accepted)
.map((u) => u.id);
const acceptedWorkers = units.filter(
(u) => u.kind === "worker" && u.accepted && u.filled,
);
const interaction = units.find((u) => u.id === "fixed:interaction");
const core = units.find((u) => u.id === "phase:core");
if (core && core.filled && !core.accepted) {
return { ready: false, missing: missing.length ? missing : [core.id] };
}
if (interaction && !interaction.accepted && interaction.filled) {
return { ready: false, missing: missing.length ? missing : [interaction.id] };
}
if (acceptedWorkers.length === 0) {
return {
ready: false,
missing: missing.length ? missing : ["(至少一个 worker 单位)"],
};
}
if (missing.length) return { ready: false, missing };
return { ready: true, missing: [] };
}

View File

@@ -0,0 +1,260 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import type { Blackboard } from "../blackboard/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import type { AcceptanceMode } from "../types/runtime.js";
import type { ParsedWorkerSkill } from "./types.js";
import {
parseWorkerSetYaml,
type ParsedWorkerSet,
type WorkerSetEntry,
} from "./worker-set-parse.js";
import {
buildInstanceWorkerDeclaration,
inferLifecycleStage,
readWorkerSetYamlForDeclaration,
} from "./worker-declaration.js";
import { loadWorkerSkill, SKILLS_ROOT } from "./loader.js";
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
import { isDesignDiskWorker } from "./creation-units.js";
import {
entriesForWorker,
formatResidentPromptSection,
parseResidentContext,
residentTagFor,
} from "./resident-context.js";
type WorkerTemplateDoc = {
id?: string;
label?: string;
duty?: string;
prompt_excerpt?: string;
suggested_context?: { static?: string[]; dynamic?: string[] };
suggested_outputs?: string[];
};
/**
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - Worker 集 acceptance: review → user_confirmedcontinue → no_confirmation
* - 未声明 acceptancedesign 生命周期默认确认play 缺省按 review稳妥
*/
export function resolveAcceptanceModeForWorker(params: {
session: RuntimeSession;
blackboard: Blackboard;
workerId: string;
}): AcceptanceMode {
const workerId = params.workerId.trim();
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed";
}
const decl = buildInstanceWorkerDeclaration(
params.session,
params.blackboard,
inferLifecycleStage(params.session),
);
const entry = decl.parsed?.workers.find((w) => w.ref?.trim() === workerId);
if (entry?.acceptance === "continue") return "no_confirmation";
if (entry?.acceptance === "review") return "user_confirmed";
// 未写明:创作阶段默认验收;游玩缺省也验收(避免静默连跑)
return "user_confirmed";
}
/** 从声明 + 可选模板构建可执行 worker有磁盘 SKILL 时优先磁盘design-intake */
export async function resolveRunnableWorker(params: {
skillPackName: string;
workerId: string;
session: RuntimeSession;
blackboard: Blackboard;
skillsRoot?: string;
}): Promise<{
worker: ParsedWorkerSkill;
promptBody: string;
source: "disk" | "declaration";
}> {
const root = params.skillsRoot ?? SKILLS_ROOT;
try {
const { loadWorkerSkillWithContext } = await import("./loader.js");
const flowRaw = params.blackboard.getContentByTag("设计.创作流程");
const currentStepName = params.blackboard.getContentByTag("创作.当前步骤");
const selectedRecipeRef = params.blackboard.getContentByTag("创作.选用配方");
const acceptedRaw =
params.session.slots?.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
const { parseAcceptedSteps } = await import("./creation-flow.js");
const withCtx = await loadWorkerSkillWithContext(
params.skillPackName,
params.workerId,
root,
{
flowRaw,
currentStepName,
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
selectedRecipeRef,
},
);
return {
worker: withCtx.worker,
promptBody: withCtx.promptBody,
source: "disk",
};
} catch {
// fall through to declaration
}
const raw = readWorkerSetYamlForDeclaration(params.blackboard, params.session);
const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
const entry = parsed?.workers.find(
(w) => w.ref?.trim() === params.workerId.trim(),
);
if (!entry?.ref) {
throw new Error(
`未找到 worker「${params.workerId}」的磁盘 SKILL且 设计.worker集 中无对应声明`,
);
}
const template = await loadWorkerTemplate(
params.skillPackName,
entry.ref,
root,
);
const built = buildDeclaredWorkerSkill({
skillPackName: params.skillPackName,
entry,
template,
workerSet: parsed,
});
return { ...built, source: "declaration" };
}
async function loadWorkerTemplate(
skillPackName: string,
workerId: string,
skillsRoot: string,
): Promise<WorkerTemplateDoc | null> {
const { loadSkill } = await import("./loader.js");
let packRoot: string | undefined;
try {
const skill = await loadSkill(skillPackName, skillsRoot);
packRoot = skill.skillPackRoot;
} catch {
return null;
}
if (!packRoot) return null;
const file = path.join(
skillsRoot,
packRoot,
"worker-templates",
`${workerId}.yaml`,
);
try {
const raw = await readFile(file, "utf8");
const doc = parseYaml(raw) as WorkerTemplateDoc;
return doc && typeof doc === "object" ? doc : null;
} catch {
return null;
}
}
export function buildDeclaredWorkerSkill(params: {
skillPackName: string;
entry: WorkerSetEntry;
template: WorkerTemplateDoc | null;
workerSet: ParsedWorkerSet | null;
}): { worker: ParsedWorkerSkill; promptBody: string } {
const id = params.entry.ref!.trim();
const staticTags =
params.entry.context?.static ??
params.template?.suggested_context?.static ??
["设计.worker集", CONTEXT_BRIEF_TAG];
const dynamicTags =
params.entry.context?.dynamic ??
params.template?.suggested_context?.dynamic ??
["用户.最新输入"];
const outputTags =
params.entry.outputs?.length
? params.entry.outputs
: params.template?.suggested_outputs?.length
? params.template.suggested_outputs
: ["输出.用户展示"];
const resident = parseResidentContext(params.workerSet?.resident_context);
const residentForWorker = entriesForWorker(resident, id);
const residentStaticTags = residentForWorker
.filter((e) => e.position !== "dynamic")
.map(residentTagFor);
const residentDynamicTags = residentForWorker
.filter((e) => e.position === "dynamic")
.map(residentTagFor);
const inputTags = [
...new Set([
...staticTags,
...residentStaticTags,
...dynamicTags,
...residentDynamicTags,
CONTEXT_BRIEF_TAG,
]),
];
const duty =
params.entry.duty?.trim() ||
params.template?.duty?.trim() ||
`执行 ${id}`;
const excerpt = params.template?.prompt_excerpt?.trim() || "";
const presentation = params.entry.presentation
? JSON.stringify(params.entry.presentation, null, 2)
: "";
const narrative = params.workerSet?.narrative_guide?.trim() || "";
const premises = (params.workerSet?.core_premises ?? []).filter(Boolean);
const residentSection = formatResidentPromptSection(resident, id);
const body = [
`# ${params.template?.label ?? id}`,
"",
"## 角色与职责",
"",
duty,
"",
excerpt ? `## 写法要点\n\n${excerpt}` : "",
presentation ? `## presentation实例\n\n\`\`\`json\n${presentation}\n\`\`\`` : "",
narrative ? `## 叙事指南\n\n${narrative}` : "",
premises.length
? `## 核心实现前提\n\n${premises.map((p) => `- ${p}`).join("\n")}`
: "",
residentSection,
params.entry.rationale
? `## 为何需要本 worker\n\n${params.entry.rationale}`
: "",
"",
"## 输出",
"",
`写入 outputTags${outputTags.join("、")}`,
]
.filter(Boolean)
.join("\n");
const worker: ParsedWorkerSkill = {
id,
skill: params.skillPackName,
name: params.template?.label ?? id,
description: duty.slice(0, 200),
version: 1,
inputTags,
outputTags,
inputMerge: "latest",
path: `declaration:${id}`,
body,
};
const promptBody = [
"(本 worker 由 设计.worker集 声明驱动,无独立磁盘 SKILL。",
"",
body,
].join("\n");
return { worker, promptBody };
}

View File

@@ -2,7 +2,7 @@ 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 { BookKind, SkillStartupMode } from "../types/runtime.js";
import type {
ParsedSkill,
ParsedWorkerSkill,
@@ -10,13 +10,17 @@ import type {
SkillWorkerLlmBindings,
StartupInquiry,
} from "./types.js";
import { parseContextSegments } from "./context-segments.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";
/** 部分环境写 SKILL.md 会损坏非 ASCII允许同目录 body.md 作为回退 */
export const WORKER_SKILL_FALLBACK_FILENAME = "body.md";
export const DEFAULT_SHARED_CONTEXT_FILENAME = "shared-context.md";
export const DESIGN_COMMON_FILENAME = "design-common.md";
export const LLM_BINDINGS_FILENAME = "llm-bindings.yaml";
/** 按 Book 形态分文件夹skill 可为平铺 .md 或 {name}/orchestrator.md 包 */
@@ -28,11 +32,10 @@ type RegistryDoc = {
>;
};
/** 解析 YAML frontmatter仅支持本项目用到的简单字段 */
function parseFrontmatter(raw: string): {
meta: Record<string, string | string[] | number>;
body: string;
} {
type FrontmatterMeta = Record<string, unknown>;
/** 解析 YAML frontmatter使用 yaml 包,支持折叠标量与列表) */
function parseFrontmatter(raw: string): { meta: FrontmatterMeta; body: string } {
if (!raw.startsWith("---")) {
return { meta: {}, body: raw };
}
@@ -40,44 +43,17 @@ function parseFrontmatter(raw: string): {
if (end === -1) {
return { meta: {}, body: raw };
}
const yaml = raw.slice(3, end).trim();
const yamlText = 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;
let meta: FrontmatterMeta = {};
try {
const parsed = parseYaml(yamlText);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
meta = parsed as FrontmatterMeta;
}
} catch {
meta = {};
}
flushList();
return { meta, body };
}
@@ -119,14 +95,17 @@ function parseStartupInquiry(section: string): StartupInquiry {
return { prompt, targetKey, requiredFields: required, optionalFields: optional };
}
function metaString(meta: Record<string, string | string[] | number>, key: string): string {
function metaString(meta: FrontmatterMeta, key: string): string {
const v = meta[key];
return typeof v === "string" ? v : "";
if (typeof v === "string") return v;
if (typeof v === "number") return String(v);
return "";
}
function metaStringArray(meta: Record<string, string | string[] | number>, key: string): string[] {
function metaStringArray(meta: FrontmatterMeta, key: string): string[] {
const v = meta[key];
return Array.isArray(v) ? v : [];
if (!Array.isArray(v)) return [];
return v.map((item) => String(item));
}
function parseBookKind(value: string): BookKind | undefined {
@@ -147,7 +126,7 @@ function skillPackRootFromPath(relativePath: string): string | undefined {
return undefined;
}
function workerIdsFromMeta(meta: Record<string, string | string[] | number>): string[] {
function workerIdsFromMeta(meta: FrontmatterMeta): string[] {
const workers = metaStringArray(meta, "workers");
if (workers.length > 0) return workers;
return metaStringArray(meta, "suggestedWorkers");
@@ -227,7 +206,10 @@ async function parseSkillFile(
const fullPath = path.join(skillsRoot, relativePath);
const raw = await readFile(fullPath, "utf8");
const { meta, body } = parseFrontmatter(raw);
const startupSection = extractSection(body, "启动询问");
const startupSection =
extractSection(body, "启动询问") ||
extractSection(body, "启动agent-first") ||
extractSection(body, "启动");
const folderBookKind = bookKindFromRelativePath(relativePath);
const category = metaString(meta, "category") || folderBookKind || "custom";
const bookKind =
@@ -248,19 +230,35 @@ async function parseSkillFile(
bookKind,
path: normalizedPath,
skillPackRoot: packRoot,
version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1,
version:
typeof meta.version === "number"
? meta.version
: Number(metaString(meta, "version")) || 1,
defaultFlowId: metaString(meta, "defaultFlowId") || undefined,
suggestedWorkers: workerIdsFromMeta(meta),
tags: metaStringArray(meta, "tags"),
sharedContextPath: resolveSharedContextPath(meta, packRoot),
workerLlmBindings,
startupInquiry: parseStartupInquiry(startupSection),
startupInquiry: {
...parseStartupInquiry(startupSection),
targetKey:
metaString(meta, "demandTag") ||
parseStartupInquiry(startupSection).targetKey,
},
startupMode: parseStartupMode(metaString(meta, "startupMode")),
uiPrompt: metaString(meta, "uiPrompt") || undefined,
body,
};
}
function parseStartupMode(raw: string): SkillStartupMode | undefined {
if (raw === "agent-first" || raw === "design-intake") return "agent-first";
if (raw === "intake") return "intake";
return undefined;
}
function resolveSharedContextPath(
meta: Record<string, string | string[] | number>,
meta: FrontmatterMeta,
packRoot: string | undefined,
): string | undefined {
if (!packRoot) return undefined;
@@ -291,17 +289,22 @@ async function parseWorkerSkillFile(
? inputMergeRaw
: undefined;
const llmProfileId = metaString(meta, "llmProfileId") || undefined;
const contextSegments = parseContextSegments(meta.contextSegments);
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,
version:
typeof meta.version === "number"
? meta.version
: Number(metaString(meta, "version")) || 1,
inputTags,
outputTags,
inputMerge,
llmProfileId,
contextSegments: contextSegments.length ? contextSegments : undefined,
path: normalizedPath,
body,
};
@@ -394,7 +397,7 @@ export async function loadSkill(
return parseSkillFile(relativePath, skillsRoot);
}
/** 解析 skill 包内 worker 的 SKILL.md 相对路径 */
/** 解析 skill 包内 worker 的 SKILL.md(或 body.md 回退)相对路径 */
export async function resolveWorkerSkillPath(
skillIdOrName: string,
workerId: string,
@@ -404,13 +407,25 @@ export async function resolveWorkerSkillPath(
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;
const base = `${skill.skillPackRoot}/workers/${workerId}`;
for (const filename of [WORKER_SKILL_FILENAME, WORKER_SKILL_FALLBACK_FILENAME]) {
const relativePath = `${base}/${filename}`;
try {
const raw = await readFile(path.join(skillsRoot, relativePath), "utf8");
// 损坏的 SKILL.md中文变 ?)时跳过,改用 body.md
if (
filename === WORKER_SKILL_FILENAME &&
/\?\?/.test(raw) &&
!/[\u4e00-\u9fff]/.test(raw)
) {
continue;
}
return relativePath;
} catch {
/* try next */
}
}
return null;
}
/** 加载 skill 包固定上下文(注入所有 worker prompt 开头) */
@@ -428,18 +443,146 @@ export async function loadSkillSharedContext(
}
}
/** 加载 worker skill 正文,可选拼接 skill 包固定上下文 */
/** 创作分步 skill 的共同开头(仅 design-* */
export async function loadDesignCommon(
skillIdOrName: string,
skillsRoot = SKILLS_ROOT,
): Promise<string | null> {
const skill = await loadSkill(skillIdOrName, skillsRoot);
if (!skill.skillPackRoot) return null;
const fullPath = path.join(
skillsRoot,
skill.skillPackRoot,
DESIGN_COMMON_FILENAME,
);
try {
return await readFile(fullPath, "utf8");
} catch {
return null;
}
}
/** 加载 worker skill 正文design-flow 注入目录design-step 注入模块 prompt + 动态 tag */
export async function loadWorkerSkillWithContext(
skillIdOrName: string,
workerId: string,
skillsRoot = SKILLS_ROOT,
opts?: {
flowRaw?: string | null;
currentStepName?: string | null;
acceptedStepNames?: readonly string[];
/** 用户手动选定的配方 id / 名 */
selectedRecipeRef?: string | null;
},
): 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 };
const skill = await loadSkill(skillIdOrName, skillsRoot);
let moduleCatalogBlock: string | null = null;
let modulePromptBlock: string | null = null;
let patchedWorker = worker;
if (workerId.trim() === "design-flow" && skill.skillPackRoot) {
const {
loadModuleCatalog,
resolveSelectedRecipeDetail,
formatDesignFlowContentBlocks,
} = await import("./creation-flow.js");
const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot);
const selectedRecipe = await resolveSelectedRecipeDetail({
skillPackRoot: skill.skillPackRoot,
selectedRecipeRef: opts?.selectedRecipeRef,
skillsRoot,
});
const blocks = formatDesignFlowContentBlocks({
selectedRecipe,
modules,
missingSelection: !selectedRecipe,
});
if (blocks.length) {
moduleCatalogBlock = blocks.join("\n\n");
}
}
if (workerId.trim() === "design-step" && skill.skillPackRoot) {
const {
resolveDesignStepBinding,
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
} = await import("./creation-flow.js");
const binding = await resolveDesignStepBinding({
skillPackRoot: skill.skillPackRoot,
flowRaw: opts?.flowRaw,
currentStepName: opts?.currentStepName,
acceptedStepNames: opts?.acceptedStepNames,
skillsRoot,
});
if (binding) {
const openingNote = binding.opening
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM用户首答在「用户.worker答复」。勿重复同一开场白在其答复与提示词基础上继续追问或产出。`
: "";
modulePromptBlock = `## 【本步方法 · ${binding.module.name}\n\n${binding.modulePrompt.trim()}${openingNote}`;
const baseInputs = [
"用户.需求",
"book.brief",
"用户.最新输入",
"用户.worker答复",
"用户.修订说明",
"设计.创作流程",
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
...binding.depTags,
];
const inputTags = [...new Set(baseInputs)];
const outputTags = [
binding.module.artifact,
CREATION_CURRENT_STEP_TAG,
];
const depSegments = binding.depTags.map((tag, i) => ({
id: `dep-${i}`,
tier: "static" as const,
tags: [tag],
label: `## 【依赖产物 · ${tag}】只读`,
}));
const openingSegment = binding.opening
? [
{
id: "module-opening",
tier: "static" as const,
tags: [CREATION_MODULE_OPENING_TAG],
label: "## 【本步默认问题 · 程序已发出】只读",
},
]
: [];
patchedWorker = {
...worker,
name: `创作 · ${binding.module.name}`,
description: binding.module.declaration,
inputTags,
outputTags,
contextSegments: [
...(worker.contextSegments ?? []),
...openingSegment,
...depSegments,
],
};
}
}
const parts: string[] = [];
if (sharedContext?.trim()) {
parts.push(`# 固定创作上下文\n\n${sharedContext.trim()}`);
}
if (moduleCatalogBlock?.trim()) {
parts.push(moduleCatalogBlock.trim());
}
if (modulePromptBlock?.trim()) {
parts.push(modulePromptBlock.trim());
}
parts.push(patchedWorker.body);
const promptBody = parts.join("\n\n---\n\n");
return { worker: patchedWorker, sharedContext, promptBody };
}
/** 加载 skill 包内专属 worker skill */
@@ -473,9 +616,10 @@ export async function listWorkerSkills(
}
const workers: ParsedWorkerSkill[] = [];
for (const entry of entries.sort()) {
const skillPath = `${skill.skillPackRoot}/workers/${entry}/${WORKER_SKILL_FILENAME}`;
const relativePath = await resolveWorkerSkillPath(skillIdOrName, entry, skillsRoot);
if (!relativePath) continue;
try {
workers.push(await parseWorkerSkillFile(skillPath, skillsRoot));
workers.push(await parseWorkerSkillFile(relativePath, skillsRoot));
} catch {
// skip
}

View File

@@ -0,0 +1,134 @@
/**
* Worker / Agent 结构化追问协议。
* UI左右分页选项卡经 composer 发送payload 须含问+答(可附自由补充)。
*/
import type {
QuestionAnswer,
QuestionItem,
QuestionOption,
} from "../types/questions.js";
export type { QuestionAnswer, QuestionItem, QuestionOption };
function letterId(i: number): string {
return String.fromCharCode(65 + (i % 26));
}
/** 把 askUser 原始值规范成 QuestionItem[](兼容纯 string[] */
export function normalizeQuestions(raw: unknown): QuestionItem[] {
if (!Array.isArray(raw)) return [];
const out: QuestionItem[] = [];
for (let i = 0; i < raw.length; i++) {
const item = raw[i];
if (typeof item === "string") {
const prompt = item.trim();
if (!prompt) continue;
out.push({
id: `q${i + 1}`,
prompt,
allowOther: true,
required: true,
});
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const prompt =
typeof row.prompt === "string"
? row.prompt.trim()
: typeof row.question === "string"
? row.question.trim()
: typeof row.text === "string"
? row.text.trim()
: "";
if (!prompt) continue;
const id =
typeof row.id === "string" && row.id.trim()
? row.id.trim()
: `q${i + 1}`;
let options: QuestionOption[] | undefined;
if (Array.isArray(row.options)) {
options = [];
for (let j = 0; j < row.options.length; j++) {
const opt = row.options[j];
if (typeof opt === "string") {
const label = opt.trim();
if (!label) continue;
options.push({ id: letterId(j), label, editable: true });
continue;
}
if (!opt || typeof opt !== "object" || Array.isArray(opt)) continue;
const o = opt as Record<string, unknown>;
const label =
typeof o.label === "string"
? o.label.trim()
: typeof o.text === "string"
? o.text.trim()
: "";
if (!label) continue;
options.push({
id:
typeof o.id === "string" && o.id.trim()
? o.id.trim()
: letterId(j),
label,
editable: o.editable === false ? false : true,
});
}
if (options.length === 0) options = undefined;
}
out.push({
id,
prompt,
options,
allowOther: row.allowOther === false ? false : true,
required: row.required === false ? false : true,
});
}
return out;
}
/** 发给 AI必须含问题与答案可选自由补充 */
export function formatQuestionAnswersForAi(
questions: QuestionItem[],
answers: QuestionAnswer[],
note?: string,
): string {
const byId = new Map(answers.map((a) => [a.questionId, a]));
const lines: string[] = ["【追问作答】"];
for (const q of questions) {
const a = byId.get(q.id);
const answer = a?.text?.trim() || "(未答)";
lines.push(`问:${q.prompt}`);
lines.push(`答:${answer}`);
lines.push("");
}
const trimmedNote = note?.trim();
if (trimmedNote) {
lines.push("【补充】");
lines.push(trimmedNote);
}
return lines.join("\n").trim();
}
/** 气泡摘要:答句 + 可选补充 */
export function formatQuestionAnswersForDisplay(
questions: QuestionItem[],
answers: QuestionAnswer[],
note?: string,
): string {
const byId = new Map(answers.map((a) => [a.questionId, a]));
const parts: string[] = [];
for (const q of questions) {
const a = byId.get(q.id);
if (!a?.text?.trim()) continue;
parts.push(a.text.trim());
}
const trimmedNote = note?.trim();
if (trimmedNote) parts.push(trimmedNote);
return parts.length ? parts.join("\n") : "(已提交追问作答)";
}
export function questionPrompts(questions: QuestionItem[]): string[] {
return questions.map((q) => q.prompt).filter(Boolean);
}

View File

@@ -0,0 +1,128 @@
/**
* 常驻上下文:按 Worker 集 resident_context 挂载到指定 worker。
* 写入黑板 tag并并入该 worker 的 static 输入。
*/
import type { Blackboard } from "../blackboard/blackboard.js";
export type ResidentContextEntry = {
id: string;
/** 注入位提示static | dynamic拼装分层 */
position?: "static" | "dynamic";
importance?: number;
content: string;
/** 挂载到哪些 worker ref空 = 全部声明 worker */
mount?: string[];
/** 显式 tag默认 上下文.常驻.{id} */
tag?: string;
};
export function parseResidentContext(raw: unknown): ResidentContextEntry[] {
if (!Array.isArray(raw)) return [];
const out: ResidentContextEntry[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const id =
typeof row.id === "string"
? row.id.trim()
: typeof row.key === "string"
? row.key.trim()
: "";
const content =
typeof row.content === "string"
? row.content
: typeof row.text === "string"
? row.text
: typeof row.summary === "string"
? row.summary
: "";
if (!id || !content.trim()) continue;
const mount = Array.isArray(row.mount)
? row.mount
.filter((m): m is string => typeof m === "string" && m.trim().length > 0)
.map((m) => m.trim())
: Array.isArray(row.workers)
? row.workers
.filter((m): m is string => typeof m === "string" && m.trim().length > 0)
.map((m) => m.trim())
: undefined;
const position =
row.position === "dynamic" || row.tier === "dynamic"
? "dynamic"
: row.position === "static" || row.tier === "static"
? "static"
: "static";
out.push({
id,
position,
importance:
typeof row.importance === "number" ? row.importance : undefined,
content: content.trim(),
mount,
tag: typeof row.tag === "string" && row.tag.trim() ? row.tag.trim() : undefined,
});
}
return out.sort((a, b) => (b.importance ?? 0) - (a.importance ?? 0));
}
export function residentTagFor(entry: ResidentContextEntry): string {
return entry.tag ?? `上下文.常驻.${entry.id}`;
}
export function entriesForWorker(
entries: ResidentContextEntry[],
workerId: string,
): ResidentContextEntry[] {
const id = workerId.trim();
return entries.filter((e) => {
if (!e.mount || e.mount.length === 0) return true;
return e.mount.includes(id);
});
}
/**
* 把匹配本 worker 的常驻块写入黑板,返回应并入 inputTags 的 tag 列表(按 position
*/
export function mountResidentContextForWorker(params: {
blackboard: Blackboard;
entries: ResidentContextEntry[];
workerId: string;
source?: string;
}): { staticTags: string[]; dynamicTags: string[]; written: string[] } {
const matched = entriesForWorker(params.entries, params.workerId);
const staticTags: string[] = [];
const dynamicTags: string[] = [];
const written: string[] = [];
const source = params.source ?? "system:resident-context";
for (const entry of matched) {
const tag = residentTagFor(entry);
const existing = params.blackboard.getContentByTag(tag);
if (existing !== entry.content) {
params.blackboard.write({
tag,
content: entry.content,
source,
});
written.push(tag);
}
if (entry.position === "dynamic") dynamicTags.push(tag);
else staticTags.push(tag);
}
return { staticTags, dynamicTags, written };
}
/** 拼进声明驱动 prompt 的常驻段(无黑板时也可纯文本注入) */
export function formatResidentPromptSection(
entries: ResidentContextEntry[],
workerId: string,
): string {
const matched = entriesForWorker(entries, workerId);
if (matched.length === 0) return "";
const blocks = matched.map((e) => {
const label = e.id;
return `### ${label}\n\n${e.content}`;
});
return `## 常驻上下文(本 worker 挂载)\n\n${blocks.join("\n\n")}`;
}

View File

@@ -12,6 +12,8 @@ export function toActiveSkillSnapshot(skill: ParsedSkill): ActiveSkillSnapshot {
bookKind: skill.bookKind,
defaultFlowId: skill.defaultFlowId,
suggestedWorkers: skill.suggestedWorkers,
startupMode: skill.startupMode,
uiPrompt: skill.uiPrompt,
startupPrompt: skill.startupInquiry.prompt,
startupTargetKey: skill.startupInquiry.targetKey,
intakeFields,

View File

@@ -1,5 +1,6 @@
import type { BlackboardInputMerge } from "../types/blackboard.js";
import type { AdvancePolicy } from "../types/runtime.js";
import type { AdvancePolicy, SkillStartupMode } from "../types/runtime.js";
import type { ContextSegmentDef } from "./context-segments.js";
export type WorkerLlmBinding = {
/** 固定 ApiProfile.id省略 = 会话默认 */
@@ -53,6 +54,10 @@ export type ParsedSkill = {
/** llm-bindings.yaml可选见 docs/worker-skill-format.md §9 */
workerLlmBindings?: SkillWorkerLlmBindings;
startupInquiry: StartupInquiry;
/** intakelegacy 包)或 agent-first默认UI 引导后 Agent 调度) */
startupMode?: SkillStartupMode;
/** agent-first 首屏固定引导文案 */
uiPrompt?: string;
/** 推进策略预留。loader 第一版不解析 orchestrator ## 推进策略 */
advancePolicy?: AdvancePolicy;
body: string;
@@ -70,6 +75,8 @@ export type ParsedWorkerSkill = {
inputMerge?: BlackboardInputMerge;
/** ApiProfile.id省略 = 走 llm-bindings 或会话默认 */
llmProfileId?: string;
/** 上下半拼装;缺省则 executor 回退 JSON inputs */
contextSegments?: ContextSegmentDef[];
/** 相对 skills/ 的路径 */
path: string;
body: string;

View File

@@ -0,0 +1,166 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import {
deriveDesignStageScope,
deriveRunWorkerScope,
parseWorkerSetYaml,
runWorkerMeta,
type ParsedWorkerSet,
} from "./worker-set-parse.js";
export type LifecycleStage = "design" | "play";
/** 实例 Worker 声明:由创作阶段 设计.worker集 动态定义play 只调度声明内的 id */
export type InstanceWorkerDeclaration = {
/** 声明正文来源 tagnull 表示尚无 Worker 集 */
sourceTag: "设计.worker集" | "设计.worker集.草稿" | null;
/** Worker 集是否已通过用户验收 */
accepted: boolean;
parsed: ParsedWorkerSet | null;
/** 当前 lifecycle 下总管可 run_worker 的 id 列表 */
activeWorkerIds: string[];
/** play 阶段声明deriveRunWorkerScope */
playWorkerIds: string[];
/** 创作末尾声明(如 opening-generator */
designEndWorkerIds: string[];
};
const WORKER_SET_ACCEPTED_TAG = "设计.worker集";
const WORKER_SET_DRAFT_TAG = "设计.worker集.草稿";
export function hasAcceptedWorkerSet(session: RuntimeSession): boolean {
if (Boolean(session.slots.designInstanceReady)) return true;
return session.artifacts.some(
(a) =>
a.status === "accepted" &&
a.outputTags.some((tag) => tag === WORKER_SET_ACCEPTED_TAG),
);
}
export function canEnterPlay(session: RuntimeSession): boolean {
return hasAcceptedWorkerSet(session);
}
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
const override = session.slots.uiLifecycleStage;
if (override === "play" && canEnterPlay(session)) return "play";
return "design";
}
/** 读取用于构建声明的 Worker 集 YAML */
export function readWorkerSetYamlForDeclaration(
blackboard: Blackboard,
session: RuntimeSession,
): { yaml: string; sourceTag: InstanceWorkerDeclaration["sourceTag"] } | null {
const accepted = blackboard.getContentByTag(WORKER_SET_ACCEPTED_TAG)?.trim();
const draft = blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim();
const workerSetAccepted = hasAcceptedWorkerSet(session);
if (workerSetAccepted && accepted) {
return { yaml: accepted, sourceTag: WORKER_SET_ACCEPTED_TAG };
}
if (draft) {
return { yaml: draft, sourceTag: WORKER_SET_DRAFT_TAG };
}
if (accepted) {
return { yaml: accepted, sourceTag: WORKER_SET_ACCEPTED_TAG };
}
return null;
}
/**
* 构建实例 Worker 声明。
* world-simulatorplay 仅 activeWorkerIdsdesign 验收前为分步 design-*。
*/
export function buildInstanceWorkerDeclaration(
session: RuntimeSession,
blackboard: Blackboard,
lifecycle: LifecycleStage = inferLifecycleStage(session),
): InstanceWorkerDeclaration {
const accepted = hasAcceptedWorkerSet(session);
const raw = readWorkerSetYamlForDeclaration(blackboard, session);
const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
const playWorkerIds = accepted && parsed ? deriveRunWorkerScope(parsed) : [];
const designEndWorkerIds =
accepted && parsed ? deriveDesignStageScope(parsed) : [];
const designStepIds = ["design-flow", "design-step"];
let activeWorkerIds: string[];
if (lifecycle === "play") {
activeWorkerIds = [...playWorkerIds];
} else if (!accepted) {
activeWorkerIds = [...designStepIds];
} else {
activeWorkerIds = [...designStepIds, ...designEndWorkerIds];
// 去重保序
const seen = new Set<string>();
activeWorkerIds = activeWorkerIds.filter((id) => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
}
return {
sourceTag: raw?.sourceTag ?? null,
accepted,
parsed,
activeWorkerIds,
playWorkerIds,
designEndWorkerIds,
};
}
export function isWorkerDeclared(
declaration: InstanceWorkerDeclaration,
workerId: string,
): boolean {
const id = workerId.trim();
return declaration.activeWorkerIds.includes(id);
}
export function formatUndeclaredWorkerError(
workerId: string,
declaration: InstanceWorkerDeclaration,
): string {
const allowed =
declaration.activeWorkerIds.length > 0
? declaration.activeWorkerIds.join("、")
: "(尚无)";
return (
`Worker「${workerId}」不在本实例声明内。` +
`当前可调度:${allowed}` +
(declaration.accepted
? " play 阶段仅允许 设计.worker集 中 ref 列出的 Worker。"
: " 请先完成 design-flow → design-step并验收终稿 Worker 集。")
);
}
/** 合并磁盘已安装 SKILL 与声明中的 gap id供 list_workers 展示) */
export function mergeDeclaredWorkersForAgent(
installed: Array<{ id: string; description: string }>,
declaration: InstanceWorkerDeclaration,
): Array<{ id: string; description: string }> {
const byId = new Map(installed.map((w) => [w.id, w]));
const out: Array<{ id: string; description: string }> = [];
for (const id of declaration.activeWorkerIds) {
const found = byId.get(id);
if (found) {
out.push(found);
} else {
const meta = runWorkerMeta(id);
out.push({
id,
description: `[声明已启用 · SKILL 待补] ${meta.purpose}`,
});
}
}
return out;
}
export function shouldEnforceWorkerDeclaration(skillPackName?: string): boolean {
return skillPackName === "world-simulator";
}

View File

@@ -0,0 +1,523 @@
import { parse as parseYaml } from "yaml";
export type WorkerSetPresentation = {
tone?: string;
pacing?: string;
information_layers?: string[];
avoid?: string[];
[key: string]: unknown;
};
export type WorkerSetContext = {
static?: string[];
dynamic?: string[];
};
export type WorkerAcceptance = "review" | "continue";
export type WorkerSetEntry = {
ref: string | null;
/**
* 用户可见中文名(创造 worker 时优先填写)。
* 与 `ref` 分离:`ref` 仍为英文机器 id调度 / 模板 / mount
*/
name?: string;
role?: string;
duty?: string;
when?: string;
rationale?: string;
/**
* run 验收点:本 worker 完成后是否停下来给人读。
* review = 用户验收continue = 可连跑下一 worker。
*/
acceptance?: WorkerAcceptance;
merge_considered?: string;
gap?: string | null;
presentation?: WorkerSetPresentation | null;
/** play 阶段冻结的上下文插入顺序design-intake 设计) */
context?: WorkerSetContext;
/** 本 worker 写入黑板的 tag */
outputs?: string[];
};
export type ParsedWorkerSetInteraction = {
user_stance?: string;
system_role?: string;
output?: string;
turn_shape?: string;
[key: string]: unknown;
};
export type ParsedWorkerSet = {
version?: number;
form_summary?: string;
interaction_paradigm?: string;
/** 新规格:站位 / 系统扮演 / 输出 / 轮转 */
interaction?: ParsedWorkerSetInteraction;
experience_check?: Record<string, unknown>;
core_worker?: string;
reasoning?: string;
play_morphology?: string;
input_protocol?: Record<string, string>;
workers: WorkerSetEntry[];
tag_flow?: string[];
resident_context?: unknown[];
tables?: Record<string, unknown>;
narrative_guide?: string;
core_premises?: string[];
design_end?: Record<string, unknown>;
/** @deprecated 旧字段;新规格用 design_end */
instantiate_hints?: {
invoke?: string[];
skip?: string[];
skip_reason?: string;
notes?: string;
};
open_questions?: string[];
notes?: string;
parseError?: string;
};
/** 创作阶段可选 skill 元数据accept Worker 集之后、play 之前) */
export const DESIGN_STAGE_SKILL_META: Record<
string,
{ label: string; purpose: string }
> = {
"opening-generator": {
label: "开局 · 开场白",
purpose:
"创作末尾:结合已定世界/故事写开场白(主);表初值与开场对齐,能推则推。",
},
};
/**
* @deprecated 旧「run worker → instantiate 管道」映射。Worker 集即实例规格,不再用于推导 design 进度。
*/
export const RUN_TO_INSTANTIATE: Record<string, string[]> = {
"world-simulator": ["world-blueprint"],
narrator: ["narrative-guide"],
"variable-update": ["variable-catalog"],
"input-expand": ["narrative-guide"],
"plot-continue": ["narrative-guide"],
"role-decide": ["generation-rules"],
};
/** @deprecated 旧管道 skill 元数据;仅兼容旧 Worker 集 YAML 展示 */
export const INSTANTIATE_SKILL_META: Record<
string,
{ label: string; purpose: string }
> = {
"world-blueprint": {
label: "世界蓝图",
purpose: "背景板与核心设定,供 world-simulator 等读取。",
},
topology: {
label: "拓扑 / 关系",
purpose: "地图、关系网或进阶路径(按需多次)。",
},
"generation-rules": {
label: "生成规则",
purpose: "元规则:如何生成 NPC、物品等实例内容。",
},
"narrative-guide": {
label: "叙事 / 描写指南",
purpose: "正文 POV、时态、文风narrator 等 static 上下文)。",
},
"variable-catalog": {
label: "变量目录",
purpose: "要跟踪的状态与变化规则variable-update 用)。",
},
corpus: {
label: "语料 / 场景策略",
purpose: "口吻样例、场景模板、描写与节奏策略。",
},
};
export const RUN_WORKER_META: Record<string, { label: string; purpose: string }> =
{
"world-simulator": {
label: "世界模拟",
purpose: "裁决规则、更新事件流与可见信息。",
},
narrator: {
label: "转述 / 展示",
purpose: "把核心/世界层干巴输出转为用户可读回复(文学化或 Markdown 等)。",
},
"variable-update": {
label: "变量更新",
purpose: "跟踪等级、资源、职业等状态变量。",
},
"input-expand": {
label: "输入拓写",
purpose: "把用户裸输入拓写为场景内行动/意图。",
},
"plot-continue": {
label: "剧情续写",
purpose: "基于拓写结果续写剧情片段。",
},
"role-decide": {
label: "角色决策",
purpose: "单个重要角色独立决策(信息隔绝时用)。",
},
"round-present": {
label: "回合陈述",
purpose: "结构化陈述本轮事件与各方行动/思考摘要。",
},
};
function asString(value: unknown): string | undefined {
if (value == null) return undefined;
if (typeof value === "string") return value.trim() || undefined;
return String(value).trim() || undefined;
}
function asStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => asString(item))
.filter((item): item is string => Boolean(item));
}
function parsePresentation(raw: unknown): WorkerSetPresentation | null | undefined {
if (raw == null) return raw === null ? null : undefined;
if (typeof raw !== "object" || Array.isArray(raw)) return undefined;
return raw as WorkerSetPresentation;
}
function parseContext(raw: unknown): WorkerSetContext | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const staticTags = asStringList(row.static);
const dynamicTags = asStringList(row.dynamic);
if (!staticTags.length && !dynamicTags.length) return undefined;
return {
static: staticTags.length ? staticTags : undefined,
dynamic: dynamicTags.length ? dynamicTags : undefined,
};
}
function parseWorkers(raw: unknown): WorkerSetEntry[] {
if (!Array.isArray(raw)) return [];
return raw.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return { ref: null };
}
const row = item as Record<string, unknown>;
const refRaw = row.ref;
const ref =
refRaw == null
? null
: asString(refRaw) ?? (typeof refRaw === "string" ? refRaw : null);
const outputs = asStringList(row.outputs);
const acceptanceRaw = asString(row.acceptance);
const acceptance: WorkerAcceptance | undefined =
acceptanceRaw === "review" || acceptanceRaw === "continue"
? acceptanceRaw
: undefined;
return {
ref,
name: asString(row.name),
role: asString(row.role),
duty: asString(row.duty),
when: asString(row.when),
rationale: asString(row.rationale),
acceptance,
merge_considered: asString(row.merge_considered),
gap: ref == null ? asString(row.gap) ?? null : asString(row.gap) ?? null,
presentation: parsePresentation(row.presentation),
context: parseContext(row.context),
outputs: outputs.length ? outputs : undefined,
};
});
}
function parseInstantiateHints(raw: unknown): ParsedWorkerSet["instantiate_hints"] {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const invoke = asStringList(row.invoke);
const skip = asStringList(row.skip);
const skip_reason = asString(row.skip_reason);
const notes = asString(row.notes);
if (invoke.length === 0 && skip.length === 0 && !skip_reason && !notes) return undefined;
return {
invoke: invoke.length ? invoke : undefined,
skip: skip.length ? skip : undefined,
skip_reason,
notes,
};
}
function parseWorkerSetObject(row: Record<string, unknown>): ParsedWorkerSet {
const interactionRaw = row.interaction;
const interaction =
interactionRaw &&
typeof interactionRaw === "object" &&
!Array.isArray(interactionRaw)
? (interactionRaw as ParsedWorkerSetInteraction)
: undefined;
return {
version: typeof row.version === "number" ? row.version : undefined,
form_summary: asString(row.form_summary),
interaction_paradigm: asString(row.interaction_paradigm),
interaction,
experience_check:
row.experience_check &&
typeof row.experience_check === "object" &&
!Array.isArray(row.experience_check)
? (row.experience_check as Record<string, unknown>)
: undefined,
core_worker: asString(row.core_worker),
reasoning: asString(row.reasoning),
play_morphology: asString(row.play_morphology),
input_protocol:
row.input_protocol &&
typeof row.input_protocol === "object" &&
!Array.isArray(row.input_protocol)
? Object.fromEntries(
Object.entries(row.input_protocol as Record<string, unknown>)
.map(([k, v]) => [k, asString(v) ?? ""])
.filter(([, v]) => v),
)
: undefined,
workers: parseWorkers(row.workers),
tag_flow: asStringList(row.tag_flow),
resident_context: Array.isArray(row.resident_context)
? row.resident_context
: undefined,
tables:
row.tables && typeof row.tables === "object" && !Array.isArray(row.tables)
? (row.tables as Record<string, unknown>)
: undefined,
narrative_guide: asString(row.narrative_guide),
core_premises: asStringList(row.core_premises),
design_end:
row.design_end &&
typeof row.design_end === "object" &&
!Array.isArray(row.design_end)
? (row.design_end as Record<string, unknown>)
: undefined,
instantiate_hints: parseInstantiateHints(row.instantiate_hints),
open_questions: asStringList(row.open_questions),
notes: asString(row.notes),
};
}
/**
* 解析 `设计.worker集`:优先 JSON含从说明文字中抽取 `{...}`),失败再试 YAML兼容旧草稿
* 新产出应为 JSON。散文/提问文字会得到明确的 parseError而不是晦涩的 YAML 报错。
*/
export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | null {
const text = raw?.trim();
if (!text) return null;
const jsonCandidate = extractJsonObjectText(text);
if (jsonCandidate) {
try {
const doc = JSON.parse(jsonCandidate) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return { workers: [], parseError: "Worker 集不是有效的 JSON 对象" };
}
return parseWorkerSetObject(doc as Record<string, unknown>);
} catch (err) {
return {
workers: [],
parseError: err instanceof Error ? err.message : "JSON 解析失败",
};
}
}
// 明显是中文说明/提问,不要丢给 YAML会报 Implicit keys…
if (looksLikeProseNotSpec(text)) {
return {
workers: [],
parseError:
"内容不是 JSON 规格(像是说明或提问文字)。提问应走 askUser规格字段只能是 {…} JSON。",
};
}
try {
const doc = parseYaml(text);
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return { workers: [], parseError: "Worker 集不是有效的对象" };
}
return parseWorkerSetObject(doc as Record<string, unknown>);
} catch (err) {
return {
workers: [],
parseError: err instanceof Error ? err.message : "Worker 集解析失败",
};
}
}
/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */
export function extractJsonObjectText(raw: string): string | null {
const text = raw.trim();
if (!text) return null;
if (text.startsWith("{")) {
try {
JSON.parse(text);
return text;
} catch {
/* fall through to brace scan */
}
}
const fence = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/i);
if (fence?.[1]) {
try {
JSON.parse(fence[1]);
return fence[1].trim();
} catch {
/* continue */
}
}
const first = text.indexOf("{");
const last = text.lastIndexOf("}");
if (first >= 0 && last > first) {
const slice = text.slice(first, last + 1);
try {
JSON.parse(slice);
return slice;
} catch {
return null;
}
}
return null;
}
export function looksLikeProseNotSpec(text: string): boolean {
const t = text.trim();
if (!t) return false;
if (t.startsWith("{") || t.startsWith("[")) return false;
// YAML 文档常见开头
if (/^(---|version:|workers:|interaction:|form_summary:)/m.test(t)) return false;
// 中文叙述 / 明显自然语言
if (/[\u4e00-\u9fff]{8,}/.test(t) && !/^\s*[\w.-]+\s*:/.test(t)) return true;
if (/^(我们|首先|根据|请|用户需求|我(?:们)?被要求)/.test(t)) return true;
return false;
}
/** 是否像一份可用的 Worker 集(而非空壳 / 仅 parseError */
export function isUsableWorkerSet(parsed: ParsedWorkerSet | null | undefined): boolean {
if (!parsed || parsed.parseError) return false;
if ((parsed.workers?.length ?? 0) > 0) return true;
if (parsed.interaction && Object.keys(parsed.interaction).length > 0) return true;
if (parsed.form_summary?.trim() || parsed.interaction_paradigm?.trim()) return true;
if (parsed.narrative_guide?.trim() || (parsed.core_premises?.length ?? 0) > 0) {
return true;
}
return false;
}
/**
* 从 Worker 集推导创作阶段收尾可选 skill如 opening-generator
* 读 design_end / instantiate_hints.invoke + workers[].ref。
*/
export function deriveDesignStageScope(workerSet: ParsedWorkerSet | null): string[] {
if (!workerSet) return [];
const seen = new Set<string>();
const ordered: string[] = [];
const add = (id: string) => {
const key = id.trim();
if (!key || seen.has(key)) return;
seen.add(key);
ordered.push(key);
};
const designEnd = workerSet.design_end;
if (designEnd) {
const opening = designEnd.opening;
if (
opening === "optional" ||
opening === true ||
opening === "opening-generator"
) {
add("opening-generator");
}
for (const id of asStringList(designEnd.invoke)) add(id);
}
for (const id of workerSet.instantiate_hints?.invoke ?? []) add(id);
for (const worker of workerSet.workers) {
const ref = worker.ref?.trim();
if (ref && DESIGN_STAGE_SKILL_META[ref]) add(ref);
}
for (const id of workerSet.instantiate_hints?.skip ?? []) {
seen.delete(id);
const idx = ordered.indexOf(id);
if (idx >= 0) ordered.splice(idx, 1);
}
return ordered;
}
/** @deprecated 请用 deriveDesignStageScope */
export function deriveInstantiateScope(workerSet: ParsedWorkerSet | null): string[] {
return deriveDesignStageScope(workerSet);
}
/** play 阶段启用的 run worker ref 列表(保序、去重;不含 opening-generator 等 design-end skill */
export function deriveRunWorkerScope(workerSet: ParsedWorkerSet | null): string[] {
if (!workerSet) return [];
const seen = new Set<string>();
const ordered: string[] = [];
for (const worker of workerSet.workers) {
const ref = worker.ref?.trim();
if (!ref || seen.has(ref)) continue;
if (DESIGN_STAGE_SKILL_META[ref]) continue;
seen.add(ref);
ordered.push(ref);
}
return ordered;
}
/**
* run 阶段需用户验收的 worker ref 列表acceptance === review
* 未写 acceptance 的不列入(创作时应写全;运行时缺省策略另议)。
*/
export function deriveReviewWorkerScope(workerSet: ParsedWorkerSet | null): string[] {
if (!workerSet) return [];
const seen = new Set<string>();
const ordered: string[] = [];
for (const worker of workerSet.workers) {
const ref = worker.ref?.trim();
if (!ref || seen.has(ref)) continue;
if (DESIGN_STAGE_SKILL_META[ref]) continue;
if (worker.acceptance !== "review") continue;
seen.add(ref);
ordered.push(ref);
}
return ordered;
}
/** 该 run worker 完成后是否应停下来给人验收 */
export function workerRequiresReview(
workerSet: ParsedWorkerSet | null,
workerId: string,
): boolean {
if (!workerSet) return false;
const id = workerId.trim();
const entry = workerSet.workers.find((w) => w.ref?.trim() === id);
return entry?.acceptance === "review";
}
export function instantiateMeta(id: string): { label: string; purpose: string } {
return (
DESIGN_STAGE_SKILL_META[id] ??
INSTANTIATE_SKILL_META[id] ?? {
label: id,
purpose: "创作阶段可选 skill。",
}
);
}
export function runWorkerMeta(id: string): { label: string; purpose: string } {
return (
RUN_WORKER_META[id] ?? {
label: id,
purpose: "play 阶段按需 invoke。",
}
);
}

View File

@@ -0,0 +1,720 @@
import type {
ParsedWorkerSet,
WorkerSetEntry,
WorkerSetPresentation,
} from "./worker-set-parse.js";
import { instantiateMeta, runWorkerMeta } from "./worker-set-parse.js";
import { listCreationUnits, extractUnitContentFromDraft } from "./creation-units.js";
import type { CreationUnitView } from "./creation-units.js";
import {
parseResidentContext,
residentTagFor,
type ResidentContextEntry,
} from "./resident-context.js";
export type TagLineView = {
tag: string;
note?: string;
filled?: boolean;
};
export type ContextLayerView = {
staticTags: TagLineView[];
dynamicTags: TagLineView[];
/** false = 部分来自包内默认模板design-intake 未写全 context */
explicit: boolean;
};
export type PresentationLineView = {
label: string;
value: string;
};
/** tag / 固定上下文 → 会塞进哪些 worker与 worker 卡片解耦) */
export type ContextTagMountView = {
workerId: string | null;
workerName: string;
/** prompt_body=写入该 worker 提示词正文input_tag=声明为黑板读入resident=常驻挂载 */
how: "prompt_body" | "input_tag" | "resident";
tier?: "static" | "dynamic";
};
export type ContextTagCardView = {
/** fixed:… / resident:… / board:tag名 */
id: string;
label: string;
kind: "fixed" | "resident" | "board";
/** 内容预览(截断) */
preview?: string;
filled: boolean;
mounts: ContextTagMountView[];
/** 给人看的挂载摘要,如「全部 Worker」「叙事转述 · 世界模拟」 */
mountSummary: string;
};
export type WorkerCardView = {
order: number;
id: string | null;
displayName: string;
roleLabel?: string;
status: "ready" | "gap";
gapNote?: string;
purpose: string;
invokeWhen: string;
rationale?: string;
/** run完成后是否停下来给人验收 */
acceptance?: "review" | "continue";
acceptanceLabel?: string;
mergeConsidered?: string;
/** @deprecated UI 以 contextTags 独立区为准;卡片上只展示 readsSummary */
context: ContextLayerView;
/** 本 worker 会读/注入的上下文短摘要(不含完整 tag 列表) */
readsSummary?: string;
writes: string[];
presentation?: PresentationLineView[];
};
export type TagFlowEdgeView = {
from: string[];
to: string[];
};
export type DesignTaskView = {
id: string;
label: string;
status: "planned" | "skipped" | "filled";
skipReason?: string;
};
export type WorkerSetUserView = {
headline?: string;
interactionParadigm?: string;
coreWorker?: string;
reasoning?: string;
playModeLabel?: string;
playModeHint?: string;
inputProtocol?: PresentationLineView[];
workers: WorkerCardView[];
/**
* 固定 / 常驻 / 黑板上下文(独立于 worker 卡)。
* 每张卡标明会塞进哪些 worker。
*/
contextTags?: ContextTagCardView[];
tagFlow: TagFlowEdgeView[];
designTasks: DesignTaskView[];
/** 创作单位worker 规格 + 固定插入上下文(同级) */
creationUnits?: CreationUnitView[];
/**
* 当前创作单位:用于验收主舞台聚焦。
* fixed/resident → 高亮「本块上下文」正文worker → 高亮该卡。
*/
focusUnit?: {
id: string;
kind: CreationUnitView["kind"];
label: string;
/** fixed / resident / phase 时抽出的本块内容(给 UI 高亮展示) */
body?: unknown;
};
openQuestions: string[];
notes?: string;
parseError?: string;
};
export type FormatWorkerSetOptions = {
/** 黑板上已有内容的 tag 名(用于设计任务 / static 是否已填) */
filledTags?: string[];
acceptedUnitIds?: string[];
currentUnitId?: string | null;
};
const PLAY_MORPHOLOGY: Record<string, { label: string; hint: string }> = {
action_reaction_loop: {
label: "行动–反应循环",
hint: "你输入一句,世界推进并回复一段",
},
chain: {
label: "链式输出",
hint: "按顺序生成,通常无多轮世界机",
},
single: {
label: "单次生成",
hint: "一次产出成稿,非多轮交互",
},
fork_review: {
label: "分叉验收",
hint: "生成后分角色验收,非交互小说",
},
multi_actor_sim: {
label: "多角色模拟",
hint: "多个角色各自决策,世界裁决后汇总",
},
};
const PRESENTATION_LABELS: Record<string, string> = {
mode: "输出模式",
tone: "语气",
pacing: "节奏",
information_layers: "信息层",
avoid: "避免",
layers: "信息层",
};
const TAG_NOTES: Record<string, string> = {
"设计.worker集": "实例分工与展示要求(本 worker 读切片)",
"世界.蓝图.确认稿": "设计阶段写入,游玩时只读",
"变量.目录.确认稿": "设计阶段写入,游玩时只读",
"变量.变化规则.确认稿": "设计阶段写入,游玩时只读",
"叙事.指南.确认稿": "设计阶段写入,游玩时只读",
"语料.场景策略集.确认稿": "设计阶段写入,游玩时只读",
"输出.回复格式.规范": "设计阶段写入,游玩时只读",
"变量.当前": "每轮更新",
"运行.事件流": "每轮追加",
"用户.最新输入": "每轮用户输入",
};
/** 包内 run worker 默认上下文契约design-intake 未写 context 时合并) */
const DEFAULT_WORKER_CONTRACTS: Record<
string,
{ context: { static: string[]; dynamic: string[] }; outputs: string[] }
> = {
"world-simulator": {
context: {
static: [
"设计.worker集",
"世界.蓝图.确认稿",
"世界.拓扑.*",
"变量.目录.确认稿",
"变量.变化规则.确认稿",
],
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入"],
},
outputs: ["运行.本轮.裁决", "运行.事件流"],
},
"variable-update": {
context: {
static: ["变量.目录.确认稿", "变量.变化规则.确认稿"],
dynamic: ["运行.本轮.裁决", "变量.当前"],
},
outputs: ["运行.本轮.变量变更", "变量.当前"],
},
narrator: {
context: {
static: [
"设计.worker集",
"叙事.指南.确认稿",
"语料.场景策略集.确认稿",
"输出.回复格式.规范",
],
dynamic: ["运行.事件流", "变量.当前", "运行.本轮.变量变更"],
},
outputs: ["输出.用户展示"],
},
"input-expand": {
context: {
static: ["设计.worker集", "叙事.指南.确认稿", "用户.需求"],
dynamic: ["用户.最新输入"],
},
outputs: ["运行.本轮.拓写"],
},
"plot-continue": {
context: {
static: ["设计.worker集", "叙事.指南.确认稿", "世界.蓝图.确认稿"],
dynamic: ["运行.本轮.拓写", "运行.事件流"],
},
outputs: ["运行.本轮.续写"],
},
"role-decide": {
context: {
static: ["设计.worker集", "世界.生成规则.*", "实例.角色.*"],
dynamic: ["运行.事件流", "可见信息"],
},
outputs: ["运行.本轮.角色决策"],
},
};
const INPUT_PROTOCOL_LABELS: Record<string, string> = {
parens: "()圆括号",
quotes: "「」/ \"\" 台词",
brackets: "【】行动",
default: "默认规则",
};
const WORKER_ROLE_LABELS: Record<string, string> = {
core: "核心",
auxiliary: "辅助",
transcription: "转述",
};
function tagMatchesPattern(tag: string, pattern: string): boolean {
if (pattern.endsWith(".*")) {
const prefix = pattern.slice(0, -2);
return tag === prefix || tag.startsWith(`${prefix}.`);
}
return tag === pattern;
}
function tagFilled(tag: string, filledTags: string[]): boolean {
if (filledTags.some((t) => tagMatchesPattern(t, tag))) return true;
if (tag.endsWith(".*")) {
const prefix = tag.slice(0, -2);
return filledTags.some((t) => t === prefix || t.startsWith(`${prefix}.`));
}
return filledTags.includes(tag);
}
function resolveContract(entry: WorkerSetEntry): {
context: { static: string[]; dynamic: string[] };
outputs: string[];
explicit: boolean;
} {
const defaults = entry.ref ? DEFAULT_WORKER_CONTRACTS[entry.ref] : undefined;
const ctx = entry.context;
const hasExplicitContext =
Boolean(ctx?.static?.length) || Boolean(ctx?.dynamic?.length);
const hasExplicitOutputs = Boolean(entry.outputs?.length);
return {
context: {
static: hasExplicitContext
? (ctx?.static ?? [])
: (defaults?.context.static ?? []),
dynamic: hasExplicitContext
? (ctx?.dynamic ?? [])
: (defaults?.context.dynamic ?? []),
},
outputs: hasExplicitOutputs
? (entry.outputs ?? [])
: (defaults?.outputs ?? []),
explicit: hasExplicitContext || hasExplicitOutputs,
};
}
function formatPresentation(pres: WorkerSetPresentation): PresentationLineView[] {
const lines: PresentationLineView[] = [];
for (const [key, value] of Object.entries(pres)) {
if (value == null || value === "") continue;
const label = PRESENTATION_LABELS[key] ?? key;
const text = Array.isArray(value) ? value.join("、") : String(value);
lines.push({ label, value: text });
}
return lines;
}
function parseTagFlow(lines: string[] | undefined): TagFlowEdgeView[] {
if (!lines?.length) return [];
return lines
.map((line) => {
const arrow = line.includes("→") ? "→" : "->";
const parts = line.split(arrow);
if (parts.length < 2) return null;
const from = parts[0]
.split("+")
.map((s) => s.trim())
.filter(Boolean);
const to = parts
.slice(1)
.join(arrow)
.split("+")
.map((s) => s.trim())
.filter(Boolean);
return { from, to };
})
.filter((e): e is TagFlowEdgeView => e != null && e.to.length > 0);
}
function toTagLines(
tags: string[],
filledTags: string[],
tier: "static" | "dynamic",
): TagLineView[] {
return tags.map((tag) => ({
tag,
note:
TAG_NOTES[tag] ??
(tier === "static" ? "设计阶段写入,游玩时只读" : "每轮读写"),
filled: filledTags.length ? tagFilled(tag, filledTags) : undefined,
}));
}
function previewText(raw: string | undefined, max = 120): string | undefined {
const t = raw?.trim();
if (!t) return undefined;
return t.length > max ? `${t.slice(0, max)}` : t;
}
function formatMountSummary(
mounts: ContextTagMountView[],
allWorkerCount: number,
): string {
if (mounts.length === 0) return "尚未指定 Worker";
const names = [...new Set(mounts.map((m) => m.workerName))];
if (allWorkerCount > 0 && names.length >= allWorkerCount) {
return "全部 Worker";
}
if (names.length <= 3) return names.join(" · ");
return `${names.slice(0, 2).join(" · ")}${names.length}`;
}
function workerDisplayName(entry: WorkerSetEntry, index: number): string {
const meta = entry.ref ? runWorkerMeta(entry.ref) : null;
return (
entry.name?.trim() ||
(entry.ref == null
? entry.duty?.slice(0, 24) || `待命名 Worker ${index + 1}`
: meta!.label)
);
}
/** 从规格反查:每块固定/常驻/黑板 tag 会塞进哪些 worker */
function buildContextTagCards(
parsed: ParsedWorkerSet,
workers: WorkerCardView[],
filledTags: string[],
): ContextTagCardView[] {
const cards: ContextTagCardView[] = [];
const allWorkerMounts: ContextTagMountView[] = workers
.filter((w) => w.id)
.map((w) => ({
workerId: w.id,
workerName: w.displayName,
how: "prompt_body" as const,
}));
const workerCount = allWorkerMounts.length;
const narrative = parsed.narrative_guide?.trim();
if (narrative) {
const mounts = allWorkerMounts.map((m) => ({ ...m, how: "prompt_body" as const }));
cards.push({
id: "fixed:narrative_guide",
label: "叙事指南",
kind: "fixed",
preview: previewText(narrative),
filled: true,
mounts,
mountSummary: formatMountSummary(mounts, workerCount),
});
}
const premises = (parsed.core_premises ?? []).filter(
(p): p is string => typeof p === "string" && Boolean(p.trim()),
);
if (premises.length) {
const mounts = allWorkerMounts.map((m) => ({ ...m, how: "prompt_body" as const }));
cards.push({
id: "fixed:core_premises",
label: "核心实现前提",
kind: "fixed",
preview: previewText(premises.join("")),
filled: true,
mounts,
mountSummary: formatMountSummary(mounts, workerCount),
});
}
if (parsed.input_protocol && Object.values(parsed.input_protocol).some(Boolean)) {
const mounts = allWorkerMounts.map((m) => ({ ...m, how: "prompt_body" as const }));
const bits = Object.entries(parsed.input_protocol)
.filter(([, v]) => v)
.map(([k, v]) => `${INPUT_PROTOCOL_LABELS[k] ?? k}${v}`);
cards.push({
id: "fixed:input_protocol",
label: "输入协议",
kind: "fixed",
preview: previewText(bits.join("")),
filled: true,
mounts,
mountSummary: formatMountSummary(mounts, workerCount),
});
}
// 美学:各 worker 自己的 presentation → 只挂该 worker
for (const w of workers) {
if (!w.presentation?.length) continue;
const preview = w.presentation.map((p) => `${p.label}${p.value}`).join("");
const mounts: ContextTagMountView[] = [
{
workerId: w.id,
workerName: w.displayName,
how: "prompt_body",
},
];
cards.push({
id: `fixed:aesthetics:${w.id ?? w.order}`,
label: `美学纲领 · ${w.displayName}`,
kind: "fixed",
preview: previewText(preview),
filled: true,
mounts,
mountSummary: formatMountSummary(mounts, workerCount),
});
}
const residents = parseResidentContext(parsed.resident_context);
for (const entry of residents) {
const mounts = resolveResidentMounts(entry, workers);
cards.push({
id: `resident:${entry.id}`,
label: `常驻 · ${entry.id}`,
kind: "resident",
preview: previewText(entry.content),
filled: true,
mounts,
mountSummary: formatMountSummary(mounts, workerCount),
});
}
// 黑板 tag按「谁读」反查与固定正文解耦
const boardMap = new Map<
string,
{ mounts: ContextTagMountView[]; tiers: Set<string> }
>();
for (const w of workers) {
for (const t of w.context.staticTags) {
const cur = boardMap.get(t.tag) ?? { mounts: [], tiers: new Set() };
cur.mounts.push({
workerId: w.id,
workerName: w.displayName,
how: "input_tag",
tier: "static",
});
cur.tiers.add("static");
boardMap.set(t.tag, cur);
}
for (const t of w.context.dynamicTags) {
const cur = boardMap.get(t.tag) ?? { mounts: [], tiers: new Set() };
cur.mounts.push({
workerId: w.id,
workerName: w.displayName,
how: "input_tag",
tier: "dynamic",
});
cur.tiers.add("dynamic");
boardMap.set(t.tag, cur);
}
}
// 跳过已由 resident 显式 tag 覆盖的
const residentBoardTags = new Set(residents.map((e) => residentTagFor(e)));
for (const [tag, info] of boardMap) {
if (residentBoardTags.has(tag)) continue;
if (tag === "设计.worker集") continue; // 规格本身,不当「上下文块」
const mounts = info.mounts;
cards.push({
id: `board:${tag}`,
label: tag,
kind: "board",
filled: filledTags.length ? tagFilled(tag, filledTags) : false,
mounts,
mountSummary: formatMountSummary(mounts, workerCount),
});
}
return cards;
}
function resolveResidentMounts(
entry: ResidentContextEntry,
workers: WorkerCardView[],
): ContextTagMountView[] {
const withIds = workers.filter((w) => w.id);
if (!entry.mount || entry.mount.length === 0) {
return withIds.map((w) => ({
workerId: w.id,
workerName: w.displayName,
how: "resident" as const,
tier: entry.position === "dynamic" ? ("dynamic" as const) : ("static" as const),
}));
}
const wanted = new Set(entry.mount);
return withIds
.filter((w) => w.id && wanted.has(w.id))
.map((w) => ({
workerId: w.id,
workerName: w.displayName,
how: "resident" as const,
tier: entry.position === "dynamic" ? ("dynamic" as const) : ("static" as const),
}));
}
function summarizeWorkerReads(
w: WorkerCardView,
contextTags: ContextTagCardView[],
): string {
const injected = contextTags
.filter(
(c) =>
(c.kind === "fixed" || c.kind === "resident") &&
c.mounts.some((m) => m.workerId === w.id || (!m.workerId && !w.id)),
)
.map((c) => c.label);
// 「全部 Worker」挂载的 fixed 也算
const allInjected = contextTags
.filter(
(c) =>
(c.kind === "fixed" || c.kind === "resident") &&
(c.mountSummary === "全部 Worker" ||
c.mounts.some((m) => m.workerId === w.id)),
)
.map((c) => c.label);
const labels = [...new Set(allInjected.length ? allInjected : injected)];
const boardCount =
w.context.staticTags.length + w.context.dynamicTags.length;
const parts: string[] = [];
if (labels.length) parts.push(labels.slice(0, 3).join("、") + (labels.length > 3 ? "…" : ""));
if (boardCount) parts.push(`黑板 ${boardCount}`);
return parts.length ? parts.join(" · ") : "(未声明上下文)";
}
/** 将解析后的 Worker 集格式化为用户可读视图 */
export function formatWorkerSetForUser(
parsed: ParsedWorkerSet | null,
options: FormatWorkerSetOptions = {},
): WorkerSetUserView | null {
if (!parsed) return null;
const filledTags = options.filledTags ?? [];
if (parsed.parseError) {
return {
workers: [],
contextTags: [],
tagFlow: [],
designTasks: [],
openQuestions: parsed.open_questions ?? [],
parseError: parsed.parseError,
};
}
const morphKey = parsed.play_morphology?.trim();
const morph = morphKey ? PLAY_MORPHOLOGY[morphKey] : undefined;
const workers: WorkerCardView[] = parsed.workers.map((entry, index) => {
const meta = entry.ref ? runWorkerMeta(entry.ref) : null;
const contract = resolveContract(entry);
const displayName = workerDisplayName(entry, index);
return {
order: index + 1,
id: entry.ref,
displayName,
roleLabel: entry.role ? WORKER_ROLE_LABELS[entry.role] ?? entry.role : undefined,
status: entry.ref == null ? "gap" : "ready",
gapNote: entry.gap ?? (entry.ref == null ? "尚未有对应 SKILL" : undefined),
purpose: entry.duty?.trim() || meta?.purpose || "—",
invokeWhen: entry.when?.trim() || "由 Agent 按本轮状态决定",
rationale: entry.rationale,
acceptance: entry.acceptance,
acceptanceLabel:
entry.acceptance === "review"
? "完成后验收"
: entry.acceptance === "continue"
? "可连跑"
: undefined,
mergeConsidered: entry.merge_considered,
context: {
staticTags: toTagLines(contract.context.static, filledTags, "static"),
dynamicTags: toTagLines(contract.context.dynamic, filledTags, "dynamic"),
explicit: contract.explicit,
},
writes: contract.outputs,
presentation: entry.presentation
? formatPresentation(entry.presentation)
: undefined,
};
});
const contextTags = buildContextTagCards(parsed, workers, filledTags);
for (const w of workers) {
w.readsSummary = summarizeWorkerReads(w, contextTags);
}
const invoke = parsed.instantiate_hints?.invoke ?? [];
const designTasks: DesignTaskView[] = [];
for (const id of invoke) {
const meta = instantiateMeta(id);
const relatedTags = instantiateTagsForSkill(id);
const filled = relatedTags.some((t) => tagFilledOnBoard(t, filledTags));
designTasks.push({
id,
label: meta.label,
status: filled ? "filled" : "planned",
});
}
for (const id of parsed.instantiate_hints?.skip ?? []) {
designTasks.push({
id,
label: instantiateMeta(id).label,
status: "skipped",
skipReason: parsed.instantiate_hints?.skip_reason,
});
}
const inputProtocol = parsed.input_protocol
? Object.entries(parsed.input_protocol)
.filter(([, v]) => v)
.map(([key, value]) => ({
label: INPUT_PROTOCOL_LABELS[key] ?? key,
value,
}))
: undefined;
const creationUnits = listCreationUnits(parsed, {
acceptedUnitIds: options.acceptedUnitIds,
currentUnitId: options.currentUnitId,
});
const currentUnit =
creationUnits.find((u) => u.current) ??
(options.currentUnitId
? creationUnits.find((u) => u.id === options.currentUnitId)
: undefined);
let focusUnit: WorkerSetUserView["focusUnit"];
if (currentUnit) {
const body =
currentUnit.kind === "fixed" || currentUnit.kind === "phase"
? extractUnitContentFromDraft(parsed, currentUnit.id)
: currentUnit.kind === "worker"
? extractUnitContentFromDraft(parsed, currentUnit.id)
: null;
focusUnit = {
id: currentUnit.id,
kind: currentUnit.kind,
label: currentUnit.label,
body: body ?? undefined,
};
}
return {
headline: parsed.form_summary,
interactionParadigm: parsed.interaction_paradigm,
coreWorker: parsed.core_worker,
reasoning: parsed.reasoning,
playModeLabel: morph?.label ?? morphKey,
playModeHint: morph?.hint,
inputProtocol,
workers,
contextTags,
tagFlow: parseTagFlow(parsed.tag_flow),
designTasks,
creationUnits,
focusUnit,
openQuestions: parsed.open_questions ?? [],
notes: parsed.notes,
};
}
function tagFilledOnBoard(tagPattern: string, filledTags: string[]): boolean {
if (tagPattern.endsWith(".")) {
return filledTags.some((t) => t.startsWith(tagPattern));
}
return tagFilled(tagPattern, filledTags);
}
/** 创作阶段收尾 skill 典型写入 tag用于设计任务「已填」检测 */
function instantiateTagsForSkill(skillId: string): string[] {
if (skillId === "opening-generator") {
return ["输出.开场白", "运行.初始变量"];
}
return [];
}

View File

@@ -30,6 +30,8 @@ export type MessageTokenUsage = {
caller: string;
model?: string;
recordId?: string;
/** ISO 时间戳,便于按时间段汇总 */
at?: string;
};
export type CallerTokenBreakdown = {
@@ -243,5 +245,6 @@ export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage
caller: record.caller,
model: record.model,
recordId: record.id,
at: record.at,
};
}

View File

@@ -10,6 +10,7 @@ export type PersistedBookSession = {
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
messages: PersistedChatMessage[];
messageBranchState?: PersistedMessageBranchState;
savedAt: string;
};
@@ -31,4 +32,40 @@ export type PersistedChatMessage = {
caller?: string;
model?: string;
};
/** 全量 LLM 请求上下文(可选;按设置只保留最新 N 条) */
contextTrace?: {
caller: string;
createdAt: string;
messages: Array<{ role: string; content: string }>;
charCount: number;
model?: string;
};
branchGroupId?: string;
branchIndex?: number;
branchTotal?: number;
};
export type PersistedMessageBranchState = {
branches: Record<
string,
{
anchorIndex: number;
groupId: string;
activeIndex: number;
variants: Array<{
messages: PersistedChatMessage[];
checkpoint: {
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
};
}>;
}
>;
preMessageCheckpoints: Record<
string,
{
runtimeSession: RuntimeSession;
blackboardItems: BlackboardItem[];
}
>;
};

View File

@@ -0,0 +1,63 @@
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
export type LlmContextMessage = {
role: string;
content: string;
};
export type LlmContextTrace = {
/** 调用方,如 worker:design-core / main-agent */
caller: string;
createdAt: string;
messages: LlmContextMessage[];
/** 便于列表展示 */
charCount: number;
model?: string;
};
export function buildContextTrace(params: {
caller: string;
messages: Array<{ role: string; content: string }>;
model?: string;
}): LlmContextTrace {
const messages = params.messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
return {
caller: params.caller,
createdAt: new Date().toISOString(),
messages,
charCount,
model: params.model,
};
}
/**
* 只保留「带 contextTrace 的消息」中最新 keepLatest 条的全文;
* 更早的消息删除 contextTrace 字段(消息本身保留)。
*/
export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>(
messages: T[],
keepLatest: number,
): T[] {
const keep = Math.max(0, Math.floor(keepLatest));
if (keep <= 0) {
return messages.map((m) => {
if (!m.contextTrace) return m;
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
return rest as T;
});
}
const withTraceIdx: number[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].contextTrace) withTraceIdx.push(i);
}
if (withTraceIdx.length <= keep) return messages;
const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep));
return messages.map((m, i) => {
if (!drop.has(i) || !m.contextTrace) return m;
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
return rest as T;
});
}

23
src/types/questions.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 结构化追问协议类型Worker askUser / Agent ask_user 共用) */
export type QuestionOption = {
id: string;
label: string;
/** 默认 true点文案可改写点字母才选中 */
editable?: boolean;
};
export type QuestionItem = {
id: string;
prompt: string;
options?: QuestionOption[];
allowOther?: boolean;
required?: boolean;
};
export type QuestionAnswer = {
questionId: string;
optionId?: string;
/** 最终确认文案(必填,可含用户改写) */
text: string;
};

View File

@@ -51,6 +51,6 @@ export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta {
}
export const SNAPSHOT_KIND_LABELS: Record<SnapshotKind, string> = {
instance: "实例",
run: "进度",
instance: "创作定稿",
run: "游玩进度",
};

View File

@@ -8,6 +8,7 @@
*/
import type { IntakeFieldDef } from "./intake.js";
import type { QuestionItem } from "./questions.js";
/** 运行相位:系统当前在等什么。只有 5 种。 */
export type RuntimePhase =
@@ -24,10 +25,30 @@ export type RuntimePhase =
export type WaitingReason =
| { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } // 启动:选 SKILL.md
| { kind: "intake"; prompt: string } // 启动填空:必要/可选项收集
| { kind: "input"; message?: string } // 总管 ask_user / 返工说明(启动完成后)
| {
kind: "input";
message?: string;
/** 总管 ask_user 结构化追问(有则走询问卡) */
questions?: QuestionItem[];
pageSize?: number;
} // 总管 ask_user / 返工说明(启动完成后)
| { kind: "approve_step"; decisionId: string } // 总管建议 run_worker等用户确认
| { kind: "review_artifact"; artifactId: string } // worker 产物待验收
| { kind: "worker_questions"; workerId: string; questions: string[] } // worker 中途提问
| {
kind: "review_artifact";
artifactId: string;
/**
* 挂在产物下的可选追问Cursor AskQuestion 心流)。
* 有值时不阻断验收:用户可直接 Accept也可先作答再 Accept。
*/
questions?: QuestionItem[];
pageSize?: number;
} // worker 产物待验收
| {
kind: "worker_questions";
workerId: string;
questions: QuestionItem[];
pageSize?: number;
} // worker 无产物时的阻塞提问
| { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
@@ -43,6 +64,9 @@ export type SkillIndexEntry = {
*/
export type BookKind = "novel" | "dialogue";
/** orchestrator 启动模式 */
export type SkillStartupMode = "intake" | "agent-first";
/** 选中的 skill 快照,写入 session.slots.activeSkill供启动询问与后续流程使用 */
export type ActiveSkillSnapshot = {
name: string;
@@ -52,6 +76,10 @@ export type ActiveSkillSnapshot = {
bookKind?: BookKind;
defaultFlowId?: string;
suggestedWorkers: string[];
/** intake总管填空agent-firstUI 固定引导 → 用户首句 → Agent 调 Skill */
startupMode?: SkillStartupMode;
/** agent-first 时首屏展示给用户的固定引导(纯 UI */
uiPrompt?: string;
/** 来自 SKILL.md ## 启动询问 的展示文案 */
startupPrompt: string;
/** 用户首次输入写入的 slots 键,如 book.brief */
@@ -118,6 +146,10 @@ export type MainAgentDecision = {
workerId?: string;
/** 调度 role-decide 等时指定当前决策角色Runtime 写入 世界.当前角色.id */
workerContext?: { roleId?: string };
/** ask_user给用户看的内容完备度评价写入 waitingReason.message */
assessment?: string;
/** ask_user 结构化追问(有则前端询问卡) */
questions?: QuestionItem[];
/** true 时进入 waiting_user(approve_step),等用户确认后才 run_worker */
requiresApproval: boolean;
statePatchAllowed: false;
@@ -149,7 +181,7 @@ export type ResumeContext = {
workerId: string;
stepId?: string;
acceptanceMode: AcceptanceMode;
questions: string[];
questions: QuestionItem[];
};
/**
@@ -159,7 +191,13 @@ export type ResumeContext = {
export type RuntimeEvent =
| {
type: "session_started";
payload: { presetId: string; flowId?: string; availableSkills: SkillIndexEntry[] };
payload: {
presetId: string;
flowId?: string;
availableSkills: SkillIndexEntry[];
/** 提供时跳过 skill_selection直接进入 intake新建作品默认路径 */
initialSkill?: ActiveSkillSnapshot;
};
}
| { type: "skill_selected"; payload: { skill: ActiveSkillSnapshot } }
| {
@@ -184,10 +222,26 @@ export type RuntimeEvent =
acceptanceMode: AcceptanceMode;
};
}
| { type: "worker_completed"; payload: { artifactId: string } }
| {
type: "worker_completed";
payload: {
artifactId: string;
/** 有产物时的可选追问,挂到 review_artifact */
questions?: QuestionItem[] | string[];
};
}
| {
type: "worker_needs_input";
payload: { workerId: string; stepId?: string; questions: string[] };
payload: {
workerId: string;
stepId?: string;
questions: QuestionItem[] | string[];
};
}
| {
/** 验收态下作答/跳过挂载追问:不离开 review_artifact */
type: "user_resolved_sidecar_questions";
payload: { answersText?: string };
}
| { type: "user_accepted_artifact"; payload: { artifactId: string } }
| {

View File

@@ -41,7 +41,17 @@ export type ListArtifactsParams = Record<string, never>;
export type AskUserParams = {
reason: string;
/** 给用户看的内容完备度评价Markdown优先于 message */
assessment?: string;
/** @deprecated 兼容旧调用,等同 assessment */
message?: string;
questions?: Array<{
id?: string;
prompt: string;
options?: Array<{ id?: string; label: string; editable?: boolean } | string>;
allowOther?: boolean;
required?: boolean;
}>;
};
export type RunWorkerParams = {

View File

@@ -1,10 +1,23 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import type { LlmProvider, StreamCallbacks } from "../llm/client.js";
import { supportsContentStream } from "../llm/stream-complete.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";
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
import type { ParsedWorkerSkill } from "../skills/types.js";
import {
extractJsonObjectText,
isUsableWorkerSet,
parseWorkerSetYaml,
} from "../skills/worker-set-parse.js";
import { assembleWorkerContext } from "../skills/context-segments.js";
import {
normalizeQuestions,
type QuestionItem,
} from "../skills/question-protocol.js";
export type WorkerRunParams = {
skillName: string;
@@ -12,15 +25,25 @@ export type WorkerRunParams = {
slots: Record<string, unknown>;
blackboard: Blackboard;
llm: LlmProvider;
stream?: WorkerStreamCallbacks;
/** 声明驱动:跳过磁盘 SKILL直接用解析好的契约 */
declared?: { worker: ParsedWorkerSkill; promptBody: string };
};
export type WorkerStreamCallbacks = {
onThinkingDelta?: (delta: string) => void;
onOutputDelta?: (delta: string) => void;
};
export type WorkerRunResult = {
outputs: Record<string, string>;
summary: string;
preview: string;
askUser?: string[];
askUser?: QuestionItem[];
};
const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]);
const WORKER_OUTPUT_INSTRUCTION = `
---
@@ -31,12 +54,18 @@ const WORKER_OUTPUT_INSTRUCTION = `
{
"outputs": { "<outputTag>": "<内容字符串>" },
"summary": "50字以内产物摘要",
"askUser": null 或 ["需要用户补充的问题"]
"askUser": null 或 问题数组
}
askUser 每项可为:
- 字符串:"需要用户补充的问题"
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
- outputs 的 key 必须是要求的 outputTags
- 若信息不足outputs 可为空对象askUser 填入问题
- 若 inputs 中 \`用户.博弈需求\`(或 book.brief已有实质内容禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- **设计.worker集 / 设计.worker集.草稿**value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options完整句、可改写不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief已有实质内容禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
function slotValueForTag(
@@ -82,6 +111,16 @@ function gatherInputs(
}
}
if (inputTags.includes("用户.需求") && !inputs["用户.需求"]) {
const fromSlot = slotValueForTag("用户.需求", slots);
if (fromSlot) {
inputs["用户.需求"] = fromSlot;
} else {
const transcript = collectUserInputTranscript(slots);
if (transcript) inputs["用户.需求"] = transcript;
}
}
return inputs;
}
@@ -93,6 +132,20 @@ function parseWorkerResponse(
try {
parsed = JSON.parse(raw);
} catch {
// 整段不是协议 JSON切勿把散文塞进设计.worker集*
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
const questions = normalizeQuestions(extractQuestionsFromText(raw));
return {
outputs: {},
summary: "未产出合法协议 JSON",
preview: raw.slice(0, 600),
askUser: questions.length
? questions.slice(0, 2)
: normalizeQuestions([
"请补充设计所需的关键信息(上一次未产出合法 JSON 规格)。",
]),
};
}
const fallback: Record<string, string> = {};
if (outputTags.length === 1) {
fallback[outputTags[0]] = raw;
@@ -119,21 +172,26 @@ function parseWorkerResponse(
const val = (outputsRaw as Record<string, unknown>)[tag];
if (typeof val === "string" && val.trim()) {
outputs[tag] = val.trim();
} else if (val && typeof val === "object") {
// 模型有时直接回对象而非字符串
outputs[tag] = JSON.stringify(val);
}
}
}
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
let askUser: string[] | undefined;
let askUser: QuestionItem[] | undefined;
if (Array.isArray(obj.askUser)) {
askUser = obj.askUser.filter(
(q): q is string =>
typeof q === "string" && q.trim().length > 0 && !isMetaAskToken(q),
askUser = normalizeQuestions(
obj.askUser.filter((q) => {
if (typeof q === "string") return q.trim() && !isMetaAskToken(q);
return true;
}),
);
} else if (typeof obj.askUser === "string" && obj.askUser.trim()) {
const q = obj.askUser.trim();
askUser = isMetaAskToken(q) ? undefined : [q];
askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]);
}
if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) {
const summaryText =
@@ -144,9 +202,11 @@ function parseWorkerResponse(
summaryText.length > 8 &&
/[?]/.test(summaryText)
) {
askUser = [summaryText];
askUser = normalizeQuestions([summaryText]);
} else if (isMetaAskToken(summaryText)) {
askUser = ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"];
askUser = normalizeQuestions([
"请补充当前步骤所需的信息(情境、参数或你的具体设想)。",
]);
}
}
@@ -161,15 +221,150 @@ function parseWorkerResponse(
.join("\n\n")
.slice(0, 4000) || summary;
return { outputs, summary, preview, askUser: askUser?.length ? askUser : undefined };
return sanitizeWorkerSetOutputs({
outputs,
summary,
preview,
askUser: askUser?.length ? askUser : undefined,
});
}
/** 规格 tag 必须是可用 JSON非法散文改为 askUser避免污染黑板 */
export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult {
const outputs = { ...result.outputs };
const askUser = [...(result.askUser ?? [])];
let droppedProse = false;
for (const tag of [...Object.keys(outputs)]) {
if (!WORKER_SET_OUTPUT_TAGS.has(tag)) continue;
const content = outputs[tag];
if (!content?.trim()) {
delete outputs[tag];
continue;
}
const extracted = extractJsonObjectText(content) ?? content.trim();
const parsed = parseWorkerSetYaml(extracted);
if (isUsableWorkerSet(parsed)) {
outputs[tag] = extracted.startsWith("{")
? extracted
: (extractJsonObjectText(extracted) ?? extracted);
continue;
}
droppedProse = true;
delete outputs[tag];
if (askUser.length === 0) {
const qs = normalizeQuestions(extractQuestionsFromText(content));
if (qs.length) askUser.push(...qs.slice(0, 2));
}
}
if (droppedProse && askUser.length === 0) {
askUser.push(
...normalizeQuestions([
"请补充或确认开局关键前提(上一次把说明文字写进了规格字段,未产出合法 JSON。",
]),
);
}
// 提问时不要夹带半残规格
if (askUser.length > 0) {
for (const tag of WORKER_SET_OUTPUT_TAGS) {
// 保留仍合法的草稿;已在上面删掉非法的
void tag;
}
}
const summary =
askUser.length && Object.keys(outputs).length === 0
? `待补充:${askUser[0]!.prompt.slice(0, 40)}`
: result.summary;
const preview =
askUser.length && Object.keys(outputs).length === 0
? askUser.map((q) => `- ${q.prompt}`).join("\n")
: 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,
};
}
function extractQuestionsFromText(text: string): string[] {
const lines = text
.split(/\n+/)
.map((l) => l.replace(/^[-*•\d.、)]+\s*/, "").trim())
.filter((l) => l.length >= 6);
const withMark = lines.filter((l) => /[?]/.test(l) || /请(?:描述|选择|确认|补充)/.test(l));
if (withMark.length) return withMark.slice(0, 3);
// 整段像提问说明
if (/请(?:描述|选择|确认|补充)|你选择|或者你也可以/.test(text)) {
const compact = text.replace(/\s+/g, " ").trim().slice(0, 240);
if (compact) return [compact];
}
return [];
}
async function completeWorkerPreferStream(
llm: LlmProvider,
messages: Parameters<LlmProvider["complete"]>[0],
options: Parameters<LlmProvider["complete"]>[1],
stream?: WorkerStreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["complete"]>>> {
const callbacks: StreamCallbacks = {
onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta),
onContentDelta: (delta) => stream?.onOutputDelta?.(delta),
};
if (supportsContentStream(llm)) {
return llm.completeStream!(messages, options, callbacks);
}
const result = await llm.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
const workerId = resolveWorkerId(params.workerId);
const { worker, promptBody } = await loadWorkerSkillWithContext(
params.skillName,
workerId,
);
let worker: ParsedWorkerSkill;
let promptBody: string;
if (params.declared) {
worker = params.declared.worker;
promptBody = params.declared.promptBody;
} else {
const flowRaw = params.blackboard.getContentByTag("设计.创作流程");
const currentStepName = params.blackboard.getContentByTag("创作.当前步骤");
const selectedRecipeRef = params.blackboard.getContentByTag("创作.选用配方");
const acceptedRaw =
params.slots.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
let acceptedStepNames: string[] = [];
try {
const { parseAcceptedSteps } = await import("../skills/creation-flow.js");
acceptedStepNames = parseAcceptedSteps(acceptedRaw);
} catch {
acceptedStepNames = [];
}
const loaded = await loadWorkerSkillWithContext(
params.skillName,
workerId,
undefined,
{
flowRaw,
currentStepName,
acceptedStepNames,
selectedRecipeRef,
},
);
worker = loaded.worker;
promptBody = loaded.promptBody;
}
const inputMerge = worker.inputMerge ?? "latest";
let inputs = gatherInputs(
@@ -183,30 +378,46 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
inputs = filterInputsForRolePerspective(inputs, params.slots);
}
const userPayload = {
// 上一 worker 验收后的定稿摘要:始终注入,供本 worker「指点」用
const priorBrief = params.blackboard.getContentByTag(CONTEXT_BRIEF_TAG)?.trim();
if (priorBrief && !inputs[CONTEXT_BRIEF_TAG]) {
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
}
const userPayload = assembleWorkerContext({
inputs,
segments: worker.contextSegments,
blackboard: params.blackboard,
inputMerge,
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(
const result = await completeWorkerPreferStream(
params.llm,
[
{ role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION },
{ role: "user", content: JSON.stringify(userPayload, null, 2) },
{ role: "user", content: userPayload },
],
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,
},
params.stream,
);
return parseWorkerResponse(result.content, worker.outputTags);
}
/** @internal 供单测 */
export function parseWorkerResponseForTest(
raw: string,
outputTags: string[],
): WorkerRunResult {
return parseWorkerResponse(raw, outputTags);
}
/** @internal 供单测验证 inputTags → inputs 拼接 */
export function gatherWorkerInputs(
inputTags: string[],