引入游玩呈现壳与旁观维护槽,完善结算合并、十分制自评与技能产物 UI。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,29 @@ function asStringList(v: unknown): string[] {
|
||||
return v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
|
||||
}
|
||||
|
||||
/** 自评分数展示:0–10;兼容旧百分数 */
|
||||
function formatSelfScoreLabel(score: unknown): string {
|
||||
if (typeof score === "number" && Number.isFinite(score)) {
|
||||
if (score >= 0 && score <= 10) {
|
||||
const shown = Number.isInteger(score) ? String(score) : String(Math.round(score * 10) / 10);
|
||||
return `${shown}/10`;
|
||||
}
|
||||
if (score > 10 && score <= 100) {
|
||||
const ten = Math.round((score / 10) * 10) / 10;
|
||||
const shown = Number.isInteger(ten) ? String(ten) : String(ten);
|
||||
return `${shown}/10`;
|
||||
}
|
||||
}
|
||||
if (typeof score === "string" && score.trim()) {
|
||||
const s = score.trim();
|
||||
if (/\/\s*10$/i.test(s)) return s.replace(/\s+/g, "");
|
||||
const n = Number(s.replace(/%$/, ""));
|
||||
if (Number.isFinite(n)) return formatSelfScoreLabel(n);
|
||||
return s;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
function normalizeStability(v: unknown): ContextFragmentStability | undefined {
|
||||
if (v === "stable" || v === "semi" || v === "volatile") return v;
|
||||
if (v === "稳" || v === "少变") return "stable";
|
||||
@@ -164,7 +187,7 @@ export function contextFragmentToView(raw: unknown): ContextFragmentView {
|
||||
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.说明}` : ""}`;
|
||||
return `${r.名 ?? "?"}:${formatSelfScoreLabel(r.分数)}${r.说明 ? ` — ${r.说明}` : ""}`;
|
||||
});
|
||||
if (typeof row.薄弱点 === "string" && row.薄弱点.trim()) {
|
||||
lines.push(`薄弱点:${row.薄弱点.trim()}`);
|
||||
@@ -233,10 +256,7 @@ export function extractFragmentAskSidecar(raw: unknown): {
|
||||
const name = asString(r.名) ?? asString(r.维度) ?? "?";
|
||||
const score = r.分数;
|
||||
const note = asString(r.说明);
|
||||
const scoreText =
|
||||
typeof score === "number" || typeof score === "string"
|
||||
? `${score}%`
|
||||
: "?";
|
||||
const scoreText = formatSelfScoreLabel(score);
|
||||
assessmentParts.push(
|
||||
note ? `${name} ${scoreText} — ${note}` : `${name} ${scoreText}`,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
import type { ContextSegmentDef } from "./context-segments.js";
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
import { parsePlaySlots, type PlaySlotsConfig } from "./play-slots.js";
|
||||
import {
|
||||
parsePlaySlots,
|
||||
playSlotIdForRef,
|
||||
PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY,
|
||||
type PlaySlotsConfig,
|
||||
} from "./play-slots.js";
|
||||
import {
|
||||
DIALOGUE_HISTORY_TAG,
|
||||
isDialogueHistoryRef,
|
||||
@@ -430,8 +435,22 @@ export function mergeContextOrderIntoWorkerSetJson(
|
||||
/** 声明缺 context 时的兜底(与模板/检查器默认对齐的最小集) */
|
||||
const SYNTH_FALLBACK_CONTEXT: Record<
|
||||
string,
|
||||
{ static: string[]; dynamic: string[] }
|
||||
{ static: string[]; dynamic: string[]; /** 默认不注入对话.历史 */ skipHistory?: boolean }
|
||||
> = {
|
||||
auditor: {
|
||||
static: [
|
||||
"设计.变量设计与更新规则",
|
||||
"设计.生成规则",
|
||||
"设计.变量控制上下文",
|
||||
],
|
||||
dynamic: [
|
||||
"用户.最新输入",
|
||||
"变量.当前",
|
||||
"上下文.旁观.状态摘要",
|
||||
"运行.本轮.工单",
|
||||
],
|
||||
skipHistory: true,
|
||||
},
|
||||
"world-simulator": {
|
||||
static: ["设计.变量设计与更新规则"],
|
||||
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入", "上下文.角色态度"],
|
||||
@@ -460,7 +479,12 @@ export function synthesizeContextOrderFromWorkers(
|
||||
if (!ref) continue;
|
||||
const fallback = SYNTH_FALLBACK_CONTEXT[ref];
|
||||
const staticTags = w.context?.static ?? fallback?.static ?? [];
|
||||
const dynamicTags = w.context?.dynamic ?? fallback?.dynamic ?? ["用户.最新输入"];
|
||||
const dynamicTags =
|
||||
w.context?.dynamic ?? fallback?.dynamic ?? ["用户.最新输入"];
|
||||
const slotId = playSlotIdForRef(ref);
|
||||
const skipHistory =
|
||||
Boolean(fallback?.skipHistory) ||
|
||||
(slotId != null && PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY.has(slotId));
|
||||
const inserts: ContextOrderInsert[] = [
|
||||
{
|
||||
order: 0,
|
||||
@@ -479,16 +503,17 @@ export function synthesizeContextOrderFromWorkers(
|
||||
projection: tag.includes("事件流") ? "summary" : "full",
|
||||
});
|
||||
}
|
||||
inserts.push({
|
||||
order: o++,
|
||||
ref: DIALOGUE_HISTORY_TAG,
|
||||
projection: "summary",
|
||||
note: "对话历史(按投影裁剪)",
|
||||
});
|
||||
if (!skipHistory) {
|
||||
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(),
|
||||
@@ -504,8 +529,18 @@ export function synthesizeContextOrderFromWorkers(
|
||||
if (!slots.length) return undefined;
|
||||
return renumberContextOrder({
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
brief: "扁平投影序(含对话.历史标签,可编排)",
|
||||
brief: skipHistoryBrief(slots)
|
||||
? "扁平投影序(旁观维护无对话.历史;主世界层等可含历史)"
|
||||
: "扁平投影序(含对话.历史标签,可编排)",
|
||||
play_slots: playSlots,
|
||||
slots,
|
||||
});
|
||||
}
|
||||
|
||||
function skipHistoryBrief(slots: ContextOrderSlot[]): boolean {
|
||||
return slots.some(
|
||||
(s) =>
|
||||
!s.inserts.some((i) => isDialogueHistoryRef(i.ref)) &&
|
||||
playSlotIdForRef(s.ref) === "auditor",
|
||||
);
|
||||
}
|
||||
|
||||
220
src/skills/maintain-packet.ts
Normal file
220
src/skills/maintain-packet.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 旁观维护包(副 LLM):表/规则补充工单。
|
||||
* 存黑板 tag:运行.本轮.旁观
|
||||
* 多数轮为空操作;不写真相权威、不写用户正文。
|
||||
*/
|
||||
import {
|
||||
createTableFromValues,
|
||||
type TableDoc,
|
||||
} from "../blackboard/table-cells.js";
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
|
||||
export const MAINTAIN_SCHEMA = "maintain.v1" as const;
|
||||
export const MAINTAIN_TAG = "运行.本轮.旁观";
|
||||
|
||||
export type MaintainNeedGenerate =
|
||||
| false
|
||||
| {
|
||||
rule_id: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type MaintainTableOp = {
|
||||
/** set:写入绝对值;delta:在现有值上加减 */
|
||||
op: "set" | "delta";
|
||||
/** 目标表 tag;默认 变量.当前 */
|
||||
tag?: string;
|
||||
key: string;
|
||||
value?: unknown;
|
||||
delta?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type MaintainPacket = {
|
||||
schema: typeof MAINTAIN_SCHEMA;
|
||||
/** 是否需要按生成规则补产物;默认 false */
|
||||
need_generate?: MaintainNeedGenerate;
|
||||
/** 表字段操作;空数组 = 本轮无需改表 */
|
||||
table_ops?: MaintainTableOp[];
|
||||
/** 只读意见(漂移/口径),不进用户正文 */
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export type MaintainPacketView = {
|
||||
ok: boolean;
|
||||
parseError?: string;
|
||||
packet?: MaintainPacket;
|
||||
empty: boolean;
|
||||
};
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function parseNeedGenerate(raw: unknown): MaintainNeedGenerate | undefined {
|
||||
if (raw === false || raw === null || raw === undefined) return false;
|
||||
if (raw === true) return false;
|
||||
if (typeof raw === "object" && !Array.isArray(raw)) {
|
||||
const row = raw as Record<string, unknown>;
|
||||
const ruleId =
|
||||
asString(row.rule_id) ?? asString(row.ruleId) ?? asString(row.规则);
|
||||
const reason = asString(row.reason) ?? asString(row.原因) ?? "";
|
||||
if (!ruleId) return false;
|
||||
return { rule_id: ruleId, reason };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTableOps(raw: unknown): MaintainTableOp[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: MaintainTableOp[] = [];
|
||||
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 opRaw = asString(row.op) ?? "set";
|
||||
const op: "set" | "delta" = opRaw === "delta" ? "delta" : "set";
|
||||
const delta =
|
||||
typeof row.delta === "number" && Number.isFinite(row.delta)
|
||||
? row.delta
|
||||
: undefined;
|
||||
if (op === "delta" && delta == null) continue;
|
||||
if (op === "set" && row.value === undefined && row.to === undefined) continue;
|
||||
out.push({
|
||||
op,
|
||||
tag: asString(row.tag) ?? asString(row.表),
|
||||
key,
|
||||
value: row.value ?? row.to,
|
||||
delta,
|
||||
note: asString(row.note) ?? asString(row.备注),
|
||||
});
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
export function normalizeMaintainPacket(
|
||||
row: Record<string, unknown>,
|
||||
): MaintainPacket {
|
||||
const notesRaw = row.notes ?? row.备注;
|
||||
const notes = Array.isArray(notesRaw)
|
||||
? notesRaw
|
||||
.map((x) => asString(x))
|
||||
.filter((x): x is string => Boolean(x))
|
||||
: undefined;
|
||||
return {
|
||||
schema: MAINTAIN_SCHEMA,
|
||||
need_generate: parseNeedGenerate(row.need_generate ?? row.needGenerate),
|
||||
table_ops: parseTableOps(row.table_ops ?? row.tableOps ?? row.表操作),
|
||||
notes: notes?.length ? notes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyMaintainPacket(): MaintainPacket {
|
||||
return {
|
||||
schema: MAINTAIN_SCHEMA,
|
||||
need_generate: false,
|
||||
table_ops: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function isMaintainPacketEmpty(packet: MaintainPacket): boolean {
|
||||
const need =
|
||||
packet.need_generate &&
|
||||
typeof packet.need_generate === "object" &&
|
||||
Boolean(packet.need_generate.rule_id);
|
||||
const ops = packet.table_ops?.length ?? 0;
|
||||
const notes = packet.notes?.length ?? 0;
|
||||
return !need && ops === 0 && notes === 0;
|
||||
}
|
||||
|
||||
export function parseMaintainPacket(
|
||||
raw: string | undefined | null,
|
||||
): MaintainPacketView {
|
||||
const text = raw?.trim();
|
||||
if (!text) {
|
||||
return {
|
||||
ok: true,
|
||||
packet: emptyMaintainPacket(),
|
||||
empty: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 允许模型用极短词表示空操作
|
||||
if (/^(无需|无|空|skip|none|noop)$/i.test(text)) {
|
||||
return { ok: true, packet: emptyMaintainPacket(), empty: true };
|
||||
}
|
||||
|
||||
const jsonText =
|
||||
extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
|
||||
if (!jsonText) {
|
||||
return {
|
||||
ok: false,
|
||||
parseError: "旁观维护包不是 JSON",
|
||||
empty: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = JSON.parse(jsonText) as unknown;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
return {
|
||||
ok: false,
|
||||
parseError: "旁观维护包不是 JSON 对象",
|
||||
empty: true,
|
||||
};
|
||||
}
|
||||
const packet = normalizeMaintainPacket(doc as Record<string, unknown>);
|
||||
return {
|
||||
ok: true,
|
||||
packet,
|
||||
empty: isMaintainPacketEmpty(packet),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
parseError:
|
||||
err instanceof Error ? err.message : "旁观维护包 JSON 解析失败",
|
||||
empty: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将针对某一表 tag 的 ops 编成 TableDoc patch(供 mergeTableCells)。
|
||||
* 仅处理 op=set|delta;其它 tag 由调用方过滤。
|
||||
*/
|
||||
export function maintainOpsToTablePatch(
|
||||
ops: MaintainTableOp[],
|
||||
current: TableDoc | null,
|
||||
targetTag = "变量.当前",
|
||||
): TableDoc | null {
|
||||
const relevant = ops.filter(
|
||||
(op) => (op.tag?.trim() || "变量.当前") === targetTag,
|
||||
);
|
||||
if (!relevant.length) return null;
|
||||
|
||||
const byKey = new Map((current?.rows ?? []).map((r) => [r.key, r]));
|
||||
const values: Record<string, unknown> = {};
|
||||
const meta: Record<string, { note?: string }> = {};
|
||||
|
||||
for (const op of relevant) {
|
||||
const existing = byKey.get(op.key);
|
||||
if (op.op === "delta") {
|
||||
const cur = existing?.value;
|
||||
const n = typeof cur === "number" ? cur : Number(cur);
|
||||
values[op.key] = Number.isFinite(n)
|
||||
? (n as number) + (op.delta ?? 0)
|
||||
: (op.delta ?? 0);
|
||||
} else {
|
||||
values[op.key] = op.value;
|
||||
}
|
||||
if (op.note) meta[op.key] = { note: op.note };
|
||||
}
|
||||
|
||||
if (!Object.keys(values).length) return null;
|
||||
return createTableFromValues(values, "system", meta);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* 固定游玩槽位:世界模拟路径不再自由发明 workers[],
|
||||
* 以勾选 gm / narrator / perspective 展开为声明条目。
|
||||
* 以勾选 auditor / 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 PlaySlotId = "auditor" | "gm" | "narrator" | "perspective";
|
||||
/** 按需槽(不进入 auditor→perspective→gm→narrator 管线) */
|
||||
export type OnDemandSlotId = "chance";
|
||||
|
||||
export type PlaySlotsConfig = {
|
||||
/**
|
||||
* 旁观维护(副 LLM:表/规则检查,默认空操作);默认 true。
|
||||
* 每轮可上场,但不等于每轮改表。缺省键视为开启。
|
||||
*/
|
||||
auditor?: boolean;
|
||||
/** 主世界层(裁决);默认 true */
|
||||
gm: boolean;
|
||||
/** 叙事转述;默认 true */
|
||||
@@ -26,6 +31,7 @@ export type PlaySlotsConfig = {
|
||||
};
|
||||
|
||||
export const DEFAULT_PLAY_SLOT_REFS: Record<PlaySlotId, string> = {
|
||||
auditor: "auditor",
|
||||
gm: "world-simulator",
|
||||
narrator: "narrator",
|
||||
perspective: "role-decide",
|
||||
@@ -39,6 +45,12 @@ export const PLAY_SLOT_META: Record<
|
||||
PlaySlotId,
|
||||
{ label: string; purpose: string; defaultAcceptance: WorkerAcceptance }
|
||||
> = {
|
||||
auditor: {
|
||||
label: "旁观维护",
|
||||
purpose:
|
||||
"副 LLM:检查表与生成规则是否需补充;默认空操作;不写正文、不写真相权威。",
|
||||
defaultAcceptance: "continue",
|
||||
},
|
||||
gm: {
|
||||
label: "主世界层",
|
||||
purpose: "读真值与 Progressive 投影,输出结构化裁决包;可提议变量变更。",
|
||||
@@ -69,21 +81,47 @@ export const ON_DEMAND_SLOT_META: Record<
|
||||
};
|
||||
|
||||
/** 推荐回合顺序(启用的槽按此排序;不含按需槽) */
|
||||
export const PLAY_SLOT_ORDER: PlaySlotId[] = ["perspective", "gm", "narrator"];
|
||||
export const PLAY_SLOT_ORDER: PlaySlotId[] = [
|
||||
"auditor",
|
||||
"perspective",
|
||||
"gm",
|
||||
"narrator",
|
||||
];
|
||||
|
||||
/** 这些槽的默认上下文不注入「对话.历史」长聊 */
|
||||
export const PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY: ReadonlySet<PlaySlotId> =
|
||||
new Set(["auditor"]);
|
||||
|
||||
export function playSlotIdForRef(ref: string): PlaySlotId | undefined {
|
||||
const trimmed = ref.trim();
|
||||
for (const id of Object.keys(DEFAULT_PLAY_SLOT_REFS) as PlaySlotId[]) {
|
||||
if (DEFAULT_PLAY_SLOT_REFS[id] === trimmed) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function defaultPlaySlots(): PlaySlotsConfig {
|
||||
return { gm: true, narrator: true, perspective: false, chance: false };
|
||||
return {
|
||||
auditor: true,
|
||||
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.auditor === "boolean") base.auditor = row.auditor;
|
||||
// 兼容旧键名 / 别名
|
||||
if (typeof row.maintain === "boolean") base.auditor = row.maintain;
|
||||
if (typeof row.旁观 === "boolean") base.auditor = row.旁观;
|
||||
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;
|
||||
@@ -116,7 +154,10 @@ export function refForSlot(
|
||||
}
|
||||
|
||||
export function enabledPlaySlotIds(slots: PlaySlotsConfig): PlaySlotId[] {
|
||||
return PLAY_SLOT_ORDER.filter((id) => Boolean(slots[id]));
|
||||
return PLAY_SLOT_ORDER.filter((id) => {
|
||||
if (id === "auditor") return slots.auditor !== false;
|
||||
return Boolean(slots[id]);
|
||||
});
|
||||
}
|
||||
|
||||
export function enabledOnDemandSlotIds(slots: PlaySlotsConfig): OnDemandSlotId[] {
|
||||
@@ -153,17 +194,21 @@ export function workerEntryForSlot(
|
||||
role: id,
|
||||
duty: meta.purpose,
|
||||
when:
|
||||
id === "perspective"
|
||||
? "强信息隔离且本轮需要该角色独立反应时"
|
||||
: id === "gm"
|
||||
? "每轮用户输入后"
|
||||
: "主世界层裁决包就绪后",
|
||||
id === "auditor"
|
||||
? "每轮用户输入后、主世界层之前"
|
||||
: id === "perspective"
|
||||
? "强信息隔离且本轮需要该角色独立反应时"
|
||||
: id === "gm"
|
||||
? "每轮用户输入后(旁观维护与可选视角之后)"
|
||||
: "主世界层裁决包就绪后",
|
||||
rationale:
|
||||
id === "perspective"
|
||||
? "知密内容不能进主世界层上下文"
|
||||
: id === "gm"
|
||||
? "裁决与真值变更需要独立推理槽"
|
||||
: "用户可见正文与裁决分离,避免文风与规则互相挤压",
|
||||
id === "auditor"
|
||||
? "表与规则补充与主裁决分槽,避免挤占对话历史上下文"
|
||||
: id === "perspective"
|
||||
? "知密内容不能进主世界层上下文"
|
||||
: id === "gm"
|
||||
? "裁决与真值变更需要独立推理槽"
|
||||
: "用户可见正文与裁决分离,避免文风与规则互相挤压",
|
||||
acceptance: meta.defaultAcceptance,
|
||||
invocation: "turn",
|
||||
};
|
||||
@@ -251,12 +296,23 @@ export function inferPlaySlotsFromWorkers(
|
||||
const refs = new Set(
|
||||
workers.map((w) => w.ref?.trim()).filter((r): r is string => Boolean(r)),
|
||||
);
|
||||
const hasAuditor = refs.has(DEFAULT_PLAY_SLOT_REFS.auditor);
|
||||
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;
|
||||
if (
|
||||
!hasAuditor &&
|
||||
!hasGm &&
|
||||
!hasNarrator &&
|
||||
!hasPerspective &&
|
||||
!hasChance
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
// 旧规格无 auditor ref → 视为关,避免强行插入
|
||||
auditor: hasAuditor,
|
||||
gm: hasGm,
|
||||
narrator: hasNarrator,
|
||||
perspective: hasPerspective,
|
||||
|
||||
345
src/skills/present-packet.ts
Normal file
345
src/skills/present-packet.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 游玩呈现包 present.v1 + 从「设计.正文组成」解析壳适配。
|
||||
* 规范:docs/play-presentation-shells.md
|
||||
*/
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
|
||||
export const PRESENT_SCHEMA = "present.v1" as const;
|
||||
export const PRESENT_TAG = "输出.用户展示";
|
||||
|
||||
export const PRESENT_SHELL_IDS = [
|
||||
"prose",
|
||||
"chat_monitor",
|
||||
"turn_panel",
|
||||
"chapter_reader",
|
||||
] as const;
|
||||
|
||||
export type PresentShellId = (typeof PRESENT_SHELL_IDS)[number];
|
||||
|
||||
export type PresentRegionId =
|
||||
| "monitor"
|
||||
| "header"
|
||||
| "body"
|
||||
| "footer"
|
||||
| "aside"
|
||||
| "hidden";
|
||||
|
||||
export type PresentShellTweaks = {
|
||||
tone_chrome?: string;
|
||||
show_suggested_actions?: boolean;
|
||||
block_labels?: Record<string, string>;
|
||||
empty_states?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ShellAdaptation = {
|
||||
shell_id: PresentShellId;
|
||||
why?: string;
|
||||
tweaks: PresentShellTweaks;
|
||||
};
|
||||
|
||||
export type PresentBlocks = Partial<
|
||||
Record<PresentRegionId, string | Record<string, unknown> | unknown[]>
|
||||
>;
|
||||
|
||||
export type PresentPacket = {
|
||||
schema: typeof PRESENT_SCHEMA;
|
||||
shell: PresentShellId;
|
||||
blocks: PresentBlocks;
|
||||
meta?: {
|
||||
suggested_actions?: string[];
|
||||
hide?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PresentPacketView = {
|
||||
ok: boolean;
|
||||
parseError?: string;
|
||||
packet: PresentPacket;
|
||||
/** 原文不是 JSON 时回退为 prose body */
|
||||
fallbackPlain: boolean;
|
||||
};
|
||||
|
||||
const SHELL_SET = new Set<string>(PRESENT_SHELL_IDS);
|
||||
|
||||
export function isPresentShellId(v: unknown): v is PresentShellId {
|
||||
return typeof v === "string" && SHELL_SET.has(v);
|
||||
}
|
||||
|
||||
export function defaultShellTweaks(
|
||||
shell: PresentShellId,
|
||||
): PresentShellTweaks {
|
||||
return {
|
||||
tone_chrome: "default",
|
||||
show_suggested_actions: shell === "turn_panel",
|
||||
block_labels: {},
|
||||
empty_states: {},
|
||||
};
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function asStringList(v: unknown): string[] | undefined {
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const out = v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
function asStringMap(v: unknown): Record<string, string> | undefined {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
const key = k.trim();
|
||||
const s = asString(val);
|
||||
if (key && s) out[key] = s;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
export function normalizePresentShellId(
|
||||
raw: unknown,
|
||||
fallback: PresentShellId = "prose",
|
||||
): PresentShellId {
|
||||
if (isPresentShellId(raw)) return raw;
|
||||
const s = asString(raw);
|
||||
if (s && isPresentShellId(s)) return s;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeBlocks(raw: unknown): PresentBlocks {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const row = raw as Record<string, unknown>;
|
||||
const out: PresentBlocks = {};
|
||||
const keys: PresentRegionId[] = [
|
||||
"monitor",
|
||||
"header",
|
||||
"body",
|
||||
"footer",
|
||||
"aside",
|
||||
"hidden",
|
||||
];
|
||||
for (const k of keys) {
|
||||
if (row[k] !== undefined) out[k] = row[k] as PresentBlocks[typeof k];
|
||||
}
|
||||
// 兼容中文键
|
||||
if (out.body === undefined && row.正文 !== undefined) out.body = row.正文 as string;
|
||||
if (out.monitor === undefined && row.监控 !== undefined) {
|
||||
out.monitor = row.监控 as PresentBlocks["monitor"];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizePresentPacket(
|
||||
row: Record<string, unknown>,
|
||||
fallbackShell: PresentShellId = "prose",
|
||||
): PresentPacket {
|
||||
const shell = normalizePresentShellId(
|
||||
row.shell ?? row.shell_id ?? row.呈现壳,
|
||||
fallbackShell,
|
||||
);
|
||||
return {
|
||||
schema: PRESENT_SCHEMA,
|
||||
shell,
|
||||
blocks: normalizeBlocks(row.blocks ?? row.区域),
|
||||
meta: {
|
||||
suggested_actions: asStringList(
|
||||
row.meta && typeof row.meta === "object"
|
||||
? (row.meta as Record<string, unknown>).suggested_actions ??
|
||||
(row.meta as Record<string, unknown>).建议行动
|
||||
: row.suggested_actions,
|
||||
),
|
||||
hide: asStringList(
|
||||
row.meta && typeof row.meta === "object"
|
||||
? (row.meta as Record<string, unknown>).hide
|
||||
: undefined,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function presentFromPlainText(
|
||||
text: string,
|
||||
shell: PresentShellId = "prose",
|
||||
): PresentPacket {
|
||||
return {
|
||||
schema: PRESENT_SCHEMA,
|
||||
shell,
|
||||
blocks: { body: text },
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePresentPacket(
|
||||
raw: string | undefined | null,
|
||||
fallbackShell: PresentShellId = "prose",
|
||||
): PresentPacketView {
|
||||
const text = raw?.trim() ?? "";
|
||||
if (!text) {
|
||||
return {
|
||||
ok: true,
|
||||
packet: presentFromPlainText("", fallbackShell),
|
||||
fallbackPlain: true,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonText =
|
||||
extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
|
||||
if (!jsonText) {
|
||||
return {
|
||||
ok: true,
|
||||
packet: presentFromPlainText(text, fallbackShell),
|
||||
fallbackPlain: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = JSON.parse(jsonText) as unknown;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
return {
|
||||
ok: true,
|
||||
packet: presentFromPlainText(text, fallbackShell),
|
||||
fallbackPlain: true,
|
||||
};
|
||||
}
|
||||
const row = doc as Record<string, unknown>;
|
||||
// 明确 present 或带 blocks/shell 的展示包
|
||||
const looksPresent =
|
||||
row.schema === PRESENT_SCHEMA ||
|
||||
row.blocks != null ||
|
||||
isPresentShellId(row.shell) ||
|
||||
isPresentShellId(row.shell_id);
|
||||
if (!looksPresent && row.schema) {
|
||||
// 其它 schema(如 settlement)不当作 present
|
||||
return {
|
||||
ok: true,
|
||||
packet: presentFromPlainText(text, fallbackShell),
|
||||
fallbackPlain: true,
|
||||
};
|
||||
}
|
||||
const packet = normalizePresentPacket(row, fallbackShell);
|
||||
if (
|
||||
packet.blocks.body === undefined &&
|
||||
!packet.blocks.monitor &&
|
||||
!packet.blocks.header
|
||||
) {
|
||||
// JSON 但无区域 → 整段当 body 说明
|
||||
if (!looksPresent) {
|
||||
return {
|
||||
ok: true,
|
||||
packet: presentFromPlainText(text, fallbackShell),
|
||||
fallbackPlain: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, packet, fallbackPlain: false };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
parseError: err instanceof Error ? err.message : "present JSON 解析失败",
|
||||
packet: presentFromPlainText(text, fallbackShell),
|
||||
fallbackPlain: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isPresentLikeObject(doc: unknown): boolean {
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
|
||||
const row = doc as Record<string, unknown>;
|
||||
if (row.schema === PRESENT_SCHEMA) return true;
|
||||
if (isPresentShellId(row.shell) && row.blocks != null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「设计.正文组成」context-fragment 解析壳适配。
|
||||
*/
|
||||
export function parseShellAdaptationFromReplyFormat(
|
||||
raw: string | undefined | null,
|
||||
): ShellAdaptation | undefined {
|
||||
if (!raw?.trim()) return undefined;
|
||||
const jsonText =
|
||||
extractJsonObjectText(raw) ?? (raw.trim().startsWith("{") ? raw.trim() : null);
|
||||
if (!jsonText) return undefined;
|
||||
try {
|
||||
const doc = JSON.parse(jsonText) as unknown;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return undefined;
|
||||
const row = doc as Record<string, unknown>;
|
||||
const body =
|
||||
row.正文 && typeof row.正文 === "object" && !Array.isArray(row.正文)
|
||||
? (row.正文 as Record<string, unknown>)
|
||||
: row;
|
||||
const shellBox =
|
||||
body.呈现壳 && typeof body.呈现壳 === "object" && !Array.isArray(body.呈现壳)
|
||||
? (body.呈现壳 as Record<string, unknown>)
|
||||
: body.shell && typeof body.shell === "object"
|
||||
? (body.shell as Record<string, unknown>)
|
||||
: body;
|
||||
|
||||
const shellId = normalizePresentShellId(
|
||||
shellBox.shell_id ?? shellBox.shellId ?? body.shell_id ?? body.shell,
|
||||
"prose",
|
||||
);
|
||||
// 若正文组成完全没提壳,且也不是明确 prose 局,仍返回 prose 默认
|
||||
const tweaksRaw =
|
||||
shellBox.微调 && typeof shellBox.微调 === "object"
|
||||
? (shellBox.微调 as Record<string, unknown>)
|
||||
: shellBox.tweaks && typeof shellBox.tweaks === "object"
|
||||
? (shellBox.tweaks as Record<string, unknown>)
|
||||
: {};
|
||||
const base = defaultShellTweaks(shellId);
|
||||
return {
|
||||
shell_id: shellId,
|
||||
why: asString(shellBox.为何选它 ?? shellBox.why),
|
||||
tweaks: {
|
||||
tone_chrome:
|
||||
asString(tweaksRaw.tone_chrome) ?? base.tone_chrome,
|
||||
show_suggested_actions:
|
||||
typeof tweaksRaw.show_suggested_actions === "boolean"
|
||||
? tweaksRaw.show_suggested_actions
|
||||
: base.show_suggested_actions,
|
||||
block_labels:
|
||||
asStringMap(tweaksRaw.block_labels) ?? base.block_labels,
|
||||
empty_states:
|
||||
asStringMap(tweaksRaw.empty_states) ?? base.empty_states,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 区域是否应在该壳默认展示(无内容时可按 empty_states 显示) */
|
||||
export function shellDefaultRegions(
|
||||
shell: PresentShellId,
|
||||
): PresentRegionId[] {
|
||||
switch (shell) {
|
||||
case "chat_monitor":
|
||||
return ["monitor", "body", "footer"];
|
||||
case "turn_panel":
|
||||
return ["monitor", "header", "body", "footer", "aside"];
|
||||
case "chapter_reader":
|
||||
return ["header", "body", "aside", "footer"];
|
||||
case "prose":
|
||||
default:
|
||||
return ["body"];
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPresentBlockContent(
|
||||
value: string | Record<string, unknown> | unknown[] | undefined,
|
||||
): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((x) => (typeof x === "string" ? x : JSON.stringify(x)))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
// 监控键值 → 短行
|
||||
return Object.entries(value)
|
||||
.map(([k, v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
* 主世界层 → 叙事转述的裁决包(settlement / direction packet)。
|
||||
* 存黑板 tag:运行.本轮.裁决
|
||||
*/
|
||||
import {
|
||||
createTableFromValues,
|
||||
type TableDoc,
|
||||
} from "../blackboard/table-cells.js";
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
|
||||
export const SETTLEMENT_SCHEMA = "settlement.v1" as const;
|
||||
@@ -229,3 +233,37 @@ export function isSettlementLikeObject(doc: unknown): boolean {
|
||||
for (const k of keys) if (k in row) hits += 1;
|
||||
return hits >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 variable_changes 编成 TableDoc patch,供合并进「变量.当前」。
|
||||
* - 有 `to` → 写绝对值
|
||||
* - 仅有 `delta` → 在现有数值上加减(无现值则从 0)
|
||||
*/
|
||||
export function settlementChangesToTablePatch(
|
||||
changes: SettlementVariableChange[],
|
||||
current: TableDoc | null,
|
||||
): TableDoc | null {
|
||||
if (!changes.length) return null;
|
||||
const byKey = new Map((current?.rows ?? []).map((r) => [r.key, r]));
|
||||
const values: Record<string, unknown> = {};
|
||||
const meta: Record<string, { note?: string }> = {};
|
||||
|
||||
for (const c of changes) {
|
||||
const key = c.key.trim();
|
||||
if (!key) continue;
|
||||
const existing = byKey.get(key);
|
||||
if (c.to !== undefined) {
|
||||
values[key] = c.to;
|
||||
} else if (c.delta != null) {
|
||||
const cur = existing?.value;
|
||||
const n = typeof cur === "number" ? cur : Number(cur);
|
||||
values[key] = Number.isFinite(n) ? (n as number) + c.delta : c.delta;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (c.note) meta[key] = { note: c.note };
|
||||
}
|
||||
|
||||
if (!Object.keys(values).length) return null;
|
||||
return createTableFromValues(values, "system", meta);
|
||||
}
|
||||
|
||||
@@ -212,6 +212,23 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
string,
|
||||
{ context: { static: string[]; dynamic: string[] }; outputs: string[] }
|
||||
> = {
|
||||
auditor: {
|
||||
context: {
|
||||
static: [
|
||||
"设计.worker集",
|
||||
"设计.变量设计与更新规则",
|
||||
"设计.生成规则",
|
||||
"设计.变量控制上下文",
|
||||
],
|
||||
dynamic: [
|
||||
"用户.最新输入",
|
||||
"变量.当前",
|
||||
"上下文.旁观.状态摘要",
|
||||
"运行.本轮.工单",
|
||||
],
|
||||
},
|
||||
outputs: ["运行.本轮.旁观"],
|
||||
},
|
||||
"world-simulator": {
|
||||
context: {
|
||||
static: [
|
||||
@@ -226,6 +243,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"用户.最新输入",
|
||||
"上下文.角色态度",
|
||||
"大纲.当前章",
|
||||
"运行.本轮.旁观",
|
||||
],
|
||||
},
|
||||
outputs: ["运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"],
|
||||
@@ -243,8 +261,10 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"设计.worker集",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
|
||||
dynamic: ["运行.本轮.裁决", "用户.最新输入", "变量.当前"],
|
||||
},
|
||||
outputs: ["输出.用户展示"],
|
||||
},
|
||||
@@ -289,6 +309,7 @@ const WORKER_ROLE_LABELS: Record<string, string> = {
|
||||
core: "核心",
|
||||
auxiliary: "辅助",
|
||||
transcription: "转述",
|
||||
auditor: "旁观维护",
|
||||
gm: "主世界层",
|
||||
narrator: "叙事转述",
|
||||
perspective: "角色视角",
|
||||
@@ -746,7 +767,10 @@ export function formatWorkerSetForUser(
|
||||
? PLAY_SLOT_ORDER.map((id: PlaySlotId) => ({
|
||||
id,
|
||||
label: PLAY_SLOT_META[id].label,
|
||||
enabled: Boolean(parsed.play_slots![id]),
|
||||
enabled:
|
||||
id === "auditor"
|
||||
? parsed.play_slots!.auditor !== false
|
||||
: Boolean(parsed.play_slots![id]),
|
||||
ref: refForSlot(parsed.play_slots!, id),
|
||||
}))
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user