重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()];
|
||||
}
|
||||
|
||||
141
src/blackboard/table-cells.ts
Normal file
141
src/blackboard/table-cells.ts
Normal 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=actor,rev=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)) };
|
||||
}
|
||||
275
src/blackboard/table-side-effects.ts
Normal file
275
src/blackboard/table-side-effects.ts
Normal 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 = 边沿 + 记 fired;edge/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_tag;queue_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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 → 自动进入默认 orchestrator(world-simulator)
|
||||
2. 按启动询问描述创作需求
|
||||
3. /decide worker design-intake approve → /approve → …
|
||||
|
||||
Legacy:/start-with world-simulator(显式指定包)
|
||||
|
||||
命令:
|
||||
/start 开始会话
|
||||
|
||||
57
src/config/default-orchestrator.ts
Normal file
57
src/config/default-orchestrator.ts
Normal 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-first(UI 引导 → 用户输入 → 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";
|
||||
}
|
||||
@@ -8,9 +8,15 @@ export type AppSettings = {
|
||||
version: 1;
|
||||
activeProfileId: string | null;
|
||||
activePresetId: string | null;
|
||||
/**
|
||||
* 每个会话保留带全量上下文痕迹的消息条数(最新 N 条)。
|
||||
* 更早的消息仍保留,但去掉 contextTrace 正文。默认 5;0 表示不存痕迹。
|
||||
*/
|
||||
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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
149
src/llm/stream-complete.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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_user:assessment 是主内容(完备度评价);questions 挂在其下且用户可跳过;一次 1~2 题。
|
||||
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 可用 worker(workerId 必须与下列 id 完全一致):
|
||||
${workerLines}
|
||||
|
||||
## design 优先顺序
|
||||
- 尚无「设计.创作流程」→ **design-flow**(近期 horizon;status=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-step;steps 做完但 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),
|
||||
|
||||
@@ -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. 终止动作只能用 tool:ask_user、run_worker、review_blackboard、finish。
|
||||
- ask_user:**主内容是 assessment(内容完备度评价)**;questions 挂在其下且可被用户跳过。
|
||||
· assessment(必填):给用户看的评价正文。以【核心体验】为首;按需列维度(内容/人物关系/感官/背景规则/意义主题…),不必凑齐。每维写完备度%、已知、待探;待探用【】标互斥或可组合方向。
|
||||
· questions(必填数组,语义可选):只针对 assessment「待探」里想确认的点;一次 1~2 题。用户可不答、直接让你基于现有信息继续。prompt=明确题干;options=2~4 条**建议示范**;required 默认 false。
|
||||
· reason:调度短句,正推,勿塞长文。
|
||||
· 能推断就别问;已写在 用户.需求 里的不要重复问。
|
||||
5. run_worker 只传 workerId;inputTags/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-step;steps 完但 status=open → 再 design-flow。」
|
||||
- 「Worker 集已 accept 且声明需要开局 → 调用 opening-generator 写开场白。」
|
||||
|
||||
示例(坏):
|
||||
- 「调度 design-core / design-fixed(已废弃)。」
|
||||
- 「一次 design-flow 排死全程固定 DAG。」
|
||||
- 「跳过流程编排直接写满 Worker 集。」
|
||||
- 「默认先上世界模拟全套再问用户要什么。」
|
||||
- 「替用户改选 / 猜测导演。」
|
||||
|
||||
在 reasoning / 可见 content 中请用 **正推句式** 写出调度理由;终止 tool 的 reason 字段同样用正推表述。
|
||||
|
||||
## design 阶段提示
|
||||
- 尚无「设计.创作流程」→ **优先 design-flow**(近期 horizon;status=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()) {
|
||||
|
||||
@@ -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 下的可选追问(用户可不答)。基于「待探」出题,一次 1~2 题。prompt=明确选择题干;options=建议示范(可改写后采用)。",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "稳定 id,如 q1 / stance",
|
||||
},
|
||||
prompt: {
|
||||
type: "string",
|
||||
description:
|
||||
"题干:引导用户选定一种详细体验倾向(例:「你更倾向于哪种皇帝的享受?」)。",
|
||||
},
|
||||
options: {
|
||||
type: "array",
|
||||
description:
|
||||
"2~4 个建议示范。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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
235
src/runtime/compress-after-worker.ts
Normal file
235
src/runtime/compress-after-worker.ts
Normal 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()];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: "请发送任意消息继续,或联系维护者",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 / 能力包 id(registry 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;
|
||||
}
|
||||
|
||||
121
src/server/display-labels.ts
Normal file
121
src/server/display-labels.ts
Normal 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 "导演";
|
||||
}
|
||||
223
src/server/message-branch.ts
Normal file
223
src/server/message-branch.ts
Normal 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;
|
||||
}
|
||||
162
src/server/prune-creation-messages.ts
Normal file
162
src/server/prune-creation-messages.ts
Normal 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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
165
src/skills/context-segments.ts
Normal file
165
src/skills/context-segments.ts
Normal 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
964
src/skills/creation-flow.ts
Normal 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.yaml(when / 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,
|
||||
};
|
||||
}
|
||||
694
src/skills/creation-units.ts
Normal file
694
src/skills/creation-units.ts
Normal 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 若是 taxonomy(core/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: [] };
|
||||
}
|
||||
260
src/skills/declared-worker.ts
Normal file
260
src/skills/declared-worker.ts
Normal 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_confirmed;continue → no_confirmation
|
||||
* - 未声明 acceptance:design 生命周期默认确认;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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
134
src/skills/question-protocol.ts
Normal file
134
src/skills/question-protocol.ts
Normal 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);
|
||||
}
|
||||
128
src/skills/resident-context.ts
Normal file
128
src/skills/resident-context.ts
Normal 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")}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
/** intake(legacy 包)或 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;
|
||||
|
||||
166
src/skills/worker-declaration.ts
Normal file
166
src/skills/worker-declaration.ts
Normal 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 = {
|
||||
/** 声明正文来源 tag;null 表示尚无 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-simulator:play 仅 activeWorkerIds;design 验收前为分步 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";
|
||||
}
|
||||
523
src/skills/worker-set-parse.ts
Normal file
523
src/skills/worker-set-parse.ts
Normal 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。",
|
||||
}
|
||||
);
|
||||
}
|
||||
720
src/skills/worker-set-view.ts
Normal file
720
src/skills/worker-set-view.ts
Normal 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 [];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
63
src/types/context-trace.ts
Normal file
63
src/types/context-trace.ts
Normal 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
23
src/types/questions.ts
Normal 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;
|
||||
};
|
||||
@@ -51,6 +51,6 @@ export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta {
|
||||
}
|
||||
|
||||
export const SNAPSHOT_KIND_LABELS: Record<SnapshotKind, string> = {
|
||||
instance: "实例",
|
||||
run: "进度",
|
||||
instance: "创作定稿",
|
||||
run: "游玩进度",
|
||||
};
|
||||
|
||||
@@ -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-first:UI 固定引导 → 用户首句 → 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 } }
|
||||
| {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user