重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user