把创作产物收敛为可挂载片段与 play_slots/context_order,同步修订世界模拟器模块与运行时拼装。 Co-authored-by: Cursor <cursoragent@cursor.com>
512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
/**
|
||
* 上下文投影排序(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,
|
||
});
|
||
}
|