/** * 上下文投影排序(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; 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; 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 | 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; } } catch { return undefined; } } else if (raw && typeof raw === "object" && !Array.isArray(raw)) { row = raw as Record; } 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(); 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; 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; } 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, }); }