引入上下文片段、固定槽与投影排序,并完善机遇裁定与游玩期 UI。
把创作产物收敛为可挂载片段与 play_slots/context_order,同步修订世界模拟器模块与运行时拼装。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
325
src/skills/chance-tools.ts
Normal file
325
src/skills/chance-tools.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 程序机遇工具:骰子、比点、抽签/加权抽取。
|
||||
* 供按需执行单元 `chance` 使用;禁止用模型「假装随机」。
|
||||
*/
|
||||
|
||||
export type ChanceRollRequest = {
|
||||
op: "roll";
|
||||
/** 如 1d20、2d6+3、d100;空格可忽略 */
|
||||
expression: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ChanceCompareRequest = {
|
||||
op: "compare";
|
||||
left: number;
|
||||
right: number;
|
||||
mode?: "gt" | "gte" | "lt" | "lte" | "eq";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ChanceDrawRequest = {
|
||||
op: "draw";
|
||||
pool: string[];
|
||||
count?: number;
|
||||
/** 默认 true:不放回 */
|
||||
unique?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ChancePickRequest = {
|
||||
op: "pick";
|
||||
items: Array<{ id: string; weight?: number }>;
|
||||
count?: number;
|
||||
unique?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ChanceRequest =
|
||||
| ChanceRollRequest
|
||||
| ChanceCompareRequest
|
||||
| ChanceDrawRequest
|
||||
| ChancePickRequest;
|
||||
|
||||
export type ChanceResult = {
|
||||
schema: "chance.v1";
|
||||
op: ChanceRequest["op"];
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
/** 人话摘要 */
|
||||
summary: string;
|
||||
detail: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const DICE_RE = /^\s*(\d*)\s*[dD]\s*(\d+)\s*([+-]\s*\d+)?\s*$/;
|
||||
|
||||
function randInt(min: number, max: number): number {
|
||||
const lo = Math.ceil(min);
|
||||
const hi = Math.floor(max);
|
||||
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
||||
}
|
||||
|
||||
export function parseChanceRequest(raw: unknown): ChanceRequest | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const op = typeof row.op === "string" ? row.op.trim() : "";
|
||||
if (op === "roll") {
|
||||
const expression = typeof row.expression === "string" ? row.expression : "";
|
||||
if (!expression.trim()) return null;
|
||||
return {
|
||||
op: "roll",
|
||||
expression: expression.trim(),
|
||||
reason: typeof row.reason === "string" ? row.reason : undefined,
|
||||
};
|
||||
}
|
||||
if (op === "compare") {
|
||||
if (typeof row.left !== "number" || typeof row.right !== "number") return null;
|
||||
const mode = row.mode;
|
||||
const okMode =
|
||||
mode === "gt" ||
|
||||
mode === "gte" ||
|
||||
mode === "lt" ||
|
||||
mode === "lte" ||
|
||||
mode === "eq" ||
|
||||
mode === undefined;
|
||||
if (!okMode) return null;
|
||||
return {
|
||||
op: "compare",
|
||||
left: row.left,
|
||||
right: row.right,
|
||||
mode,
|
||||
reason: typeof row.reason === "string" ? row.reason : undefined,
|
||||
};
|
||||
}
|
||||
if (op === "draw") {
|
||||
if (!Array.isArray(row.pool)) return null;
|
||||
const pool = row.pool.filter((x): x is string => typeof x === "string");
|
||||
if (!pool.length) return null;
|
||||
return {
|
||||
op: "draw",
|
||||
pool,
|
||||
count: typeof row.count === "number" ? row.count : undefined,
|
||||
unique: typeof row.unique === "boolean" ? row.unique : undefined,
|
||||
reason: typeof row.reason === "string" ? row.reason : undefined,
|
||||
};
|
||||
}
|
||||
if (op === "pick") {
|
||||
if (!Array.isArray(row.items)) return null;
|
||||
const items: Array<{ id: string; weight?: number }> = [];
|
||||
for (const it of row.items) {
|
||||
if (!it || typeof it !== "object" || Array.isArray(it)) continue;
|
||||
const id = (it as { id?: unknown }).id;
|
||||
const weight = (it as { weight?: unknown }).weight;
|
||||
if (typeof id !== "string" || !id.trim()) continue;
|
||||
items.push({
|
||||
id: id.trim(),
|
||||
weight: typeof weight === "number" ? weight : undefined,
|
||||
});
|
||||
}
|
||||
if (!items.length) return null;
|
||||
return {
|
||||
op: "pick",
|
||||
items,
|
||||
count: typeof row.count === "number" ? row.count : undefined,
|
||||
unique: typeof row.unique === "boolean" ? row.unique : undefined,
|
||||
reason: typeof row.reason === "string" ? row.reason : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function executeChance(request: ChanceRequest): ChanceResult {
|
||||
const reason = request.reason;
|
||||
try {
|
||||
switch (request.op) {
|
||||
case "roll":
|
||||
return executeRoll(request, reason);
|
||||
case "compare":
|
||||
return executeCompare(request, reason);
|
||||
case "draw":
|
||||
return executeDraw(request, reason);
|
||||
case "pick":
|
||||
return executePick(request, reason);
|
||||
default:
|
||||
return fail("unknown", "不支持的 op", reason);
|
||||
}
|
||||
} catch (e) {
|
||||
return fail(
|
||||
(request as ChanceRequest).op,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(
|
||||
op: string,
|
||||
error: string,
|
||||
reason?: string,
|
||||
): ChanceResult {
|
||||
return {
|
||||
schema: "chance.v1",
|
||||
op: op as ChanceRequest["op"],
|
||||
ok: false,
|
||||
reason,
|
||||
summary: `机遇失败:${error}`,
|
||||
detail: {},
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function executeRoll(req: ChanceRollRequest, reason?: string): ChanceResult {
|
||||
const m = req.expression.replace(/\s+/g, "").match(DICE_RE);
|
||||
if (!m) {
|
||||
return fail("roll", `无法解析骰式:${req.expression}`, reason);
|
||||
}
|
||||
const count = m[1] ? Number(m[1]) : 1;
|
||||
const sides = Number(m[2]);
|
||||
const mod = m[3] ? Number(m[3].replace(/\s+/g, "")) : 0;
|
||||
if (!Number.isFinite(count) || count < 1 || count > 100) {
|
||||
return fail("roll", "骰子个数须在 1~100", reason);
|
||||
}
|
||||
if (!Number.isFinite(sides) || sides < 2 || sides > 1000) {
|
||||
return fail("roll", "面数须在 2~1000", reason);
|
||||
}
|
||||
const dice: number[] = [];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = randInt(1, sides);
|
||||
dice.push(v);
|
||||
sum += v;
|
||||
}
|
||||
const total = sum + mod;
|
||||
const modText = mod === 0 ? "" : mod > 0 ? `+${mod}` : `${mod}`;
|
||||
return {
|
||||
schema: "chance.v1",
|
||||
op: "roll",
|
||||
ok: true,
|
||||
reason,
|
||||
summary: `掷骰 ${count}d${sides}${modText} → [${dice.join(",")}]${modText} = ${total}`,
|
||||
detail: {
|
||||
expression: `${count}d${sides}${modText}`,
|
||||
dice,
|
||||
modifier: mod,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function executeCompare(
|
||||
req: ChanceCompareRequest,
|
||||
reason?: string,
|
||||
): ChanceResult {
|
||||
const mode = req.mode ?? "gte";
|
||||
let win = false;
|
||||
switch (mode) {
|
||||
case "gt":
|
||||
win = req.left > req.right;
|
||||
break;
|
||||
case "gte":
|
||||
win = req.left >= req.right;
|
||||
break;
|
||||
case "lt":
|
||||
win = req.left < req.right;
|
||||
break;
|
||||
case "lte":
|
||||
win = req.left <= req.right;
|
||||
break;
|
||||
case "eq":
|
||||
win = req.left === req.right;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
schema: "chance.v1",
|
||||
op: "compare",
|
||||
ok: true,
|
||||
reason,
|
||||
summary: `比点 ${req.left} ${mode} ${req.right} → ${win ? "成立" : "不成立"}`,
|
||||
detail: { left: req.left, right: req.right, mode, win },
|
||||
};
|
||||
}
|
||||
|
||||
function executeDraw(req: ChanceDrawRequest, reason?: string): ChanceResult {
|
||||
const count = req.count ?? 1;
|
||||
const unique = req.unique !== false;
|
||||
if (count < 1 || count > 50) {
|
||||
return fail("draw", "抽取数量须在 1~50", reason);
|
||||
}
|
||||
if (unique && count > req.pool.length) {
|
||||
return fail("draw", "不放回抽取数量超过池大小", reason);
|
||||
}
|
||||
const bag = [...req.pool];
|
||||
const drawn: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = randInt(0, bag.length - 1);
|
||||
drawn.push(bag[idx]!);
|
||||
if (unique) bag.splice(idx, 1);
|
||||
}
|
||||
return {
|
||||
schema: "chance.v1",
|
||||
op: "draw",
|
||||
ok: true,
|
||||
reason,
|
||||
summary: `抽签 ×${count} → ${drawn.join("、")}`,
|
||||
detail: { drawn, unique, poolSize: req.pool.length },
|
||||
};
|
||||
}
|
||||
|
||||
function executePick(req: ChancePickRequest, reason?: string): ChanceResult {
|
||||
const count = req.count ?? 1;
|
||||
const unique = req.unique !== false;
|
||||
if (count < 1 || count > 50) {
|
||||
return fail("pick", "抽取数量须在 1~50", reason);
|
||||
}
|
||||
let bag = req.items.map((it) => ({
|
||||
id: it.id,
|
||||
weight: it.weight && it.weight > 0 ? it.weight : 1,
|
||||
}));
|
||||
if (unique && count > bag.length) {
|
||||
return fail("pick", "不放回加权抽取数量超过条目数", reason);
|
||||
}
|
||||
const picked: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const totalW = bag.reduce((s, x) => s + x.weight, 0);
|
||||
let r = Math.random() * totalW;
|
||||
let chosen = bag[0]!;
|
||||
for (const it of bag) {
|
||||
r -= it.weight;
|
||||
if (r <= 0) {
|
||||
chosen = it;
|
||||
break;
|
||||
}
|
||||
}
|
||||
picked.push(chosen.id);
|
||||
if (unique) bag = bag.filter((x) => x.id !== chosen.id);
|
||||
}
|
||||
return {
|
||||
schema: "chance.v1",
|
||||
op: "pick",
|
||||
ok: true,
|
||||
reason,
|
||||
summary: `加权抽取 ×${count} → ${picked.join("、")}`,
|
||||
detail: { picked, unique },
|
||||
};
|
||||
}
|
||||
|
||||
/** 从黑板正文或 workerContext 解析请求 */
|
||||
export function resolveChanceRequest(params: {
|
||||
workerContext?: Record<string, unknown> | null;
|
||||
blackboardRequestJson?: string | null;
|
||||
}): ChanceRequest | null {
|
||||
const ctx = params.workerContext;
|
||||
if (ctx && typeof ctx === "object") {
|
||||
const nested = ctx.chance ?? ctx.request ?? ctx;
|
||||
const parsed = parseChanceRequest(nested);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
const raw = params.blackboardRequestJson?.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return parseChanceRequest(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
352
src/skills/context-fragment.ts
Normal file
352
src/skills/context-fragment.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 上下文片段产物(context-fragment.v1)解析与展示辅助。
|
||||
* 规范:docs/context-fragment-design.md
|
||||
*/
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
import {
|
||||
normalizeQuestions,
|
||||
type QuestionItem,
|
||||
} from "./question-protocol.js";
|
||||
|
||||
export const CONTEXT_FRAGMENT_SCHEMA = "context-fragment.v1" as const;
|
||||
|
||||
export type ContextFragmentStability = "stable" | "semi" | "volatile";
|
||||
|
||||
export type ContextFragment = {
|
||||
schema: typeof CONTEXT_FRAGMENT_SCHEMA;
|
||||
技能: string;
|
||||
brief: string;
|
||||
mount: string[];
|
||||
稳变?: ContextFragmentStability;
|
||||
正文: unknown;
|
||||
开放问题: string[];
|
||||
};
|
||||
|
||||
export type ContextFragmentView = {
|
||||
ok: boolean;
|
||||
parseError?: string;
|
||||
fragment?: ContextFragment;
|
||||
sections: Array<{ title: string; lines: string[] }>;
|
||||
};
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function asStringList(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
|
||||
}
|
||||
|
||||
function normalizeStability(v: unknown): ContextFragmentStability | undefined {
|
||||
if (v === "stable" || v === "semi" || v === "volatile") return v;
|
||||
if (v === "稳" || v === "少变") return "stable";
|
||||
if (v === "中" || v === "偶发") return "semi";
|
||||
if (v === "变" || v === "常变") return "volatile";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 从任意 JSON 文本或对象解析片段;兼容无 schema 但含 brief+正文 的旧形 */
|
||||
export function parseContextFragment(raw: unknown): ContextFragment | undefined {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
}
|
||||
if (!row) return undefined;
|
||||
|
||||
const hasSchema = row.schema === CONTEXT_FRAGMENT_SCHEMA;
|
||||
const hasBody = "正文" in row || "body" in row;
|
||||
const brief = asString(row.brief) ?? asString(row.概要);
|
||||
if (!hasSchema && !(brief && hasBody)) return undefined;
|
||||
|
||||
const skill =
|
||||
asString(row.技能) ?? asString(row.skill) ?? asString(row.name) ?? "";
|
||||
const 正文 = row.正文 !== undefined ? row.正文 : row.body !== undefined ? row.body : {};
|
||||
const mount = asStringList(row.mount ?? row.挂载);
|
||||
const 开放问题 = asStringList(row.开放问题 ?? row.open_questions);
|
||||
|
||||
return {
|
||||
schema: CONTEXT_FRAGMENT_SCHEMA,
|
||||
技能: skill,
|
||||
brief: brief ?? "",
|
||||
mount,
|
||||
稳变: normalizeStability(row.稳变 ?? row.stability),
|
||||
正文,
|
||||
开放问题,
|
||||
};
|
||||
}
|
||||
|
||||
export function isContextFragmentDoc(doc: unknown): boolean {
|
||||
return Boolean(parseContextFragment(doc));
|
||||
}
|
||||
|
||||
/** 投影级别:summary 优先 brief;fields 尝试列出正文顶层键 */
|
||||
export function projectFragmentContent(
|
||||
raw: string,
|
||||
projection: string,
|
||||
): string {
|
||||
const frag = parseContextFragment(raw);
|
||||
const mode = projection.trim().toLowerCase();
|
||||
if (!frag) {
|
||||
if (mode === "summary" && raw.length > 800) return `${raw.slice(0, 800)}…`;
|
||||
return raw;
|
||||
}
|
||||
if (mode === "summary" || mode === "brief") {
|
||||
return frag.brief || raw;
|
||||
}
|
||||
if (mode === "fields") {
|
||||
if (frag.正文 && typeof frag.正文 === "object" && !Array.isArray(frag.正文)) {
|
||||
const keys = Object.keys(frag.正文 as object);
|
||||
return [`brief: ${frag.brief}`, `字段: ${keys.join("、") || "(无)"}`].join("\n");
|
||||
}
|
||||
return frag.brief || raw;
|
||||
}
|
||||
if (mode === "fixed") {
|
||||
return frag.brief || raw;
|
||||
}
|
||||
// full
|
||||
return JSON.stringify(
|
||||
{
|
||||
schema: frag.schema,
|
||||
技能: frag.技能,
|
||||
brief: frag.brief,
|
||||
mount: frag.mount,
|
||||
稳变: frag.稳变,
|
||||
正文: frag.正文,
|
||||
开放问题: frag.开放问题,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function contextFragmentToView(raw: unknown): ContextFragmentView {
|
||||
const fragment = parseContextFragment(raw);
|
||||
if (!fragment) {
|
||||
return { ok: false, parseError: "无法解析为 context-fragment.v1", sections: [] };
|
||||
}
|
||||
const sections: Array<{ title: string; lines: string[] }> = [];
|
||||
if (fragment.brief) sections.push({ title: "概要", lines: [fragment.brief] });
|
||||
const meta: string[] = [];
|
||||
if (fragment.技能) meta.push(`技能:${fragment.技能}`);
|
||||
if (fragment.mount.length) meta.push(`挂载:${fragment.mount.join("、")}`);
|
||||
if (fragment.稳变) meta.push(`稳变:${fragment.稳变}`);
|
||||
if (meta.length) sections.push({ title: "挂载", lines: meta });
|
||||
|
||||
if (fragment.正文 != null && fragment.正文 !== "") {
|
||||
if (typeof fragment.正文 === "string") {
|
||||
sections.push({ title: "正文", lines: [fragment.正文] });
|
||||
} else if (typeof fragment.正文 === "object") {
|
||||
const lines = flattenObjectLines(fragment.正文, 0, 4);
|
||||
if (lines.length) sections.push({ title: "正文", lines });
|
||||
}
|
||||
}
|
||||
const rawDoc =
|
||||
typeof raw === "object" && raw && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: null;
|
||||
const selfScore = rawDoc?.自评;
|
||||
if (selfScore && typeof selfScore === "object" && !Array.isArray(selfScore)) {
|
||||
const row = selfScore as Record<string, unknown>;
|
||||
const dims = Array.isArray(row.维度) ? row.维度 : [];
|
||||
const lines = dims.map((d) => {
|
||||
if (!d || typeof d !== "object") return String(d);
|
||||
const r = d as Record<string, unknown>;
|
||||
return `${r.名 ?? "?"}:${r.分数 ?? "?"}%${r.说明 ? ` — ${r.说明}` : ""}`;
|
||||
});
|
||||
if (typeof row.薄弱点 === "string" && row.薄弱点.trim()) {
|
||||
lines.push(`薄弱点:${row.薄弱点.trim()}`);
|
||||
}
|
||||
if (lines.length) sections.push({ title: "自评", lines });
|
||||
}
|
||||
const probe = rawDoc?.追问;
|
||||
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
|
||||
const row = probe as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
if (typeof row.导语 === "string" && row.导语.trim()) lines.push(row.导语.trim());
|
||||
const qs = Array.isArray(row.题目) ? row.题目 : [];
|
||||
qs.forEach((q, i) => {
|
||||
if (!q || typeof q !== "object") return;
|
||||
const r = q as Record<string, unknown>;
|
||||
lines.push(`${i + 1}. ${r.问 ?? "?"}`);
|
||||
if (Array.isArray(r.建议选项) && r.建议选项.length) {
|
||||
lines.push(`选项:${r.建议选项.map(String).join(" / ")}`);
|
||||
}
|
||||
if (typeof r.示例 === "string" && r.示例.trim()) {
|
||||
lines.push(`示例:${r.示例.trim()}`);
|
||||
}
|
||||
});
|
||||
if (lines.length) sections.push({ title: "追问", lines });
|
||||
}
|
||||
if (fragment.开放问题.length) {
|
||||
sections.push({ title: "开放问题", lines: fragment.开放问题 });
|
||||
}
|
||||
return { ok: true, fragment, sections };
|
||||
}
|
||||
|
||||
/** 从片段 JSON 抽出追问→询问卡;自评→评估导语(供验收挂载) */
|
||||
export function extractFragmentAskSidecar(raw: unknown): {
|
||||
questions: QuestionItem[];
|
||||
assessment: string;
|
||||
} {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return { questions: [], assessment: "" };
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
}
|
||||
if (!row) return { questions: [], assessment: "" };
|
||||
|
||||
const looksFragment =
|
||||
row.schema === CONTEXT_FRAGMENT_SCHEMA ||
|
||||
(typeof row.brief === "string" && ("正文" in row || "body" in row));
|
||||
if (!looksFragment) return { questions: [], assessment: "" };
|
||||
|
||||
const assessmentParts: string[] = [];
|
||||
const selfScore = row.自评;
|
||||
if (selfScore && typeof selfScore === "object" && !Array.isArray(selfScore)) {
|
||||
const sc = selfScore as Record<string, unknown>;
|
||||
const dims = Array.isArray(sc.维度) ? sc.维度 : [];
|
||||
for (const d of dims) {
|
||||
if (!d || typeof d !== "object") continue;
|
||||
const r = d as Record<string, unknown>;
|
||||
const name = asString(r.名) ?? asString(r.维度) ?? "?";
|
||||
const score = r.分数;
|
||||
const note = asString(r.说明);
|
||||
const scoreText =
|
||||
typeof score === "number" || typeof score === "string"
|
||||
? `${score}%`
|
||||
: "?";
|
||||
assessmentParts.push(
|
||||
note ? `${name} ${scoreText} — ${note}` : `${name} ${scoreText}`,
|
||||
);
|
||||
}
|
||||
const weak = asString(sc.薄弱点);
|
||||
if (weak) assessmentParts.push(`薄弱点:${weak}`);
|
||||
}
|
||||
|
||||
const probe = row.追问;
|
||||
const rawQs: unknown[] = [];
|
||||
let lead = "";
|
||||
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
|
||||
const p = probe as Record<string, unknown>;
|
||||
lead = asString(p.导语) ?? "";
|
||||
const topics = Array.isArray(p.题目) ? p.题目 : [];
|
||||
for (let i = 0; i < topics.length; i++) {
|
||||
const t = topics[i];
|
||||
if (!t || typeof t !== "object") continue;
|
||||
const q = t as Record<string, unknown>;
|
||||
const prompt = asString(q.问) ?? asString(q.prompt);
|
||||
if (!prompt) continue;
|
||||
const opts = Array.isArray(q.建议选项)
|
||||
? q.建议选项
|
||||
: Array.isArray(q.options)
|
||||
? q.options
|
||||
: [];
|
||||
const example = asString(q.示例);
|
||||
const optLabels = opts
|
||||
.map((o) => {
|
||||
if (typeof o === "string") return o.trim();
|
||||
if (o && typeof o === "object") {
|
||||
const r = o as Record<string, unknown>;
|
||||
return asString(r.label) ?? asString(r.文案) ?? asString(r.text) ?? "";
|
||||
}
|
||||
return String(o ?? "").trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
// 建议选项 → 选择题;示例并入题干提示,便于 ask 卡展示
|
||||
rawQs.push({
|
||||
id: `frag-q${i + 1}`,
|
||||
prompt: example ? `${prompt}\n示例:${example}` : prompt,
|
||||
options: optLabels,
|
||||
allowOther: true,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 开放问题兜底成无选项追问
|
||||
const openQs = asStringList(row.开放问题);
|
||||
if (!rawQs.length && openQs.length) {
|
||||
for (let i = 0; i < openQs.length; i++) {
|
||||
rawQs.push({
|
||||
id: `frag-open${i + 1}`,
|
||||
prompt: openQs[i],
|
||||
allowOther: true,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const questions = normalizeQuestions(rawQs);
|
||||
const assessment = [lead, ...assessmentParts].filter(Boolean).join("\n");
|
||||
return { questions, assessment };
|
||||
}
|
||||
|
||||
function flattenObjectLines(value: unknown, depth: number, maxDepth: number): string[] {
|
||||
if (value == null) return [];
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return [String(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return ["(空)"];
|
||||
return value.flatMap((v, i) => {
|
||||
if (v == null || typeof v !== "object") return [String(v)];
|
||||
const row = v as Record<string, unknown>;
|
||||
const title =
|
||||
[row.名, row.显示名, row.块id, row.rule_id, row.id, row.映射id, row.段id]
|
||||
.find((x) => typeof x === "string" && x.trim()) ?? `#${i + 1}`;
|
||||
const nested = flattenObjectLines(v, depth + 1, maxDepth);
|
||||
if (depth + 1 >= maxDepth) {
|
||||
const summary = Object.entries(row)
|
||||
.filter(([, x]) => x != null && x !== "" && typeof x !== "object")
|
||||
.slice(0, 4)
|
||||
.map(([k, x]) => `${k}=${x}`)
|
||||
.join(" · ");
|
||||
return [`${title}${summary ? `(${summary})` : ""}`];
|
||||
}
|
||||
return [`【${title}】`, ...nested.map((line) => ` ${line}`)];
|
||||
});
|
||||
}
|
||||
if (typeof value === "object" && depth < maxDepth) {
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (v == null || v === "") continue;
|
||||
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
||||
out.push(`${k}:${v}`);
|
||||
} else if (Array.isArray(v) || typeof v === "object") {
|
||||
out.push(`${k}:`);
|
||||
for (const line of flattenObjectLines(v, depth + 1, maxDepth)) {
|
||||
out.push(` ${line}`);
|
||||
}
|
||||
} else {
|
||||
out.push(`${k}:${String(v)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const keys = Object.keys(value as object);
|
||||
return [`(嵌套 ${keys.length} 键:${keys.slice(0, 8).join("、")}${keys.length > 8 ? "…" : ""})`];
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
511
src/skills/context-order.ts
Normal file
511
src/skills/context-order.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 上下文投影排序(context-order.v1)→ contextSegments。
|
||||
* 扁平数字序;「对话.历史」也是可投影标签,不是硬分区锚点。
|
||||
* 规范:docs/context-fragment-design.md
|
||||
*/
|
||||
import type { ContextSegmentDef } from "./context-segments.js";
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
import { parsePlaySlots, type PlaySlotsConfig } from "./play-slots.js";
|
||||
import {
|
||||
DIALOGUE_HISTORY_TAG,
|
||||
isDialogueHistoryRef,
|
||||
} from "./dialogue-history.js";
|
||||
|
||||
export const CONTEXT_ORDER_SCHEMA = "context-order.v1" as const;
|
||||
export const CONTEXT_ORDER_TAG = "设计.上下文投影排序";
|
||||
export const WORKER_PERSONA_REF = "worker.persona";
|
||||
export { DIALOGUE_HISTORY_TAG };
|
||||
|
||||
/** @deprecated 仅兼容旧表;拼装按扁平 order,历史靠 ref=对话.历史 */
|
||||
export type ContextOrderAnchor = "pre_history" | "post_history";
|
||||
export type ContextOrderProjection = "fixed" | "full" | "summary" | "fields";
|
||||
|
||||
export type ContextOrderInsert = {
|
||||
order: number;
|
||||
/** @deprecated 可选;set_anchor 语义改为「移到历史标签前/后」 */
|
||||
anchor?: ContextOrderAnchor;
|
||||
ref: string;
|
||||
projection: ContextOrderProjection;
|
||||
note?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type ContextOrderSlot = {
|
||||
ref: string;
|
||||
label?: string;
|
||||
inserts: ContextOrderInsert[];
|
||||
};
|
||||
|
||||
export type ContextOrderDoc = {
|
||||
schema: typeof CONTEXT_ORDER_SCHEMA;
|
||||
brief?: string;
|
||||
play_slots?: PlaySlotsConfig;
|
||||
slots: ContextOrderSlot[];
|
||||
};
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function parseAnchor(v: unknown): ContextOrderAnchor {
|
||||
if (v === "post_history" || v === "dynamic" || v === "历史后") return "post_history";
|
||||
return "pre_history";
|
||||
}
|
||||
|
||||
function parseProjection(v: unknown): ContextOrderProjection {
|
||||
if (v === "summary" || v === "fields" || v === "fixed" || v === "full") return v;
|
||||
if (v === "摘要") return "summary";
|
||||
if (v === "字段") return "fields";
|
||||
return "full";
|
||||
}
|
||||
|
||||
function parseInsert(raw: unknown): ContextOrderInsert | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const ref = asString(row.ref) ?? asString(row.tag) ?? asString(row.源);
|
||||
if (!ref) return undefined;
|
||||
const orderRaw = row.order ?? row.序;
|
||||
const order = typeof orderRaw === "number" && Number.isFinite(orderRaw) ? orderRaw : Number(orderRaw);
|
||||
const hasAnchor = row.anchor != null || row.锚点 != null;
|
||||
return {
|
||||
order: Number.isFinite(order) ? order : 99,
|
||||
anchor: hasAnchor ? parseAnchor(row.anchor ?? row.锚点) : undefined,
|
||||
ref,
|
||||
projection: parseProjection(row.projection ?? row.投影),
|
||||
note: asString(row.note) ?? asString(row.说明),
|
||||
label: asString(row.label) ?? asString(row.标题),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSlot(raw: unknown): ContextOrderSlot | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const ref = asString(row.ref) ?? asString(row.id);
|
||||
if (!ref) return undefined;
|
||||
const insertsRaw = Array.isArray(row.inserts) ? row.inserts : [];
|
||||
const inserts = insertsRaw
|
||||
.map(parseInsert)
|
||||
.filter((x): x is ContextOrderInsert => Boolean(x))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return {
|
||||
ref,
|
||||
label: asString(row.label) ?? asString(row.name),
|
||||
inserts,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
}
|
||||
if (!row) return undefined;
|
||||
|
||||
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
|
||||
const slots = slotsRaw
|
||||
.map(parseSlot)
|
||||
.filter((x): x is ContextOrderSlot => Boolean(x));
|
||||
if (!slots.length && row.schema !== CONTEXT_ORDER_SCHEMA) return undefined;
|
||||
|
||||
return {
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
brief: asString(row.brief),
|
||||
play_slots: parsePlaySlots(row.play_slots ?? row.playSlots),
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
export function slotOrderForRef(
|
||||
doc: ContextOrderDoc | undefined,
|
||||
workerRef: string,
|
||||
): ContextOrderSlot | undefined {
|
||||
if (!doc) return undefined;
|
||||
const id = workerRef.trim();
|
||||
return doc.slots.find((s) => s.ref.trim() === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将某槽的 inserts 转为 contextSegments:严格按扁平 order。
|
||||
* worker.persona 用 inline;对话.历史走专用 tag(拼装时动态投影)。
|
||||
*/
|
||||
export function contextOrderToSegments(params: {
|
||||
slot: ContextOrderSlot;
|
||||
personaText?: string;
|
||||
}): ContextSegmentDef[] {
|
||||
const sorted = renumberContextOrder({
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
slots: [params.slot],
|
||||
}).slots[0]!.inserts;
|
||||
|
||||
const historyIdx = sorted.findIndex((i) => isDialogueHistoryRef(i.ref));
|
||||
const segments: ContextSegmentDef[] = [];
|
||||
|
||||
for (const ins of sorted) {
|
||||
const afterHistory =
|
||||
historyIdx >= 0 && sorted.indexOf(ins) > historyIdx;
|
||||
// tier 仅作缓存提示;拼装按数组顺序,不再重排
|
||||
const tier = afterHistory ? "dynamic" : "static";
|
||||
const id = `ord${ins.order}-${ins.ref}`.replace(/[^\w.\u4e00-\u9fff-]+/g, "_");
|
||||
const label =
|
||||
ins.label ||
|
||||
(ins.note ? `## ${ins.ref}(${ins.note})` : `## ${ins.ref}`);
|
||||
|
||||
if (ins.ref === WORKER_PERSONA_REF || ins.ref === "persona") {
|
||||
const inline = params.personaText?.trim();
|
||||
if (!inline) continue;
|
||||
segments.push({
|
||||
id,
|
||||
tier,
|
||||
tags: [],
|
||||
label,
|
||||
inline,
|
||||
projection: ins.projection,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const tag = isDialogueHistoryRef(ins.ref)
|
||||
? DIALOGUE_HISTORY_TAG
|
||||
: ins.ref;
|
||||
segments.push({
|
||||
id,
|
||||
tier,
|
||||
tags: [tag],
|
||||
label,
|
||||
projection: ins.projection,
|
||||
});
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export type ContextOrderInsertView = {
|
||||
index: number;
|
||||
order: number;
|
||||
anchor?: ContextOrderAnchor;
|
||||
ref: string;
|
||||
projection: ContextOrderProjection;
|
||||
note?: string;
|
||||
label?: string;
|
||||
isHistory?: boolean;
|
||||
/** 展示一行 */
|
||||
line: string;
|
||||
};
|
||||
|
||||
export function contextOrderToView(doc: ContextOrderDoc): {
|
||||
brief?: string;
|
||||
editable: true;
|
||||
slots: Array<{
|
||||
ref: string;
|
||||
label?: string;
|
||||
inserts: ContextOrderInsertView[];
|
||||
lines: string[];
|
||||
}>;
|
||||
} {
|
||||
return {
|
||||
brief: doc.brief,
|
||||
editable: true,
|
||||
slots: doc.slots.map((s) => {
|
||||
const inserts: ContextOrderInsertView[] = s.inserts.map((i, index) => {
|
||||
const hist = isDialogueHistoryRef(i.ref);
|
||||
const line =
|
||||
`${i.order} · ${hist ? "对话.历史" : i.ref}` +
|
||||
(i.projection !== "full" ? ` · ${i.projection}` : "") +
|
||||
(i.note ? ` — ${i.note}` : "");
|
||||
return {
|
||||
index,
|
||||
order: i.order,
|
||||
anchor: i.anchor,
|
||||
ref: hist ? DIALOGUE_HISTORY_TAG : i.ref,
|
||||
projection: i.projection,
|
||||
note: i.note,
|
||||
label: i.label,
|
||||
isHistory: hist,
|
||||
line,
|
||||
};
|
||||
});
|
||||
return {
|
||||
ref: s.ref,
|
||||
label: s.label,
|
||||
inserts,
|
||||
lines: inserts.map((x) => x.line),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 扁平重编号 0..n;persona 尽量置顶;保证至多一条对话.历史 */
|
||||
export function renumberContextOrder(doc: ContextOrderDoc): ContextOrderDoc {
|
||||
return {
|
||||
...doc,
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
slots: doc.slots.map((slot) => {
|
||||
let inserts = slot.inserts.map((i) => ({
|
||||
...i,
|
||||
ref: isDialogueHistoryRef(i.ref) ? DIALOGUE_HISTORY_TAG : i.ref,
|
||||
}));
|
||||
// 合并重复历史标签:保留第一条
|
||||
const seenHist = new Set<number>();
|
||||
inserts = inserts.filter((i, idx) => {
|
||||
if (!isDialogueHistoryRef(i.ref)) return true;
|
||||
if (seenHist.size) return false;
|
||||
seenHist.add(idx);
|
||||
return true;
|
||||
});
|
||||
const personaIdx = inserts.findIndex(
|
||||
(i) => i.ref === WORKER_PERSONA_REF || i.ref === "persona",
|
||||
);
|
||||
if (personaIdx > 0) {
|
||||
const [p] = inserts.splice(personaIdx, 1);
|
||||
inserts.unshift(p!);
|
||||
}
|
||||
return {
|
||||
...slot,
|
||||
inserts: inserts.map((i, idx) => ({
|
||||
...i,
|
||||
order: idx,
|
||||
anchor: undefined,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type ContextOrderEdit =
|
||||
| { action: "move"; slotRef: string; index: number; delta: -1 | 1 }
|
||||
| {
|
||||
action: "set_anchor";
|
||||
slotRef: string;
|
||||
index: number;
|
||||
anchor: ContextOrderAnchor;
|
||||
}
|
||||
| {
|
||||
action: "set_projection";
|
||||
slotRef: string;
|
||||
index: number;
|
||||
projection: ContextOrderProjection;
|
||||
}
|
||||
| { action: "replace"; context_order: unknown };
|
||||
|
||||
function cloneDoc(doc: ContextOrderDoc): ContextOrderDoc {
|
||||
return {
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
brief: doc.brief,
|
||||
play_slots: doc.play_slots ? { ...doc.play_slots } : undefined,
|
||||
slots: doc.slots.map((s) => ({
|
||||
...s,
|
||||
inserts: s.inserts.map((i) => ({ ...i })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 扁平列表编排:
|
||||
* - move:与相邻项交换
|
||||
* - set_anchor:把该项移到「对话.历史」之前(pre)或之后(post);无历史则先插入历史标签
|
||||
* - set_projection:只改投影
|
||||
*/
|
||||
export function applyContextOrderEdit(
|
||||
doc: ContextOrderDoc,
|
||||
edit: ContextOrderEdit,
|
||||
): ContextOrderDoc {
|
||||
if (edit.action === "replace") {
|
||||
const parsed = parseContextOrder(edit.context_order);
|
||||
if (!parsed) throw new Error("context_order 无法解析");
|
||||
return renumberContextOrder(parsed);
|
||||
}
|
||||
|
||||
const next = cloneDoc(doc);
|
||||
const slot = next.slots.find((s) => s.ref.trim() === edit.slotRef.trim());
|
||||
if (!slot) throw new Error(`未找到槽 ${edit.slotRef}`);
|
||||
if (edit.index < 0 || edit.index >= slot.inserts.length) {
|
||||
throw new Error("插入项下标越界");
|
||||
}
|
||||
|
||||
if (edit.action === "set_projection") {
|
||||
slot.inserts[edit.index]!.projection = edit.projection;
|
||||
return renumberContextOrder(next);
|
||||
}
|
||||
|
||||
if (edit.action === "set_anchor") {
|
||||
const item = slot.inserts[edit.index]!;
|
||||
if (isDialogueHistoryRef(item.ref)) {
|
||||
return renumberContextOrder(next);
|
||||
}
|
||||
slot.inserts.splice(edit.index, 1);
|
||||
ensureHistoryInsert(slot);
|
||||
const histIdx = slot.inserts.findIndex((i) => isDialogueHistoryRef(i.ref));
|
||||
const insertAt =
|
||||
edit.anchor === "pre_history" ? Math.max(0, histIdx) : histIdx + 1;
|
||||
slot.inserts.splice(insertAt, 0, item);
|
||||
return renumberContextOrder(next);
|
||||
}
|
||||
|
||||
// move:整表相邻交换
|
||||
const to = edit.index + edit.delta;
|
||||
if (to < 0 || to >= slot.inserts.length) {
|
||||
return renumberContextOrder(next);
|
||||
}
|
||||
const tmp = slot.inserts[edit.index]!;
|
||||
slot.inserts[edit.index] = slot.inserts[to]!;
|
||||
slot.inserts[to] = tmp;
|
||||
return renumberContextOrder(next);
|
||||
}
|
||||
|
||||
function ensureHistoryInsert(slot: ContextOrderSlot): void {
|
||||
if (slot.inserts.some((i) => isDialogueHistoryRef(i.ref))) return;
|
||||
// 插在中段:persona 后、易变前
|
||||
const mid = Math.min(
|
||||
Math.max(1, Math.ceil(slot.inserts.length / 2)),
|
||||
slot.inserts.length,
|
||||
);
|
||||
slot.inserts.splice(mid, 0, {
|
||||
order: mid,
|
||||
ref: DIALOGUE_HISTORY_TAG,
|
||||
projection: "summary",
|
||||
note: "对话历史(按投影裁剪)",
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeContextOrder(doc: ContextOrderDoc): string {
|
||||
const normalized = renumberContextOrder(doc);
|
||||
return JSON.stringify(
|
||||
{
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
brief: normalized.brief,
|
||||
play_slots: normalized.play_slots,
|
||||
slots: normalized.slots.map((s) => ({
|
||||
ref: s.ref,
|
||||
label: s.label,
|
||||
inserts: s.inserts.map((i) => ({
|
||||
order: i.order,
|
||||
ref: i.ref,
|
||||
projection: i.projection,
|
||||
...(i.note ? { note: i.note } : {}),
|
||||
...(i.label ? { label: i.label } : {}),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
/** 把排序表写入运行规格 JSON 文本的 context_order 字段 */
|
||||
export function mergeContextOrderIntoWorkerSetJson(
|
||||
workerSetRaw: string,
|
||||
order: ContextOrderDoc,
|
||||
): string {
|
||||
const text = extractJsonObjectText(workerSetRaw) ?? workerSetRaw.trim();
|
||||
let row: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("设计.worker集 不是 JSON 对象");
|
||||
}
|
||||
row = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("设计.worker集 无法解析为 JSON");
|
||||
}
|
||||
const normalized = renumberContextOrder(order);
|
||||
row.context_order = JSON.parse(serializeContextOrder(normalized)) as unknown;
|
||||
delete row.contextOrder;
|
||||
return JSON.stringify(row, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无排序表时,从 worker 声明的 static/dynamic 合成可编辑初稿(含 persona)。
|
||||
*/
|
||||
/** 声明缺 context 时的兜底(与模板/检查器默认对齐的最小集) */
|
||||
const SYNTH_FALLBACK_CONTEXT: Record<
|
||||
string,
|
||||
{ static: string[]; dynamic: string[] }
|
||||
> = {
|
||||
"world-simulator": {
|
||||
static: ["设计.变量设计与更新规则"],
|
||||
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入", "上下文.角色态度"],
|
||||
},
|
||||
narrator: {
|
||||
static: ["设计.叙事指南"],
|
||||
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
|
||||
},
|
||||
"role-decide": {
|
||||
static: [],
|
||||
dynamic: ["用户.最新输入", "运行.本轮.裁决"],
|
||||
},
|
||||
};
|
||||
|
||||
export function synthesizeContextOrderFromWorkers(
|
||||
workers: Array<{
|
||||
ref?: string;
|
||||
name?: string;
|
||||
context?: { static?: string[]; dynamic?: string[] };
|
||||
}>,
|
||||
playSlots?: PlaySlotsConfig,
|
||||
): ContextOrderDoc | undefined {
|
||||
const slots: ContextOrderSlot[] = [];
|
||||
for (const w of workers) {
|
||||
const ref = w.ref?.trim();
|
||||
if (!ref) continue;
|
||||
const fallback = SYNTH_FALLBACK_CONTEXT[ref];
|
||||
const staticTags = w.context?.static ?? fallback?.static ?? [];
|
||||
const dynamicTags = w.context?.dynamic ?? fallback?.dynamic ?? ["用户.最新输入"];
|
||||
const inserts: ContextOrderInsert[] = [
|
||||
{
|
||||
order: 0,
|
||||
ref: WORKER_PERSONA_REF,
|
||||
projection: "fixed",
|
||||
note: "槽位人设",
|
||||
},
|
||||
];
|
||||
let o = 1;
|
||||
for (const tag of staticTags) {
|
||||
if (!tag?.trim() || tag === "设计.worker集") continue;
|
||||
if (isDialogueHistoryRef(tag)) continue;
|
||||
inserts.push({
|
||||
order: o++,
|
||||
ref: tag.trim(),
|
||||
projection: tag.includes("事件流") ? "summary" : "full",
|
||||
});
|
||||
}
|
||||
inserts.push({
|
||||
order: o++,
|
||||
ref: DIALOGUE_HISTORY_TAG,
|
||||
projection: "summary",
|
||||
note: "对话历史(按投影裁剪)",
|
||||
});
|
||||
for (const tag of dynamicTags) {
|
||||
if (!tag?.trim()) continue;
|
||||
if (isDialogueHistoryRef(tag)) continue;
|
||||
// 事件流已可由历史兜底;仍可单独挂
|
||||
inserts.push({
|
||||
order: o++,
|
||||
ref: tag.trim(),
|
||||
projection: "full",
|
||||
});
|
||||
}
|
||||
slots.push({
|
||||
ref,
|
||||
label: w.name,
|
||||
inserts,
|
||||
});
|
||||
}
|
||||
if (!slots.length) return undefined;
|
||||
return renumberContextOrder({
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
brief: "扁平投影序(含对话.历史标签,可编排)",
|
||||
play_slots: playSlots,
|
||||
slots,
|
||||
});
|
||||
}
|
||||
@@ -9,16 +9,28 @@ import {
|
||||
CREATION_ACCEPTED_CONTENT_TAG,
|
||||
formatAcceptedContentForPrompt,
|
||||
} from "./creation-units.js";
|
||||
import { projectFragmentContent } from "./context-fragment.js";
|
||||
import {
|
||||
buildHistoryFallback,
|
||||
isDialogueHistoryRef,
|
||||
projectDialogueHistory,
|
||||
DIALOGUE_HISTORY_TAG,
|
||||
} from "./dialogue-history.js";
|
||||
|
||||
export type ContextSegmentTier = "static" | "dynamic";
|
||||
|
||||
export type ContextSegmentDef = {
|
||||
id: string;
|
||||
/** 缓存提示;有序拼装时不再按 tier 重排 */
|
||||
tier: ContextSegmentTier;
|
||||
tags: string[];
|
||||
label?: string;
|
||||
/** latest | concat | tail_lines_N */
|
||||
policy?: string;
|
||||
/** 不读黑板,直接注入(如 worker.persona) */
|
||||
inline?: string;
|
||||
/** context-order 投影级别:full | summary | fields | fixed */
|
||||
projection?: string;
|
||||
};
|
||||
|
||||
export function parseContextSegments(raw: unknown): ContextSegmentDef[] {
|
||||
@@ -32,13 +44,16 @@ export function parseContextSegments(raw: unknown): ContextSegmentDef[] {
|
||||
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;
|
||||
const inline = typeof r.inline === "string" ? r.inline.trim() : undefined;
|
||||
if (!id || (tags.length === 0 && !inline)) continue;
|
||||
out.push({
|
||||
id,
|
||||
tier,
|
||||
tags,
|
||||
label: typeof r.label === "string" ? r.label.trim() : undefined,
|
||||
policy: typeof r.policy === "string" ? r.policy.trim() : undefined,
|
||||
inline: inline || undefined,
|
||||
projection: typeof r.projection === "string" ? r.projection.trim() : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -78,20 +93,50 @@ function formatSegmentBody(
|
||||
blackboard: Blackboard,
|
||||
inputMerge: BlackboardInputMerge,
|
||||
): string {
|
||||
if (segment.inline?.trim()) {
|
||||
return applyProjection(segment.inline.trim(), segment.projection);
|
||||
}
|
||||
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);
|
||||
let content: string;
|
||||
if (isDialogueHistoryRef(tag)) {
|
||||
content = resolveDialogueHistory(inputs, blackboard, inputMerge);
|
||||
content = projectDialogueHistory(content, segment.projection);
|
||||
} else {
|
||||
content = readTagContent(tag, inputs, blackboard, inputMerge);
|
||||
if (!content) continue;
|
||||
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
|
||||
content = formatAcceptedContentForPrompt(content);
|
||||
}
|
||||
content = applyPolicy(content, segment.policy);
|
||||
content = applyProjection(content, segment.projection);
|
||||
}
|
||||
content = applyPolicy(content, segment.policy);
|
||||
if (!content.trim()) continue;
|
||||
parts.push(content.trim());
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function resolveDialogueHistory(
|
||||
inputs: Record<string, string>,
|
||||
blackboard: Blackboard,
|
||||
inputMerge: BlackboardInputMerge,
|
||||
): string {
|
||||
const direct =
|
||||
readTagContent(DIALOGUE_HISTORY_TAG, inputs, blackboard, inputMerge) ||
|
||||
readTagContent("对话历史", inputs, blackboard, inputMerge);
|
||||
return buildHistoryFallback({
|
||||
dialogueHistory: direct,
|
||||
eventStream: readTagContent("运行.事件流", inputs, blackboard, "concat"),
|
||||
latestUser: readTagContent("用户.最新输入", inputs, blackboard, inputMerge),
|
||||
});
|
||||
}
|
||||
|
||||
function applyProjection(content: string, projection?: string): string {
|
||||
if (!projection || projection === "full") return content;
|
||||
return projectFragmentContent(content, projection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 contextSegments 拼装 user 侧上下文。
|
||||
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
|
||||
@@ -124,8 +169,6 @@ export function assembleWorkerContext(params: {
|
||||
);
|
||||
}
|
||||
|
||||
const staticSegs = segments.filter((s) => s.tier === "static");
|
||||
const dynamicSegs = segments.filter((s) => s.tier === "dynamic");
|
||||
const blocks: string[] = [];
|
||||
|
||||
const render = (seg: ContextSegmentDef) => {
|
||||
@@ -138,8 +181,8 @@ export function assembleWorkerContext(params: {
|
||||
}
|
||||
};
|
||||
|
||||
for (const seg of staticSegs) render(seg);
|
||||
for (const seg of dynamicSegs) render(seg);
|
||||
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
|
||||
for (const seg of segments) render(seg);
|
||||
|
||||
// 定稿摘要:若未在 segments 中声明,仍附在末尾
|
||||
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
|
||||
|
||||
@@ -598,17 +598,25 @@ function slugFromName(name: string): string {
|
||||
交互范式: "interaction",
|
||||
美学纲领: "aesthetics",
|
||||
叙事指南: "narrative",
|
||||
叙事指南与故事推进: "narrative",
|
||||
实现机制: "mechanism",
|
||||
世界蓝图与人文地理: "world-blueprint",
|
||||
舞台骨架: "world-blueprint",
|
||||
世界蓝图与人文地理: "world-blueprint", // 旧称
|
||||
世界蓝图: "world-blueprint", // 旧简称
|
||||
生成规则: "generation-rules",
|
||||
具体实例: "concrete-instances",
|
||||
拓扑图谱: "topology",
|
||||
设计状态栏: "status-bar",
|
||||
设计监控栏: "status-bar",
|
||||
变量设计与更新规则: "variable-design",
|
||||
变量控制上下文: "variable-context",
|
||||
设计回复格式: "reply-format",
|
||||
"Worker 规格": "worker-spec",
|
||||
正文组成: "reply-format",
|
||||
游玩拓扑: "worker-spec",
|
||||
"Worker 规格": "worker-spec", // 旧称,等同游玩拓扑
|
||||
细化终稿: "refine",
|
||||
开场白与开场变量: "opening-setup",
|
||||
开场白: "opening-setup",
|
||||
};
|
||||
return map[name] ?? name;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export const FIXED_CONTEXT_CATALOG: Array<{
|
||||
{
|
||||
id: "fixed:narrative_guide",
|
||||
flavor: "narrative_guide",
|
||||
label: "叙事指南",
|
||||
label: "叙事指南与故事推进",
|
||||
weighty: true,
|
||||
hint: "世界态度与体验边界(残酷/不有求必应/随机危险等);≠ 文风",
|
||||
},
|
||||
|
||||
@@ -24,6 +24,13 @@ import {
|
||||
parseResidentContext,
|
||||
residentTagFor,
|
||||
} from "./resident-context.js";
|
||||
import {
|
||||
CONTEXT_ORDER_TAG,
|
||||
contextOrderToSegments,
|
||||
parseContextOrder,
|
||||
slotOrderForRef,
|
||||
} from "./context-order.js";
|
||||
import type { ContextSegmentDef } from "./context-segments.js";
|
||||
|
||||
type WorkerTemplateDoc = {
|
||||
id?: string;
|
||||
@@ -106,7 +113,14 @@ export async function resolveRunnableWorker(params: {
|
||||
}
|
||||
|
||||
const raw = readWorkerSetYamlForDeclaration(params.blackboard, params.session);
|
||||
const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
|
||||
let parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
|
||||
// 用户编排的排序表优先:规格内 context_order → 独立 tag
|
||||
if (parsed && !parseContextOrder(parsed.context_order)) {
|
||||
const fromTag = parseContextOrder(
|
||||
params.blackboard.getContentByTag(CONTEXT_ORDER_TAG),
|
||||
);
|
||||
if (fromTag) parsed = { ...parsed, context_order: fromTag };
|
||||
}
|
||||
const entry = parsed?.workers.find(
|
||||
(w) => w.ref?.trim() === params.workerId.trim(),
|
||||
);
|
||||
@@ -190,16 +204,6 @@ export function buildDeclaredWorkerSkill(params: {
|
||||
.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() ||
|
||||
@@ -212,6 +216,59 @@ export function buildDeclaredWorkerSkill(params: {
|
||||
const premises = (params.workerSet?.core_premises ?? []).filter(Boolean);
|
||||
const residentSection = formatResidentPromptSection(resident, id);
|
||||
|
||||
const personaText = [duty, excerpt].filter(Boolean).join("\n\n");
|
||||
const orderDoc = parseContextOrder(params.workerSet?.context_order);
|
||||
const orderSlot = slotOrderForRef(orderDoc, id);
|
||||
let contextSegments: ContextSegmentDef[] | undefined;
|
||||
if (orderSlot && orderSlot.inserts.length) {
|
||||
contextSegments = contextOrderToSegments({
|
||||
slot: orderSlot,
|
||||
personaText,
|
||||
});
|
||||
}
|
||||
|
||||
const orderTags = (contextSegments ?? []).flatMap((s) => s.tags);
|
||||
const inputTags = [
|
||||
...new Set([
|
||||
...staticTags,
|
||||
...residentStaticTags,
|
||||
...dynamicTags,
|
||||
...residentDynamicTags,
|
||||
...orderTags,
|
||||
CONTEXT_BRIEF_TAG,
|
||||
]),
|
||||
];
|
||||
|
||||
// 无 context_order 时:沿用模板 static→dynamic 两档 segments
|
||||
if (!contextSegments?.length) {
|
||||
contextSegments = [
|
||||
...staticTags.map((tag, i) => ({
|
||||
id: `static-${i}`,
|
||||
tier: "static" as const,
|
||||
tags: [tag],
|
||||
label: `## ${tag}`,
|
||||
})),
|
||||
...residentStaticTags.map((tag, i) => ({
|
||||
id: `resident-s-${i}`,
|
||||
tier: "static" as const,
|
||||
tags: [tag],
|
||||
label: `## ${tag}`,
|
||||
})),
|
||||
...dynamicTags.map((tag, i) => ({
|
||||
id: `dynamic-${i}`,
|
||||
tier: "dynamic" as const,
|
||||
tags: [tag],
|
||||
label: `## ${tag}`,
|
||||
})),
|
||||
...residentDynamicTags.map((tag, i) => ({
|
||||
id: `resident-d-${i}`,
|
||||
tier: "dynamic" as const,
|
||||
tags: [tag],
|
||||
label: `## ${tag}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const body = [
|
||||
`# ${params.template?.label ?? id}`,
|
||||
"",
|
||||
@@ -248,6 +305,7 @@ export function buildDeclaredWorkerSkill(params: {
|
||||
inputMerge: "latest",
|
||||
path: `declaration:${id}`,
|
||||
body,
|
||||
contextSegments,
|
||||
};
|
||||
|
||||
const promptBody = [
|
||||
|
||||
96
src/skills/dialogue-history.ts
Normal file
96
src/skills/dialogue-history.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 对话.历史:排序表中的可投影「标签」,不是硬锚点分区。
|
||||
* 规范:docs/context-fragment-design.md
|
||||
*/
|
||||
export const DIALOGUE_HISTORY_TAG = "对话.历史";
|
||||
|
||||
/** 兼容别名(排序表 / 旧文档) */
|
||||
const HISTORY_ALIASES = new Set([
|
||||
DIALOGUE_HISTORY_TAG,
|
||||
"history",
|
||||
"chat.history",
|
||||
"对话历史",
|
||||
"历史对话",
|
||||
]);
|
||||
|
||||
export function isDialogueHistoryRef(ref: string): boolean {
|
||||
return HISTORY_ALIASES.has(ref.trim());
|
||||
}
|
||||
|
||||
export type HistoryProjection = "fixed" | "full" | "summary" | "fields";
|
||||
|
||||
/**
|
||||
* 按投影级别裁剪历史正文。
|
||||
* - fixed:几乎不注入(占位一句)
|
||||
* - fields:最近约 5 段
|
||||
* - summary:最近约 20 段
|
||||
* - full:最近约 80 段(或整段若更短)
|
||||
*/
|
||||
export function projectDialogueHistory(
|
||||
raw: string,
|
||||
projection?: string,
|
||||
): string {
|
||||
const text = (raw || "").trim();
|
||||
const mode = (projection || "full").trim().toLowerCase();
|
||||
if (!text) {
|
||||
return mode === "fixed" ? "" : "(尚无对话历史)";
|
||||
}
|
||||
if (mode === "fixed") return "(本步不注入历史正文)";
|
||||
|
||||
const blocks = splitHistoryBlocks(text);
|
||||
const n =
|
||||
mode === "fields" ? 5 : mode === "summary" || mode === "brief" ? 20 : 80;
|
||||
const sliced = blocks.length > n ? blocks.slice(-n) : blocks;
|
||||
const body = sliced.join("\n\n");
|
||||
if (blocks.length > n) {
|
||||
return `(仅最近 ${n} 段,更早已省略)\n\n${body}`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function splitHistoryBlocks(text: string): string[] {
|
||||
const byFence = text
|
||||
.split(/\n(?=##\s|【|用户[::]|助手[::]|系统[::])/u)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (byFence.length >= 2) return byFence;
|
||||
const byBlank = text
|
||||
.split(/\n{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (byBlank.length >= 2) return byBlank;
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
return lines.length ? lines : [text];
|
||||
}
|
||||
|
||||
/** 追加一轮用户/系统可见内容到历史正文 */
|
||||
export function appendDialogueHistoryTurn(
|
||||
existing: string,
|
||||
turn: { role: "用户" | "助手" | "系统"; text: string },
|
||||
): string {
|
||||
const body = turn.text.trim();
|
||||
if (!body) return (existing || "").trim();
|
||||
const block = `## ${turn.role}\n\n${body}`;
|
||||
const prev = (existing || "").trim();
|
||||
return prev ? `${prev}\n\n${block}` : block;
|
||||
}
|
||||
|
||||
/**
|
||||
* 若尚无 对话.历史,用事件流等拼一份可读底稿(仅用于投影,不强制回写)。
|
||||
*/
|
||||
export function buildHistoryFallback(params: {
|
||||
dialogueHistory?: string;
|
||||
eventStream?: string;
|
||||
latestUser?: string;
|
||||
}): string {
|
||||
const hist = params.dialogueHistory?.trim();
|
||||
if (hist) return hist;
|
||||
const parts: string[] = [];
|
||||
if (params.eventStream?.trim()) {
|
||||
parts.push(`## 事件流\n\n${params.eventStream.trim()}`);
|
||||
}
|
||||
if (params.latestUser?.trim()) {
|
||||
parts.push(`## 用户\n\n${params.latestUser.trim()}`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
265
src/skills/play-slots.ts
Normal file
265
src/skills/play-slots.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 固定游玩槽位:世界模拟路径不再自由发明 workers[],
|
||||
* 以勾选 gm / narrator / perspective 展开为声明条目。
|
||||
* `chance` 为按需程序槽:不进每轮管线,可被 run_worker / toolcall 调用。
|
||||
*/
|
||||
import type { WorkerAcceptance, WorkerSetEntry } from "./worker-set-parse.js";
|
||||
|
||||
export type PlaySlotId = "gm" | "narrator" | "perspective";
|
||||
/** 按需槽(不进入 perspective→gm→narrator 管线) */
|
||||
export type OnDemandSlotId = "chance";
|
||||
|
||||
export type PlaySlotsConfig = {
|
||||
/** 主世界层(裁决);默认 true */
|
||||
gm: boolean;
|
||||
/** 叙事转述;默认 true */
|
||||
narrator: boolean;
|
||||
/** 单角色知密视角;默认 false */
|
||||
perspective: boolean;
|
||||
/**
|
||||
* 机遇裁定(程序骰子/抽签/比点);默认 false。
|
||||
* 启用后可按需 run_worker,不自动每轮上场。
|
||||
*/
|
||||
chance?: boolean;
|
||||
/** 可选覆盖默认 ref */
|
||||
refs?: Partial<Record<PlaySlotId | OnDemandSlotId, string>>;
|
||||
};
|
||||
|
||||
export const DEFAULT_PLAY_SLOT_REFS: Record<PlaySlotId, string> = {
|
||||
gm: "world-simulator",
|
||||
narrator: "narrator",
|
||||
perspective: "role-decide",
|
||||
};
|
||||
|
||||
export const DEFAULT_ON_DEMAND_REFS: Record<OnDemandSlotId, string> = {
|
||||
chance: "chance",
|
||||
};
|
||||
|
||||
export const PLAY_SLOT_META: Record<
|
||||
PlaySlotId,
|
||||
{ label: string; purpose: string; defaultAcceptance: WorkerAcceptance }
|
||||
> = {
|
||||
gm: {
|
||||
label: "主世界层",
|
||||
purpose: "读真值与 Progressive 投影,输出结构化裁决包;可提议变量变更。",
|
||||
defaultAcceptance: "continue",
|
||||
},
|
||||
narrator: {
|
||||
label: "叙事转述",
|
||||
purpose: "只读裁决包 + 文风常驻,输出用户可见正文。",
|
||||
defaultAcceptance: "review",
|
||||
},
|
||||
perspective: {
|
||||
label: "角色视角",
|
||||
purpose: "强信息隔离时出反应建议;不写真值、不写终稿。",
|
||||
defaultAcceptance: "continue",
|
||||
},
|
||||
};
|
||||
|
||||
export const ON_DEMAND_SLOT_META: Record<
|
||||
OnDemandSlotId,
|
||||
{ label: string; purpose: string; defaultAcceptance: WorkerAcceptance }
|
||||
> = {
|
||||
chance: {
|
||||
label: "机遇裁定",
|
||||
purpose:
|
||||
"程序工具:掷骰、比点、抽签/加权抽取;按需调用,禁止模型编造随机结果。",
|
||||
defaultAcceptance: "continue",
|
||||
},
|
||||
};
|
||||
|
||||
/** 推荐回合顺序(启用的槽按此排序;不含按需槽) */
|
||||
export const PLAY_SLOT_ORDER: PlaySlotId[] = ["perspective", "gm", "narrator"];
|
||||
|
||||
export function defaultPlaySlots(): PlaySlotsConfig {
|
||||
return { gm: true, narrator: true, perspective: false, chance: false };
|
||||
}
|
||||
|
||||
export function parsePlaySlots(raw: unknown): PlaySlotsConfig | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const base = defaultPlaySlots();
|
||||
if (typeof row.gm === "boolean") base.gm = row.gm;
|
||||
if (typeof row.narrator === "boolean") base.narrator = row.narrator;
|
||||
if (typeof row.perspective === "boolean") base.perspective = row.perspective;
|
||||
if (typeof row.chance === "boolean") base.chance = row.chance;
|
||||
// 兼容旧键名
|
||||
if (typeof row.world === "boolean") base.gm = row.world;
|
||||
if (typeof row.transcription === "boolean") base.narrator = row.transcription;
|
||||
if (typeof row.oracle === "boolean") base.chance = row.oracle;
|
||||
if (typeof row.rng === "boolean") base.chance = row.rng;
|
||||
|
||||
const refsRaw = row.refs;
|
||||
if (refsRaw && typeof refsRaw === "object" && !Array.isArray(refsRaw)) {
|
||||
const refs: Partial<Record<PlaySlotId | OnDemandSlotId, string>> = {};
|
||||
for (const id of Object.keys(DEFAULT_PLAY_SLOT_REFS) as PlaySlotId[]) {
|
||||
const v = (refsRaw as Record<string, unknown>)[id];
|
||||
if (typeof v === "string" && v.trim()) refs[id] = v.trim();
|
||||
}
|
||||
for (const id of Object.keys(DEFAULT_ON_DEMAND_REFS) as OnDemandSlotId[]) {
|
||||
const v = (refsRaw as Record<string, unknown>)[id];
|
||||
if (typeof v === "string" && v.trim()) refs[id] = v.trim();
|
||||
}
|
||||
if (Object.keys(refs).length) base.refs = refs;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function refForSlot(
|
||||
slots: PlaySlotsConfig,
|
||||
id: PlaySlotId | OnDemandSlotId,
|
||||
): string {
|
||||
if (id === "chance") {
|
||||
return slots.refs?.chance?.trim() || DEFAULT_ON_DEMAND_REFS.chance;
|
||||
}
|
||||
return slots.refs?.[id]?.trim() || DEFAULT_PLAY_SLOT_REFS[id];
|
||||
}
|
||||
|
||||
export function enabledPlaySlotIds(slots: PlaySlotsConfig): PlaySlotId[] {
|
||||
return PLAY_SLOT_ORDER.filter((id) => Boolean(slots[id]));
|
||||
}
|
||||
|
||||
export function enabledOnDemandSlotIds(slots: PlaySlotsConfig): OnDemandSlotId[] {
|
||||
return slots.chance ? ["chance"] : [];
|
||||
}
|
||||
|
||||
/** 每轮管线 refs(不含按需槽) */
|
||||
export function refsFromPlaySlots(slots: PlaySlotsConfig): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of enabledPlaySlotIds(slots)) {
|
||||
const ref = refForSlot(slots, id);
|
||||
if (seen.has(ref)) continue;
|
||||
seen.add(ref);
|
||||
out.push(ref);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按需可调度 refs(可并入 activeWorkerIds,但不进自动回合序) */
|
||||
export function onDemandRefsFromPlaySlots(slots: PlaySlotsConfig): string[] {
|
||||
return enabledOnDemandSlotIds(slots).map((id) => refForSlot(slots, id));
|
||||
}
|
||||
|
||||
export function workerEntryForSlot(
|
||||
slots: PlaySlotsConfig,
|
||||
id: PlaySlotId,
|
||||
): WorkerSetEntry {
|
||||
const meta = PLAY_SLOT_META[id];
|
||||
const ref = refForSlot(slots, id);
|
||||
return {
|
||||
ref,
|
||||
name: meta.label,
|
||||
role: id,
|
||||
duty: meta.purpose,
|
||||
when:
|
||||
id === "perspective"
|
||||
? "强信息隔离且本轮需要该角色独立反应时"
|
||||
: id === "gm"
|
||||
? "每轮用户输入后"
|
||||
: "主世界层裁决包就绪后",
|
||||
rationale:
|
||||
id === "perspective"
|
||||
? "知密内容不能进主世界层上下文"
|
||||
: id === "gm"
|
||||
? "裁决与真值变更需要独立推理槽"
|
||||
: "用户可见正文与裁决分离,避免文风与规则互相挤压",
|
||||
acceptance: meta.defaultAcceptance,
|
||||
invocation: "turn",
|
||||
};
|
||||
}
|
||||
|
||||
export function workerEntryForOnDemandSlot(
|
||||
slots: PlaySlotsConfig,
|
||||
id: OnDemandSlotId,
|
||||
): WorkerSetEntry {
|
||||
const meta = ON_DEMAND_SLOT_META[id];
|
||||
const ref = refForSlot(slots, id);
|
||||
return {
|
||||
ref,
|
||||
name: meta.label,
|
||||
role: id,
|
||||
duty: meta.purpose,
|
||||
when: "需要程序随机、骰子、比点或抽签时由编排器按需调用",
|
||||
rationale: "真随机必须由程序给出,不能靠模型编造",
|
||||
acceptance: meta.defaultAcceptance,
|
||||
invocation: "on_demand",
|
||||
};
|
||||
}
|
||||
|
||||
/** 仅由 play_slots 展开 workers(含按需槽条目) */
|
||||
export function expandPlaySlotsToWorkers(slots: PlaySlotsConfig): WorkerSetEntry[] {
|
||||
return [
|
||||
...enabledPlaySlotIds(slots).map((id) => workerEntryForSlot(slots, id)),
|
||||
...enabledOnDemandSlotIds(slots).map((id) =>
|
||||
workerEntryForOnDemandSlot(slots, id),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 若声明了 play_slots:用槽位展开补齐/覆盖同 ref 的骨架;
|
||||
* 已有 workers 里同 ref 的 context/outputs/name 覆盖保留。
|
||||
*/
|
||||
export function mergeWorkersWithPlaySlots(
|
||||
existing: WorkerSetEntry[],
|
||||
slots: PlaySlotsConfig | undefined,
|
||||
): WorkerSetEntry[] {
|
||||
if (!slots) return existing;
|
||||
const expanded = expandPlaySlotsToWorkers(slots);
|
||||
if (!existing.length) return expanded;
|
||||
|
||||
const byRef = new Map<string, WorkerSetEntry>();
|
||||
for (const w of existing) {
|
||||
const ref = w.ref?.trim();
|
||||
if (ref) byRef.set(ref, w);
|
||||
}
|
||||
|
||||
const merged: WorkerSetEntry[] = expanded.map((slotEntry) => {
|
||||
const ref = slotEntry.ref!.trim();
|
||||
const prev = byRef.get(ref);
|
||||
if (!prev) return slotEntry;
|
||||
return {
|
||||
...slotEntry,
|
||||
name: prev.name ?? slotEntry.name,
|
||||
duty: prev.duty ?? slotEntry.duty,
|
||||
when: prev.when ?? slotEntry.when,
|
||||
rationale: prev.rationale ?? slotEntry.rationale,
|
||||
acceptance: prev.acceptance ?? slotEntry.acceptance,
|
||||
invocation: prev.invocation ?? slotEntry.invocation,
|
||||
context: prev.context ?? slotEntry.context,
|
||||
outputs: prev.outputs ?? slotEntry.outputs,
|
||||
presentation: prev.presentation ?? slotEntry.presentation,
|
||||
merge_considered: prev.merge_considered ?? slotEntry.merge_considered,
|
||||
};
|
||||
});
|
||||
|
||||
// 保留不在固定槽内的额外 worker(兼容旧规格 / 扩写路径)
|
||||
const slotRefs = new Set(expanded.map((e) => e.ref!.trim()));
|
||||
for (const w of existing) {
|
||||
const ref = w.ref?.trim();
|
||||
if (!ref || slotRefs.has(ref)) continue;
|
||||
merged.push(w);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function inferPlaySlotsFromWorkers(
|
||||
workers: WorkerSetEntry[],
|
||||
): PlaySlotsConfig | undefined {
|
||||
if (!workers.length) return undefined;
|
||||
const refs = new Set(
|
||||
workers.map((w) => w.ref?.trim()).filter((r): r is string => Boolean(r)),
|
||||
);
|
||||
const hasGm = refs.has(DEFAULT_PLAY_SLOT_REFS.gm);
|
||||
const hasNarrator = refs.has(DEFAULT_PLAY_SLOT_REFS.narrator);
|
||||
const hasPerspective = refs.has(DEFAULT_PLAY_SLOT_REFS.perspective);
|
||||
const hasChance = refs.has(DEFAULT_ON_DEMAND_REFS.chance);
|
||||
if (!hasGm && !hasNarrator && !hasPerspective && !hasChance) return undefined;
|
||||
return {
|
||||
gm: hasGm,
|
||||
narrator: hasNarrator,
|
||||
perspective: hasPerspective,
|
||||
chance: hasChance,
|
||||
};
|
||||
}
|
||||
231
src/skills/settlement-packet.ts
Normal file
231
src/skills/settlement-packet.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 主世界层 → 叙事转述的裁决包(settlement / direction packet)。
|
||||
* 存黑板 tag:运行.本轮.裁决
|
||||
*/
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
|
||||
export const SETTLEMENT_SCHEMA = "settlement.v1" as const;
|
||||
export const SETTLEMENT_TAG = "运行.本轮.裁决";
|
||||
|
||||
export type SettlementNpcMove = {
|
||||
who: string;
|
||||
move: string;
|
||||
wants?: string;
|
||||
refuses_to_say?: string;
|
||||
};
|
||||
|
||||
export type SettlementVariableChange = {
|
||||
key: string;
|
||||
from?: unknown;
|
||||
to?: unknown;
|
||||
delta?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type SettlementPacket = {
|
||||
schema: typeof SETTLEMENT_SCHEMA;
|
||||
/** 玩家本轮行动摘要 */
|
||||
player_action?: string;
|
||||
/** 已落地的客观变化(短句列表) */
|
||||
resolved?: string[];
|
||||
/** 转述应呈现的场面现状(玩家安全) */
|
||||
visible_now?: string;
|
||||
/** NPC 主动动作(binding move) */
|
||||
npc_moves?: SettlementNpcMove[];
|
||||
/** 真值变更提议(Runtime 合并进 变量.当前) */
|
||||
variable_changes?: SettlementVariableChange[];
|
||||
/** 禁止写入用户正文的内容 */
|
||||
do_not_say?: string[];
|
||||
/** 语气提示(通常来自 Progressive 投影) */
|
||||
tone_hint?: string;
|
||||
/** 可选:建议玩家选项(无主语动作短语) */
|
||||
suggested_actions?: string[];
|
||||
};
|
||||
|
||||
export type SettlementPacketView = {
|
||||
ok: boolean;
|
||||
parseError?: string;
|
||||
packet?: SettlementPacket;
|
||||
/** 用户友好分节,供 UI 渲染 */
|
||||
sections: Array<{ title: string; lines: string[] }>;
|
||||
};
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function asStringList(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
|
||||
}
|
||||
|
||||
function parseNpcMoves(raw: unknown): SettlementNpcMove[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: SettlementNpcMove[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const who = asString(row.who) ?? asString(row.actor) ?? asString(row.name);
|
||||
const move = asString(row.move) ?? asString(row.action);
|
||||
if (!who || !move) continue;
|
||||
out.push({
|
||||
who,
|
||||
move,
|
||||
wants: asString(row.wants),
|
||||
refuses_to_say: asString(row.refuses_to_say) ?? asString(row.refusesToSay),
|
||||
});
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
function parseVariableChanges(
|
||||
raw: unknown,
|
||||
): SettlementVariableChange[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: SettlementVariableChange[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const key = asString(row.key) ?? asString(row.name) ?? asString(row.变量);
|
||||
if (!key) continue;
|
||||
const delta =
|
||||
typeof row.delta === "number" && Number.isFinite(row.delta)
|
||||
? row.delta
|
||||
: undefined;
|
||||
out.push({
|
||||
key,
|
||||
from: row.from ?? row.从,
|
||||
to: row.to ?? row.到 ?? row.value,
|
||||
delta,
|
||||
note: asString(row.note) ?? asString(row.备注),
|
||||
});
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSettlementPacket(
|
||||
row: Record<string, unknown>,
|
||||
): SettlementPacket {
|
||||
const resolved = asStringList(row.resolved);
|
||||
const resolvedZh = asStringList(row.已解决);
|
||||
const doNot = asStringList(row.do_not_say);
|
||||
const doNotZh = asStringList(row.禁止说出);
|
||||
const suggested = asStringList(row.suggested_actions);
|
||||
const suggestedZh = asStringList(row.建议行动);
|
||||
return {
|
||||
schema: SETTLEMENT_SCHEMA,
|
||||
player_action: asString(row.player_action) ?? asString(row.玩家行动),
|
||||
resolved: resolved.length ? resolved : resolvedZh.length ? resolvedZh : undefined,
|
||||
visible_now: asString(row.visible_now) ?? asString(row.可见现状),
|
||||
npc_moves: parseNpcMoves(row.npc_moves ?? row.npcMoves ?? row.角色动作),
|
||||
variable_changes: parseVariableChanges(
|
||||
row.variable_changes ?? row.variableChanges ?? row.变量变更,
|
||||
),
|
||||
do_not_say: doNot.length ? doNot : doNotZh.length ? doNotZh : undefined,
|
||||
tone_hint: asString(row.tone_hint) ?? asString(row.语气),
|
||||
suggested_actions: suggested.length
|
||||
? suggested
|
||||
: suggestedZh.length
|
||||
? suggestedZh
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSettlementPacket(raw: string | undefined | null): SettlementPacketView {
|
||||
const text = raw?.trim();
|
||||
if (!text) {
|
||||
return { ok: false, parseError: "空裁决包", sections: [] };
|
||||
}
|
||||
|
||||
const jsonText = extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
|
||||
if (!jsonText) {
|
||||
// 兼容旧散文裁决:整段当作 visible_now
|
||||
return {
|
||||
ok: true,
|
||||
packet: {
|
||||
schema: SETTLEMENT_SCHEMA,
|
||||
visible_now: text,
|
||||
},
|
||||
sections: [{ title: "场面(未结构化)", lines: [text] }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = JSON.parse(jsonText) as unknown;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
return { ok: false, parseError: "裁决包不是 JSON 对象", sections: [] };
|
||||
}
|
||||
const packet = normalizeSettlementPacket(doc as Record<string, unknown>);
|
||||
return { ok: true, packet, sections: settlementSections(packet) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
parseError: err instanceof Error ? err.message : "裁决包 JSON 解析失败",
|
||||
sections: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function settlementSections(packet: SettlementPacket): Array<{
|
||||
title: string;
|
||||
lines: string[];
|
||||
}> {
|
||||
const sections: Array<{ title: string; lines: string[] }> = [];
|
||||
if (packet.player_action) {
|
||||
sections.push({ title: "玩家行动", lines: [packet.player_action] });
|
||||
}
|
||||
if (packet.resolved?.length) {
|
||||
sections.push({ title: "已落地", lines: packet.resolved });
|
||||
}
|
||||
if (packet.visible_now) {
|
||||
sections.push({ title: "可见现状", lines: [packet.visible_now] });
|
||||
}
|
||||
if (packet.npc_moves?.length) {
|
||||
sections.push({
|
||||
title: "角色动作",
|
||||
lines: packet.npc_moves.map((m) => {
|
||||
const extra = [m.wants && `想要:${m.wants}`, m.refuses_to_say && `回避:${m.refuses_to_say}`]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
return extra ? `${m.who}:${m.move}(${extra})` : `${m.who}:${m.move}`;
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (packet.variable_changes?.length) {
|
||||
sections.push({
|
||||
title: "变量变更",
|
||||
lines: packet.variable_changes.map((c) => {
|
||||
if (c.delta != null) {
|
||||
return `${c.key} ${c.delta >= 0 ? "+" : ""}${c.delta}` + (c.note ? `(${c.note})` : "");
|
||||
}
|
||||
if (c.from !== undefined || c.to !== undefined) {
|
||||
return `${c.key}:${String(c.from ?? "?")} → ${String(c.to ?? "?")}`;
|
||||
}
|
||||
return c.key;
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (packet.tone_hint) {
|
||||
sections.push({ title: "语气", lines: [packet.tone_hint] });
|
||||
}
|
||||
if (packet.do_not_say?.length) {
|
||||
sections.push({ title: "勿写入正文", lines: packet.do_not_say });
|
||||
}
|
||||
if (packet.suggested_actions?.length) {
|
||||
sections.push({ title: "建议行动", lines: packet.suggested_actions });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export function isSettlementLikeObject(doc: unknown): boolean {
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
|
||||
const row = doc as Record<string, unknown>;
|
||||
if (row.schema === SETTLEMENT_SCHEMA) return true;
|
||||
// 启发式:有裁决包典型键
|
||||
const keys = ["player_action", "visible_now", "resolved", "npc_moves", "variable_changes"];
|
||||
let hits = 0;
|
||||
for (const k of keys) if (k in row) hits += 1;
|
||||
return hits >= 2;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { RuntimeSession } from "../types/runtime.js";
|
||||
import {
|
||||
deriveDesignStageScope,
|
||||
deriveOnDemandWorkerScope,
|
||||
deriveRunWorkerScope,
|
||||
parseWorkerSetYaml,
|
||||
runWorkerMeta,
|
||||
@@ -19,8 +20,10 @@ export type InstanceWorkerDeclaration = {
|
||||
parsed: ParsedWorkerSet | null;
|
||||
/** 当前 lifecycle 下总管可 run_worker 的 id 列表 */
|
||||
activeWorkerIds: string[];
|
||||
/** play 阶段声明(deriveRunWorkerScope) */
|
||||
/** play 阶段声明(deriveRunWorkerScope;每轮管线) */
|
||||
playWorkerIds: string[];
|
||||
/** play 按需调度(chance 等;不进自动回合序) */
|
||||
onDemandWorkerIds: string[];
|
||||
/** 创作末尾声明(如 opening-generator) */
|
||||
designEndWorkerIds: string[];
|
||||
};
|
||||
@@ -81,6 +84,8 @@ export function buildInstanceWorkerDeclaration(
|
||||
const raw = readWorkerSetYamlForDeclaration(blackboard, session);
|
||||
const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
|
||||
const playWorkerIds = accepted && parsed ? deriveRunWorkerScope(parsed) : [];
|
||||
const onDemandWorkerIds =
|
||||
accepted && parsed ? deriveOnDemandWorkerScope(parsed) : [];
|
||||
const designEndWorkerIds =
|
||||
accepted && parsed ? deriveDesignStageScope(parsed) : [];
|
||||
|
||||
@@ -89,7 +94,13 @@ export function buildInstanceWorkerDeclaration(
|
||||
let activeWorkerIds: string[];
|
||||
|
||||
if (lifecycle === "play") {
|
||||
activeWorkerIds = [...playWorkerIds];
|
||||
activeWorkerIds = [...playWorkerIds, ...onDemandWorkerIds];
|
||||
const seen = new Set<string>();
|
||||
activeWorkerIds = activeWorkerIds.filter((id) => {
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
} else if (!accepted) {
|
||||
activeWorkerIds = [...designStepIds];
|
||||
} else {
|
||||
@@ -109,6 +120,7 @@ export function buildInstanceWorkerDeclaration(
|
||||
parsed,
|
||||
activeWorkerIds,
|
||||
playWorkerIds,
|
||||
onDemandWorkerIds,
|
||||
designEndWorkerIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import {
|
||||
inferPlaySlotsFromWorkers,
|
||||
mergeWorkersWithPlaySlots,
|
||||
onDemandRefsFromPlaySlots,
|
||||
parsePlaySlots,
|
||||
refsFromPlaySlots,
|
||||
type PlaySlotsConfig,
|
||||
} from "./play-slots.js";
|
||||
|
||||
export type WorkerSetPresentation = {
|
||||
tone?: string;
|
||||
@@ -15,6 +23,9 @@ export type WorkerSetContext = {
|
||||
|
||||
export type WorkerAcceptance = "review" | "continue";
|
||||
|
||||
/** turn = 每轮管线;on_demand = 仅显式 run_worker / toolcall */
|
||||
export type WorkerInvocation = "turn" | "on_demand";
|
||||
|
||||
export type WorkerSetEntry = {
|
||||
ref: string | null;
|
||||
/**
|
||||
@@ -31,6 +42,8 @@ export type WorkerSetEntry = {
|
||||
* review = 用户验收;continue = 可连跑下一 worker。
|
||||
*/
|
||||
acceptance?: WorkerAcceptance;
|
||||
/** 缺省 turn;chance 等程序工具为 on_demand */
|
||||
invocation?: WorkerInvocation;
|
||||
merge_considered?: string;
|
||||
gap?: string | null;
|
||||
presentation?: WorkerSetPresentation | null;
|
||||
@@ -60,6 +73,13 @@ export type ParsedWorkerSet = {
|
||||
play_morphology?: string;
|
||||
input_protocol?: Record<string, string>;
|
||||
workers: WorkerSetEntry[];
|
||||
/**
|
||||
* 固定游玩槽位勾选(世界模拟路径首选)。
|
||||
* 若存在,解析时会与 workers 合并:槽位定骨架,条目可覆盖 context/outputs。
|
||||
*/
|
||||
play_slots?: PlaySlotsConfig;
|
||||
/** 投影排序表(context-order.v1);拼装优先于此 */
|
||||
context_order?: unknown;
|
||||
tag_flow?: string[];
|
||||
resident_context?: unknown[];
|
||||
tables?: Record<string, unknown>;
|
||||
@@ -86,7 +106,7 @@ export const DESIGN_STAGE_SKILL_META: Record<
|
||||
"opening-generator": {
|
||||
label: "开局 · 开场白",
|
||||
purpose:
|
||||
"创作末尾:结合已定世界/故事写开场白(主);表初值与开场对齐,能推则推。",
|
||||
"创作末尾:优先落库设计.开场白与开场变量;否则现写开场;初值与开场同真相。",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,8 +128,8 @@ export const INSTANTIATE_SKILL_META: Record<
|
||||
{ label: string; purpose: string }
|
||||
> = {
|
||||
"world-blueprint": {
|
||||
label: "世界蓝图",
|
||||
purpose: "背景板与核心设定,供 world-simulator 等读取。",
|
||||
label: "舞台骨架",
|
||||
purpose: "可引用舞台(尺度、基底变造、关键舞台区),供主世界层等读取。",
|
||||
},
|
||||
topology: {
|
||||
label: "拓扑 / 关系",
|
||||
@@ -120,8 +140,8 @@ export const INSTANTIATE_SKILL_META: Record<
|
||||
purpose: "元规则:如何生成 NPC、物品等实例内容。",
|
||||
},
|
||||
"narrative-guide": {
|
||||
label: "叙事 / 描写指南",
|
||||
purpose: "正文 POV、时态、文风(narrator 等 static 上下文)。",
|
||||
label: "叙事指南与故事推进",
|
||||
purpose: "遣词、笔墨焦点、禁忌与推进口径(narrator / gm 上下文)。",
|
||||
},
|
||||
"variable-catalog": {
|
||||
label: "变量目录",
|
||||
@@ -163,6 +183,10 @@ export const RUN_WORKER_META: Record<string, { label: string; purpose: string }>
|
||||
label: "回合陈述",
|
||||
purpose: "结构化陈述本轮事件与各方行动/思考摘要。",
|
||||
},
|
||||
chance: {
|
||||
label: "机遇裁定",
|
||||
purpose: "程序掷骰/比点/抽签;按需调用,不进每轮管线。",
|
||||
},
|
||||
};
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
@@ -214,6 +238,11 @@ function parseWorkers(raw: unknown): WorkerSetEntry[] {
|
||||
acceptanceRaw === "review" || acceptanceRaw === "continue"
|
||||
? acceptanceRaw
|
||||
: undefined;
|
||||
const invocationRaw = asString(row.invocation);
|
||||
const invocation: WorkerInvocation | undefined =
|
||||
invocationRaw === "turn" || invocationRaw === "on_demand"
|
||||
? invocationRaw
|
||||
: undefined;
|
||||
return {
|
||||
ref,
|
||||
name: asString(row.name),
|
||||
@@ -222,6 +251,7 @@ function parseWorkers(raw: unknown): WorkerSetEntry[] {
|
||||
when: asString(row.when),
|
||||
rationale: asString(row.rationale),
|
||||
acceptance,
|
||||
invocation,
|
||||
merge_considered: asString(row.merge_considered),
|
||||
gap: ref == null ? asString(row.gap) ?? null : asString(row.gap) ?? null,
|
||||
presentation: parsePresentation(row.presentation),
|
||||
@@ -256,6 +286,13 @@ function parseWorkerSetObject(row: Record<string, unknown>): ParsedWorkerSet {
|
||||
? (interactionRaw as ParsedWorkerSetInteraction)
|
||||
: undefined;
|
||||
|
||||
const workersRaw = parseWorkers(row.workers);
|
||||
let play_slots = parsePlaySlots(row.play_slots ?? row.playSlots);
|
||||
if (!play_slots) {
|
||||
play_slots = inferPlaySlotsFromWorkers(workersRaw);
|
||||
}
|
||||
const workers = mergeWorkersWithPlaySlots(workersRaw, play_slots);
|
||||
|
||||
return {
|
||||
version: typeof row.version === "number" ? row.version : undefined,
|
||||
form_summary: asString(row.form_summary),
|
||||
@@ -280,7 +317,18 @@ function parseWorkerSetObject(row: Record<string, unknown>): ParsedWorkerSet {
|
||||
.filter(([, v]) => v),
|
||||
)
|
||||
: undefined,
|
||||
workers: parseWorkers(row.workers),
|
||||
play_slots,
|
||||
workers,
|
||||
context_order:
|
||||
row.context_order &&
|
||||
typeof row.context_order === "object" &&
|
||||
!Array.isArray(row.context_order)
|
||||
? row.context_order
|
||||
: row.contextOrder &&
|
||||
typeof row.contextOrder === "object" &&
|
||||
!Array.isArray(row.contextOrder)
|
||||
? row.contextOrder
|
||||
: undefined,
|
||||
tag_flow: asStringList(row.tag_flow),
|
||||
resident_context: Array.isArray(row.resident_context)
|
||||
? row.resident_context
|
||||
@@ -401,6 +449,9 @@ export function looksLikeProseNotSpec(text: string): boolean {
|
||||
export function isUsableWorkerSet(parsed: ParsedWorkerSet | null | undefined): boolean {
|
||||
if (!parsed || parsed.parseError) return false;
|
||||
if ((parsed.workers?.length ?? 0) > 0) return true;
|
||||
if (parsed.play_slots && (parsed.play_slots.gm || parsed.play_slots.narrator)) {
|
||||
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) {
|
||||
@@ -458,15 +509,63 @@ export function deriveInstantiateScope(workerSet: ParsedWorkerSet | null): strin
|
||||
return deriveDesignStageScope(workerSet);
|
||||
}
|
||||
|
||||
/** play 阶段启用的 run worker ref 列表(保序、去重;不含 opening-generator 等 design-end skill) */
|
||||
function isOnDemandWorker(worker: WorkerSetEntry): boolean {
|
||||
if (worker.invocation === "on_demand") return true;
|
||||
if (worker.invocation === "turn") return false;
|
||||
// 未标注时:chance 默认按需
|
||||
return worker.ref?.trim() === "chance";
|
||||
}
|
||||
|
||||
/** play 阶段每轮管线 refs(保序、去重;不含按需槽与 design-end) */
|
||||
export function deriveRunWorkerScope(workerSet: ParsedWorkerSet | null): string[] {
|
||||
if (!workerSet) return [];
|
||||
// 固定槽位:按 perspective → gm → narrator 顺序
|
||||
if (workerSet.play_slots) {
|
||||
const fromSlots = refsFromPlaySlots(workerSet.play_slots);
|
||||
if (fromSlots.length) {
|
||||
const seen = new Set(fromSlots);
|
||||
const ordered = [...fromSlots];
|
||||
for (const worker of workerSet.workers) {
|
||||
const ref = worker.ref?.trim();
|
||||
if (!ref || seen.has(ref) || DESIGN_STAGE_SKILL_META[ref]) continue;
|
||||
if (isOnDemandWorker(worker)) continue;
|
||||
seen.add(ref);
|
||||
ordered.push(ref);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
}
|
||||
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 (isOnDemandWorker(worker)) continue;
|
||||
seen.add(ref);
|
||||
ordered.push(ref);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** play 阶段可显式调度的按需 refs(dice/抽签等) */
|
||||
export function deriveOnDemandWorkerScope(
|
||||
workerSet: ParsedWorkerSet | null,
|
||||
): string[] {
|
||||
if (!workerSet) return [];
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
if (workerSet.play_slots) {
|
||||
for (const ref of onDemandRefsFromPlaySlots(workerSet.play_slots)) {
|
||||
if (seen.has(ref)) continue;
|
||||
seen.add(ref);
|
||||
ordered.push(ref);
|
||||
}
|
||||
}
|
||||
for (const worker of workerSet.workers) {
|
||||
const ref = worker.ref?.trim();
|
||||
if (!ref || seen.has(ref) || DESIGN_STAGE_SKILL_META[ref]) continue;
|
||||
if (!isOnDemandWorker(worker)) continue;
|
||||
seen.add(ref);
|
||||
ordered.push(ref);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,17 @@ import {
|
||||
residentTagFor,
|
||||
type ResidentContextEntry,
|
||||
} from "./resident-context.js";
|
||||
import {
|
||||
PLAY_SLOT_META,
|
||||
PLAY_SLOT_ORDER,
|
||||
refForSlot,
|
||||
type PlaySlotId,
|
||||
} from "./play-slots.js";
|
||||
import {
|
||||
contextOrderToView,
|
||||
parseContextOrder,
|
||||
synthesizeContextOrderFromWorkers,
|
||||
} from "./context-order.js";
|
||||
|
||||
export type TagLineView = {
|
||||
tag: string;
|
||||
@@ -93,6 +104,30 @@ export type WorkerSetUserView = {
|
||||
reasoning?: string;
|
||||
playModeLabel?: string;
|
||||
playModeHint?: string;
|
||||
/** 固定游玩槽位勾选摘要(世界模拟路径) */
|
||||
playSlots?: Array<{ id: string; label: string; enabled: boolean; ref: string }>;
|
||||
/** 投影排序(可编排);无表时可由 workers.context 合成 */
|
||||
contextOrder?: {
|
||||
brief?: string;
|
||||
editable?: boolean;
|
||||
/** 是否由声明合成(尚未写入规格) */
|
||||
synthesized?: boolean;
|
||||
slots: Array<{
|
||||
ref: string;
|
||||
label?: string;
|
||||
lines: string[];
|
||||
inserts?: Array<{
|
||||
index: number;
|
||||
order: number;
|
||||
anchor: string;
|
||||
ref: string;
|
||||
projection: string;
|
||||
note?: string;
|
||||
label?: string;
|
||||
line: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
inputProtocol?: PresentationLineView[];
|
||||
workers: WorkerCardView[];
|
||||
/**
|
||||
@@ -183,16 +218,21 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"设计.worker集",
|
||||
"世界.蓝图.确认稿",
|
||||
"世界.拓扑.*",
|
||||
"变量.目录.确认稿",
|
||||
"变量.变化规则.确认稿",
|
||||
"设计.变量设计与更新规则",
|
||||
],
|
||||
dynamic: [
|
||||
"变量.当前",
|
||||
"运行.事件流",
|
||||
"用户.最新输入",
|
||||
"上下文.角色态度",
|
||||
"大纲.当前章",
|
||||
],
|
||||
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入"],
|
||||
},
|
||||
outputs: ["运行.本轮.裁决", "运行.事件流"],
|
||||
outputs: ["运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"],
|
||||
},
|
||||
"variable-update": {
|
||||
context: {
|
||||
static: ["变量.目录.确认稿", "变量.变化规则.确认稿"],
|
||||
static: ["设计.变量设计与更新规则"],
|
||||
dynamic: ["运行.本轮.裁决", "变量.当前"],
|
||||
},
|
||||
outputs: ["运行.本轮.变量变更", "变量.当前"],
|
||||
@@ -201,11 +241,10 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
context: {
|
||||
static: [
|
||||
"设计.worker集",
|
||||
"叙事.指南.确认稿",
|
||||
"语料.场景策略集.确认稿",
|
||||
"输出.回复格式.规范",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
],
|
||||
dynamic: ["运行.事件流", "变量.当前", "运行.本轮.变量变更"],
|
||||
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
|
||||
},
|
||||
outputs: ["输出.用户展示"],
|
||||
},
|
||||
@@ -230,6 +269,13 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
},
|
||||
outputs: ["运行.本轮.角色决策"],
|
||||
},
|
||||
chance: {
|
||||
context: {
|
||||
static: ["设计.worker集"],
|
||||
dynamic: ["运行.机会请求"],
|
||||
},
|
||||
outputs: ["运行.本轮.机遇"],
|
||||
},
|
||||
};
|
||||
|
||||
const INPUT_PROTOCOL_LABELS: Record<string, string> = {
|
||||
@@ -243,6 +289,10 @@ const WORKER_ROLE_LABELS: Record<string, string> = {
|
||||
core: "核心",
|
||||
auxiliary: "辅助",
|
||||
transcription: "转述",
|
||||
gm: "主世界层",
|
||||
narrator: "叙事转述",
|
||||
perspective: "角色视角",
|
||||
chance: "机遇裁定",
|
||||
};
|
||||
|
||||
function tagMatchesPattern(tag: string, pattern: string): boolean {
|
||||
@@ -692,6 +742,36 @@ export function formatWorkerSetForUser(
|
||||
reasoning: parsed.reasoning,
|
||||
playModeLabel: morph?.label ?? morphKey,
|
||||
playModeHint: morph?.hint,
|
||||
playSlots: parsed.play_slots
|
||||
? PLAY_SLOT_ORDER.map((id: PlaySlotId) => ({
|
||||
id,
|
||||
label: PLAY_SLOT_META[id].label,
|
||||
enabled: Boolean(parsed.play_slots![id]),
|
||||
ref: refForSlot(parsed.play_slots!, id),
|
||||
}))
|
||||
: undefined,
|
||||
contextOrder: (() => {
|
||||
const order = parseContextOrder(parsed.context_order);
|
||||
if (order) return contextOrderToView(order);
|
||||
const workersForSynth = parsed.workers.map((w) => {
|
||||
const ref = w.ref?.trim() ?? "";
|
||||
const def = ref ? DEFAULT_WORKER_CONTRACTS[ref] : undefined;
|
||||
return {
|
||||
ref: w.ref,
|
||||
name: w.name,
|
||||
context: {
|
||||
static: w.context?.static ?? def?.context.static,
|
||||
dynamic: w.context?.dynamic ?? def?.context.dynamic,
|
||||
},
|
||||
};
|
||||
});
|
||||
const synth = synthesizeContextOrderFromWorkers(
|
||||
workersForSynth,
|
||||
parsed.play_slots,
|
||||
);
|
||||
if (!synth) return undefined;
|
||||
return { ...contextOrderToView(synth), synthesized: true };
|
||||
})(),
|
||||
inputProtocol,
|
||||
workers,
|
||||
contextTags,
|
||||
|
||||
Reference in New Issue
Block a user