diff --git a/src/persona/protagonist-bind.ts b/src/persona/protagonist-bind.ts index 832c3bd..279baaa 100644 --- a/src/persona/protagonist-bind.ts +++ b/src/persona/protagonist-bind.ts @@ -75,18 +75,24 @@ export function protagonistFragmentFromPersona( ); } -/** 黑板上尚无该 tag 的主角设定时写入(可指定开场同槽 tag)。 */ +/** + * 黑板上尚无该 tag 的主角设定时写入(可指定开场同槽 tag)。 + * overwrite:开场自带的同槽名片随开场改稿同步。 + */ export function ensureProtagonistFromPersona( board: Blackboard, persona: OpeningUserPersona | null | undefined, tag: string = PROTAGONIST_TAG, + opts: { overwrite?: boolean } = {}, ): boolean { if (!persona?.name?.trim()) return false; const writeTag = tag.trim() || PROTAGONIST_TAG; - if (board.getContentByTag(writeTag)?.trim()) return false; + const existing = board.getContentByTag(writeTag)?.trim(); + const content = protagonistFragmentFromPersona(persona); + if (existing && (!opts.overwrite || existing === content.trim())) return false; board.write({ tag: writeTag, - content: protagonistFragmentFromPersona(persona), + content, source: "runtime", }); return true; diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index fdef036..9d97913 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -140,6 +140,10 @@ import { isDictateModeValue, } from "../dictate/types.js"; import { applyDictatePlayBind } from "../dictate/play-bind.js"; +import { + applyMaintenanceStrategyToPlaySlots, + resolveMaintenanceStrategyFromBoard, +} from "../skills/maintenance-strategy.js"; import { dictateProductFamily, mergeGenerationRulesArtifacts, @@ -148,6 +152,11 @@ import { VARIABLE_CATALOG_TAG, seedVariablesFromCatalog, } from "../skills/variable-catalog.js"; +import { + VARIABLE_DESIGN_TAG, + syncVariableDesignToRuntime, + writeVariableDesignSync, +} from "../skills/variable-design-compile.js"; import { VALUE_MAP_TAG, reprojectValueMaps, @@ -164,6 +173,11 @@ import { parseShellAdaptationFromReplyFormat, presentOutputFromFallback, } from "../skills/present-packet.js"; +import { parseReplyDataContract } from "../skills/reply-contract.js"; +import { + mergePresentWithProjections, + projectPresentBlocks, +} from "../skills/present-project.js"; import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js"; import { executeChance, @@ -2049,21 +2063,45 @@ export class PhaseRuntime { // 偏好/文风改由节点产物(设计.用户需求 / 设计.叙事指南*)挂载,不再全量自动摊库 if (this.isDictateIntakeMode()) { const bound = applyDictatePlayBind(this.blackboard); - if (bound.boundTags.length) { + if (!bound.skipped && bound.boundTags.length) { this.onMessage( `[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`, ); - } else { + } else if (!bound.skipped) { this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)"); } + const strategyRaw = this.blackboard.getContentByTag("设计.维护策略")?.trim(); + if (strategyRaw) { + try { + const s = JSON.parse(strategyRaw) as { + advice?: string; + auditor?: boolean; + inline?: boolean; + }; + if (s.auditor) { + this.onMessage( + `[维护策略] 旁观副 LLM 填表(play_slots.auditor=true)`, + ); + } else if (s.advice && s.advice !== "none") { + this.onMessage(`[维护策略] ${s.advice} → 旁观未开`); + } + } catch { + /* ignore */ + } + } this.seedVariablesAndReproject("opening-setup"); } else { const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim(); const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim(); if (!finalTag) { + const strategy = resolveMaintenanceStrategyFromBoard(this.blackboard); + const play_slots = applyMaintenanceStrategyToPlaySlots( + defaultPlaySlots(), + strategy, + ); const spec = draft?.trim() ? draft - : JSON.stringify({ play_slots: defaultPlaySlots() }); + : JSON.stringify({ play_slots }, null, 2); this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup"); } } @@ -2077,36 +2115,84 @@ export class PhaseRuntime { }; } - /** 目录初值 → 变量.当前;再按 设计.变量映射 重写投影 tag */ + /** 变量设计 → 目录 → 变量.当前;再按 设计.变量映射 重写投影 tag */ private seedVariablesAndReproject(actor: string): void { - const seeded = seedVariablesFromCatalog({ + const designSync = syncVariableDesignToRuntime({ + designRaw: this.blackboard.getContentByTag(VARIABLE_DESIGN_TAG), catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG), currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG), initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG), - actor: `system:${actor}`, }); - if (seeded.initial) { - this.blackboard.write({ - tag: OPENING_INITIAL_VARS_TAG, - content: seeded.initial, - source: actor, + if (designSync.catalog || designSync.current || designSync.initial) { + writeVariableDesignSync(designSync, (tag, content) => { + this.blackboard.write({ tag, content, source: actor }); }); - } - if (seeded.current) { - this.blackboard.write({ - tag: OPENING_CURRENT_VARS_TAG, - content: seeded.current, - source: actor, - }); - if (seeded.seededKeys.length) { + if (designSync.added.length) { this.onMessage( - `[变量] 已种初值:${seeded.seededKeys.join("、")}`, + `[变量] 已从变量设计同步目录:${designSync.added.join("、")}`, ); } + if (designSync.current && designSync.seededKeys.length) { + this.onMessage( + `[变量] 已种初值:${designSync.seededKeys.join("、")}`, + ); + } + } else { + const seeded = seedVariablesFromCatalog({ + catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG), + currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG), + initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG), + actor: `system:${actor}`, + }); + if (seeded.initial) { + this.blackboard.write({ + tag: OPENING_INITIAL_VARS_TAG, + content: seeded.initial, + source: actor, + }); + } + if (seeded.current) { + this.blackboard.write({ + tag: OPENING_CURRENT_VARS_TAG, + content: seeded.current, + source: actor, + }); + if (seeded.seededKeys.length) { + this.onMessage( + `[变量] 已种初值:${seeded.seededKeys.join("、")}`, + ); + } + } } this.applyValueMapProjections(actor); } + /** 把 program_projection 块从黑板灌进 present 正文 */ + private enrichPresentWithProjections(content: string): string { + const trimmed = content.trim(); + // 纯 Markdown 终稿保持原文;投影由视图 presentationProjection 灌进壳 + if (!trimmed.startsWith("{")) return content; + const replyFormatRaw = + this.blackboard.getContentByTag("设计.正文组成") ?? + this.blackboard.getContentByTag("设计.0层正文组成") ?? + this.blackboard.getContentByTag("设计.回复格式"); + const contract = parseReplyDataContract(replyFormatRaw); + if (!contract?.programBlocks.length) return content; + const projections = projectPresentBlocks({ + contract, + statusBarRaw: + this.blackboard.getContentByTag("设计.监控栏") ?? + this.blackboard.getContentByTag("设计.回复呈现"), + currentVarsRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG), + topologyRaw: this.blackboard.getContentByTag("运行.拓扑"), + readTag: (tag) => this.blackboard.getContentByTag(tag), + }); + if (!Object.keys(projections).length) return content; + const shell = + parseShellAdaptationFromReplyFormat(replyFormatRaw)?.shell_id ?? "prose"; + return mergePresentWithProjections(content, projections, shell); + } + /** 按当前真值持续投影映射 tag(非 once) */ private applyValueMapProjections(workerId: string): void { if (!this.blackboard.getContentByTag(VALUE_MAP_TAG)?.trim()) return; @@ -2589,10 +2675,13 @@ export class PhaseRuntime { ): string { const tableTags = new Set(["运行.初始变量", "变量.当前"]); let toWrite = content; + if (tag === "输出.用户展示" || tag === "输出.开场白") { + toWrite = this.enrichPresentWithProjections(toWrite); + } let prevDoc: TableDoc | null = null; let nextDoc: TableDoc | null = null; if (tableTags.has(tag)) { - const patch = parseTableDoc(content); + const patch = parseTableDoc(toWrite); if (patch) { prevDoc = parseTableDoc(this.blackboard.getContentByTag(tag)); const { doc, skipped } = mergeTableCells({ diff --git a/src/skills/maintenance-strategy.ts b/src/skills/maintenance-strategy.ts new file mode 100644 index 0000000..4f3a7a2 --- /dev/null +++ b/src/skills/maintenance-strategy.ts @@ -0,0 +1,199 @@ +/** + * 有变量真值时固定旁观副 LLM 填表(auditor_bundle)。 + * 旁观上下文刻意保持小:上一份表 + 创作期维护准则 + 本轮主世界正文; + * 不再按维护复杂度.建议在内联 / 旁观间切换,避免主世界 tool / state_ops 反复探测。 + * 变量设计里的维护复杂度可作旁观备注,不驱动开槽。 + */ +import type { Blackboard } from "../blackboard/blackboard.js"; +import { extractJsonObjectText } from "./worker-set-parse.js"; +import type { PlaySlotsConfig } from "./play-slots.js"; +import { VARIABLE_DESIGN_TAG } from "./variable-design-compile.js"; + +export const MAINTENANCE_STRATEGY_SCHEMA = "maintenance-strategy.v1" as const; +export const MAINTENANCE_STRATEGY_TAG = "设计.维护策略"; + +export type MaintenanceAdvice = + | "none" + | "inline_tool" + | "inline_structured" + | "auditor_bundle"; + +export type MaintenanceStrategyDoc = { + schema: typeof MAINTENANCE_STRATEGY_SCHEMA; + advice: MaintenanceAdvice; + /** 是否启用旁观维护槽 */ + auditor: boolean; + /** 是否走主世界内联维护(已弃用开槽;固定旁观后恒为 false) */ + inline: boolean; + source: string; + 依据?: string[]; +}; + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function asObject(v: unknown): Record | undefined { + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + +export function normalizeMaintenanceAdvice(raw: unknown): MaintenanceAdvice { + const s = asString(raw)?.toLowerCase() ?? ""; + if ( + s === "auditor_bundle" || + s === "auditor" || + s === "旁观" || + s === "旁观维护" || + s === "bundle" + ) { + return "auditor_bundle"; + } + if ( + s === "inline_structured" || + s === "structured" || + s === "内联结构化" || + s === "state_ops" + ) { + return "inline_structured"; + } + if ( + s === "inline_tool" || + s === "inline" || + s === "tool" || + s === "内联" || + s === "内联工具" + ) { + return "inline_tool"; + } + if (s === "none" || s === "无" || s === "不维护") return "none"; + return "none"; +} + +function designHasTruths(raw: string | null | undefined): boolean { + if (!raw?.trim()) return false; + const text = extractJsonObjectText(raw) ?? raw.trim(); + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return false; + } + const root = asObject(doc); + if (!root) return false; + const body = asObject(root.正文) ?? root; + const truths = body.真值 ?? body.fields ?? body.变量; + return Array.isArray(truths) && truths.length > 0; +} + +/** 解析变量设计产物中的维护复杂度(仅作备注;开槽不读此建议) */ +export function parseMaintenanceAdviceFromVariableDesign( + raw: string | null | undefined, +): { advice: MaintenanceAdvice; 依据: string[] } | null { + if (!raw?.trim()) return null; + const text = extractJsonObjectText(raw) ?? raw.trim(); + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return null; + } + const root = asObject(doc); + if (!root) return null; + const body = asObject(root.正文) ?? root; + const box = asObject(body.维护复杂度) ?? asObject(body.maintenance); + if (!box) { + if (designHasTruths(raw)) { + return { + advice: "auditor_bundle", + 依据: ["变量设计有真值;程序固定旁观副 LLM 填表"], + }; + } + return { advice: "none", 依据: [] }; + } + const advice = normalizeMaintenanceAdvice(box.建议 ?? box.advice ?? box.strategy); + const reasonsRaw = box.依据 ?? box.reasons ?? box.why; + const 依据 = Array.isArray(reasonsRaw) + ? reasonsRaw.map((r) => asString(r)).filter((r): r is string => Boolean(r)) + : asString(reasonsRaw) + ? [asString(reasonsRaw)!] + : []; + return { advice, 依据 }; +} + +export function strategyFromAdvice( + advice: MaintenanceAdvice, + 依据: string[] = [], + source = VARIABLE_DESIGN_TAG, +): MaintenanceStrategyDoc { + // 开槽层:仅 auditor_bundle 开旁观;inline_* 在 resolve 阶段会被抬成旁观 + const auditor = advice === "auditor_bundle"; + const inline = advice === "inline_tool" || advice === "inline_structured"; + return { + schema: MAINTENANCE_STRATEGY_SCHEMA, + advice, + auditor, + inline, + source, + ...(依据.length ? { 依据 } : {}), + }; +} + +/** + * 有真值 → 固定旁观填表;无真值 → 不启旁观。 + * 设计里的 inline_* 建议不关闭旁观,只记入依据。 + */ +export function resolveMaintenanceStrategyFromBoard( + board: Blackboard, +): MaintenanceStrategyDoc { + const designRaw = board.getContentByTag(VARIABLE_DESIGN_TAG); + if (!designHasTruths(designRaw)) { + return strategyFromAdvice( + "none", + ["无变量真值,不启旁观填表"], + VARIABLE_DESIGN_TAG, + ); + } + const parsed = parseMaintenanceAdviceFromVariableDesign(designRaw); + const 依据 = [ + "程序固定旁观副 LLM 填表(上一份表 + 维护准则 + 本轮主世界正文)", + ...(parsed?.依据 ?? []), + ]; + if ( + parsed && + parsed.advice !== "auditor_bundle" && + parsed.advice !== "none" + ) { + 依据.push(`设计原建议 ${parsed.advice},已忽略内联开槽`); + } + return strategyFromAdvice("auditor_bundle", 依据, VARIABLE_DESIGN_TAG); +} + +/** 按维护策略覆盖 play_slots.auditor(不改 gm/narrator 等) */ +export function applyMaintenanceStrategyToPlaySlots( + slots: PlaySlotsConfig, + strategy: MaintenanceStrategyDoc, +): PlaySlotsConfig { + return { + ...slots, + auditor: strategy.auditor, + }; +} + +export function serializeMaintenanceStrategy(doc: MaintenanceStrategyDoc): string { + return JSON.stringify(doc, null, 2); +} + +export function writeMaintenanceStrategy( + board: Blackboard, + strategy: MaintenanceStrategyDoc, + source = "system:maintenance-strategy", +): void { + board.write({ + tag: MAINTENANCE_STRATEGY_TAG, + content: serializeMaintenanceStrategy(strategy), + source, + }); +} diff --git a/src/skills/opening-seal.ts b/src/skills/opening-seal.ts index f5526e3..2fbd22d 100644 --- a/src/skills/opening-seal.ts +++ b/src/skills/opening-seal.ts @@ -347,13 +347,18 @@ export function parseOpeningUserPersona( if (!text) return null; try { const doc = JSON.parse(text) as unknown; + const row = + doc && typeof doc === "object" && !Array.isArray(doc) + ? (doc as Record) + : undefined; + const meta = + row?.meta && typeof row.meta === "object" && !Array.isArray(row.meta) + ? (row.meta as Record) + : undefined; const fromJson = parseUserPersonaField(doc) || - parseUserPersonaField( - doc && typeof doc === "object" && !Array.isArray(doc) - ? (doc as Record).用户角色 - : undefined, - ); + parseUserPersonaField(row?.用户角色) || + parseUserPersonaField(meta?.用户角色); if (fromJson) return fromJson; } catch { /* not json */ diff --git a/src/skills/play-aux.ts b/src/skills/play-aux.ts index a71cb2c..7b52011 100644 --- a/src/skills/play-aux.ts +++ b/src/skills/play-aux.ts @@ -45,7 +45,10 @@ export type PlayAuxOpening = { /** 产物 tag:设计.开场白 或 设计.开场白#槽位;opening-setup 候选为假想 tag */ tag: string; slot: string; + /** 侧栏预览用:present.v1 已压成可读正文 */ text: string; + /** 选用时落盘 / 灌呈现壳:保留完整 present.v1 或散文原文 */ + document: string; persona: OpeningUserPersona | null; }; @@ -67,34 +70,15 @@ function selectedOpeningIndex(slots: Record | undefined): numbe return 0; } -/** 黑板上全部可选用开场(工序候选优先;否则对话落盘 设计.开场白[#槽]) */ -export function collectOpenings(board: Blackboard): PlayAuxOpening[] { - const payload = parseOpeningSealPayload( - board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG), - 0, - ); - if (payload?.candidates.length) { - return payload.candidates.map((text, index) => { - const one = parseOpeningSealPayload( - board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG), - index, - ); - const tag = `${OPENING_SETUP_ARTIFACT_TAG}#${index + 1}`; - return { - index, - tag, - slot: String(index + 1), - text: presentReadablePlain(text) || text, - persona: one?.persona ?? payload.persona, - }; - }); - } +type OpeningProductRow = { + tag: string; + content: string; + order: number; + slot: string; +}; - const rows: Array<{ - tag: string; - content: string; - order: number; - }> = []; +function listOpeningProductRows(board: Blackboard): OpeningProductRow[] { + const rows: OpeningProductRow[] = []; for (const e of board.listTagIndex()) { const family = dictateProductFamily(e.tag); if ( @@ -111,26 +95,74 @@ export function collectOpenings(board: Blackboard): PlayAuxOpening[] { typeof rawOrder === "number" && Number.isFinite(rawOrder) ? rawOrder : effectiveDictateOrder({ tag: e.tag }); - rows.push({ tag: e.tag, content, order }); + rows.push({ + tag: e.tag, + content, + order, + slot: dictateProductSlot(e.tag), + }); } rows.sort((a, b) => { if (a.order !== b.order) return a.order - b.order; return a.tag.localeCompare(b.tag, "zh"); }); + return rows; +} +function openingsFromRows( + board: Blackboard, + rows: OpeningProductRow[], +): PlayAuxOpening[] { return rows.map((row, index) => { const split = splitOpeningDocument(row.content); - const text = split.text.trim() || row.content; + const document = split.text.trim() || row.content; return { index, tag: row.tag, - slot: dictateProductSlot(row.tag), - text: presentReadablePlain(text) || text, + slot: row.slot, + document, + text: presentReadablePlain(document) || document, persona: personaBoundToOpening(board, row.tag, row.content), }; }); } +/** + * 可切换开场。已有 `设计.开场白#场景短码` 时只认这些带槽位的卡, + * 裸 `设计.开场白` 与整包 `设计.开场白与开场变量` 不进切换列表。 + * 还没有槽位卡时,才回退旧整包候选或裸基名。 + */ +export function collectOpenings(board: Blackboard): PlayAuxOpening[] { + const rows = listOpeningProductRows(board); + const slotted = rows.filter((row) => row.slot); + if (slotted.length) return openingsFromRows(board, slotted); + + const payload = parseOpeningSealPayload( + board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG), + 0, + ); + if (payload?.candidates.length) { + return payload.candidates.map((text, index) => { + const one = parseOpeningSealPayload( + board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG), + index, + ); + const tag = `${OPENING_SETUP_ARTIFACT_TAG}#${index + 1}`; + const document = text.trim(); + return { + index, + tag, + slot: String(index + 1), + document, + text: presentReadablePlain(document) || document, + persona: one?.persona ?? payload.persona, + }; + }); + } + + return openingsFromRows(board, rows); +} + export function buildPlayAuxView( board: Blackboard, slots?: Record, diff --git a/src/skills/present-packet.ts b/src/skills/present-packet.ts index d4ebfcd..c9fb777 100644 --- a/src/skills/present-packet.ts +++ b/src/skills/present-packet.ts @@ -40,9 +40,11 @@ export type ShellAdaptation = { tweaks: PresentShellTweaks; }; -export type PresentBlocks = Partial< - Record | unknown[]> ->; +export type PresentBlockValue = string | Record | unknown[]; + +/** 已知壳区域以外的键来自本局正文组成数据契约,必须原样保留。 */ +export type PresentBlocks = Partial> & + Record; export type PresentPacket = { schema: typeof PRESENT_SCHEMA; @@ -112,25 +114,29 @@ export function normalizePresentShellId( return fallback; } +function asPresentBlockValue(value: unknown): PresentBlockValue | undefined { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value; + if (value && typeof value === "object") return value as Record; + return undefined; +} + function normalizeBlocks(raw: unknown): PresentBlocks { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const row = raw as Record; 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]; + for (const [key, value] of Object.entries(row)) { + if (key === "正文" || key === "监控") continue; + const block = asPresentBlockValue(value); + if (block !== undefined) out[key] = block; + } + if (out.body === undefined && row.正文 !== undefined) { + const body = asPresentBlockValue(row.正文); + if (body !== undefined) out.body = body; } - // 兼容中文键 - if (out.body === undefined && row.正文 !== undefined) out.body = row.正文 as string; if (out.monitor === undefined && row.监控 !== undefined) { - out.monitor = row.监控 as PresentBlocks["monitor"]; + const monitor = asPresentBlockValue(row.监控); + if (monitor !== undefined) out.monitor = monitor; } if (typeof out.body === "string") { out.body = stripPresentSourceFences(out.body); diff --git a/src/skills/present-project.ts b/src/skills/present-project.ts new file mode 100644 index 0000000..19d8249 --- /dev/null +++ b/src/skills/present-project.ts @@ -0,0 +1,351 @@ +/** + * 程序投影:把 变量.当前 / 拓扑等权威数据灌进 present.v1 的 program_projection 区。 + * 模型只填 model_generated;本模块在写盘与视图层补齐投影块。 + */ +import { parseTableDoc } from "../blackboard/table-cells.js"; +import { tableValueMap } from "../blackboard/table-side-effects.js"; +import { extractJsonObjectText } from "./worker-set-parse.js"; +import type { ReplyContractBlock, ReplyDataContract } from "./reply-contract.js"; +import { + parsePresentPacket, + stringifyPresentPacket, + type PresentBlockValue, + type PresentShellId, +} from "./present-packet.js"; + +export type ProjectionBinding = { + id: string; + type: string; + ref?: string; + fields?: string[]; +}; + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function asObject(v: unknown): Record | undefined { + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + +/** 从回复呈现(设计.监控栏)取出程序投影绑定 */ +export function parseStatusBarProjectionBindings( + raw: string | null | undefined, +): ProjectionBinding[] { + if (!raw?.trim()) return []; + const text = extractJsonObjectText(raw) ?? raw.trim(); + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return []; + } + const root = asObject(doc); + if (!root) return []; + const body = asObject(root.正文) ?? root; + const regions = body.语义区域 ?? body.regions ?? body.areas; + if (!Array.isArray(regions)) return []; + const out: ProjectionBinding[] = []; + for (const item of regions) { + const row = asObject(item); + if (!row) continue; + const id = asString(row.id); + if (!id) continue; + const source = asObject(row.来源) ?? asObject(row.source); + const mode = + asString(source?.方式) ?? + asString(source?.mode) ?? + asString(source?.source) ?? + asString(row.来源); + if ( + mode && + mode !== "program_projection" && + mode !== "程序投影" && + mode !== "projection" + ) { + continue; + } + // 无方式时:若标了 ref 也当作可投影 + const ref = + asString(source?.ref) ?? + asString(source?.tag) ?? + asString(row.ref) ?? + asString(row.权威ref); + const fieldsRaw = source?.字段 ?? source?.fields ?? row.字段 ?? row.fields; + const fields = Array.isArray(fieldsRaw) + ? fieldsRaw + .map((f) => asString(f)) + .filter((f): f is string => Boolean(f)) + : undefined; + out.push({ + id, + type: + asString(row.内容类型) ?? + asString(row.type) ?? + asString(row.类型) ?? + "kv", + ...(ref ? { ref } : {}), + ...(fields?.length ? { fields } : {}), + }); + } + return out; +} + +function pickValues( + all: Map, + fields?: string[], +): Record { + const out: Record = {}; + if (fields?.length) { + for (const key of fields) { + if (all.has(key)) out[key] = all.get(key); + } + return out; + } + for (const [key, value] of all) out[key] = value; + return out; +} + +/** 表单元格常把对象/数组存成 JSON 字符串;投影前解开,避免壳只能看到一坨字。 */ +export function coerceJsonValue(value: unknown): unknown { + if (typeof value === "string") { + const t = value.trim(); + if ( + (t.startsWith("{") && t.endsWith("}")) || + (t.startsWith("[") && t.endsWith("]")) + ) { + try { + return coerceJsonValue(JSON.parse(t)); + } catch { + return value; + } + } + return value; + } + if (Array.isArray(value)) return value.map(coerceJsonValue); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + out[key] = coerceJsonValue(child); + } + return out; + } + return value; +} + +function coerceValueMap( + values: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(values)) { + out[key] = coerceJsonValue(value); + } + return out; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** 名字 → 属性对象 的矩阵,压成带 name 列的行数组,方便 table 渲染。 */ +function matrixMapToRows( + map: Record, + nameKey = "name", +): Record[] { + return Object.entries(map).map(([name, stats]) => { + if (isPlainObject(stats)) return { [nameKey]: name, ...stats }; + return { [nameKey]: name, value: stats }; + }); +} + +function shapeTable(values: Record): PresentBlockValue { + const coerced = coerceValueMap(values); + const keys = Object.keys(coerced); + if (keys.length === 1) { + const source = keys[0]!; + const only = coerced[source]; + if (Array.isArray(only)) { + return { source, rows: only }; + } + if (isPlainObject(only)) { + const entries = Object.entries(only); + if ( + entries.length && + entries.every(([, child]) => isPlainObject(child)) + ) { + return { source, rows: matrixMapToRows(only) }; + } + } + } + return coerced; +} + +function shapeBlock( + type: string, + values: Record, +): PresentBlockValue | undefined { + const keys = Object.keys(values); + if (!keys.length) return undefined; + if (type === "list") { + const coerced = coerceValueMap(values); + return Object.keys(coerced).map( + (key) => `${key}:${typeof coerced[key] === "string" ? coerced[key] : JSON.stringify(coerced[key])}`, + ); + } + if (type === "text" || type === "markdown" || type === "safe_markdown") { + const coerced = coerceValueMap(values); + return Object.keys(coerced) + .map((key) => { + const cell = coerced[key]; + const text = + typeof cell === "string" ? cell : JSON.stringify(cell, null, 2); + return `【${key}】${text}`; + }) + .join("\n"); + } + if (type === "table") { + return shapeTable(values); + } + // kv / topology / 默认:解开 JSON 字符串后的对象 + return coerceValueMap(values); +} + +function readTagContent( + readTag: ((tag: string) => string | null | undefined) | undefined, + tag: string, + fallbacks: Record, +): string | null | undefined { + if (Object.prototype.hasOwnProperty.call(fallbacks, tag)) { + return fallbacks[tag]; + } + return readTag?.(tag); +} + +function valuesFromRef(params: { + ref: string; + fields?: string[]; + readTag?: (tag: string) => string | null | undefined; + currentVarsRaw?: string | null; + topologyRaw?: string | null; +}): Record { + const ref = params.ref.trim(); + const fallbacks: Record = { + "变量.当前": params.currentVarsRaw, + "运行.初始变量": params.currentVarsRaw, + "运行.拓扑": params.topologyRaw, + }; + const raw = readTagContent(params.readTag, ref, fallbacks); + if (ref === "变量.当前" || ref === "运行.初始变量") { + const map = tableValueMap(parseTableDoc(raw)); + return pickValues(map, params.fields); + } + if (!raw?.trim()) return {}; + // 表文档 + const table = parseTableDoc(raw); + if (table?.rows.length) { + return pickValues(tableValueMap(table), params.fields); + } + // JSON 对象(拓扑等) + try { + const text = extractJsonObjectText(raw) ?? raw.trim(); + const parsed = JSON.parse(text) as unknown; + const obj = asObject(parsed); + if (obj) { + if (params.fields?.length) { + const out: Record = {}; + for (const key of params.fields) { + if (key in obj) out[key] = obj[key]; + } + return out; + } + return obj; + } + } catch { + /* plain */ + } + if (params.fields?.length === 1) { + return { [params.fields[0]!]: raw.trim() }; + } + return { value: raw.trim() }; +} + +/** + * 按正文组成契约 + 回复呈现绑定,从黑板投影 program_projection 块。 + */ +export function projectPresentBlocks(params: { + contract: ReplyDataContract; + statusBarRaw?: string | null; + currentVarsRaw?: string | null; + topologyRaw?: string | null; + readTag?: (tag: string) => string | null | undefined; +}): Record { + const bindings = parseStatusBarProjectionBindings(params.statusBarRaw); + const byId = new Map(bindings.map((b) => [b.id, b])); + const currentMap = tableValueMap(parseTableDoc(params.currentVarsRaw)); + const out: Record = {}; + + for (const block of params.contract.programBlocks) { + const bound = byId.get(block.id); + const type = bound?.type || block.type || "kv"; + let values: Record; + if (bound?.ref) { + values = valuesFromRef({ + ref: bound.ref, + fields: bound.fields, + readTag: params.readTag, + currentVarsRaw: params.currentVarsRaw, + topologyRaw: params.topologyRaw, + }); + } else if (type === "topology") { + values = valuesFromRef({ + ref: "运行.拓扑", + fields: bound?.fields, + readTag: params.readTag, + currentVarsRaw: params.currentVarsRaw, + topologyRaw: params.topologyRaw, + }); + } else { + values = pickValues(currentMap, bound?.fields); + } + const shaped = shapeBlock(type, values); + if (shaped !== undefined) out[block.id] = shaped; + } + return out; +} + +/** 仅用契约(无监控栏)时的便捷入口 */ +export function projectPresentBlocksFromContract( + contract: ReplyDataContract, + currentVarsRaw: string | null | undefined, +): Record { + return projectPresentBlocks({ contract, currentVarsRaw }); +} + +export function mergePresentWithProjections( + content: string, + projections: Record, + fallbackShell: PresentShellId = "prose", +): string { + if (!Object.keys(projections).length) return content; + const view = parsePresentPacket(content, fallbackShell); + const blocks = { ...view.packet.blocks, ...projections }; + return stringifyPresentPacket({ + ...view.packet, + blocks, + }); +} + +export function projectionForBlocks( + blocks: ReplyContractBlock[], + currentVarsRaw: string | null | undefined, +): Record { + if (!blocks.length) return {}; + const contract: ReplyDataContract = { + modelBlocks: [], + programBlocks: blocks, + }; + return projectPresentBlocks({ contract, currentVarsRaw }); +} diff --git a/src/skills/reply-contract.ts b/src/skills/reply-contract.ts new file mode 100644 index 0000000..8eda031 --- /dev/null +++ b/src/skills/reply-contract.ts @@ -0,0 +1,249 @@ +/** + * 从「设计.正文组成」取出数据契约。 + * 开场与之后每一楼只填模型生成区;程序把这份 present.v1 灌进已固定的 frontend。 + */ +import { extractJsonObjectText } from "./worker-set-parse.js"; + +export type ReplyContractBlock = { + id: string; + type: string; + source: "model_generated" | "program_projection"; + role?: string; +}; + +export type ReplyFrontend = { + html: string; + css: string; + js: string; +}; + +export type ReplyDataContract = { + modelBlocks: ReplyContractBlock[]; + programBlocks: ReplyContractBlock[]; + frontend?: ReplyFrontend; +}; + +export function parseReplyDataContract( + raw: string | undefined | null, +): ReplyDataContract | undefined { + const doc = parseObject(raw); + if (!doc) return undefined; + const body = asObject(doc.正文) ?? doc; + const contract = asObject(body.数据契约) ?? asObject(body.data_contract); + const blocksRaw = asObject(contract?.blocks); + if (!blocksRaw) return undefined; + const modelBlocks: ReplyContractBlock[] = []; + const programBlocks: ReplyContractBlock[] = []; + for (const [id, value] of Object.entries(blocksRaw)) { + const row = asObject(value); + if (!id.trim() || !row) continue; + const block: ReplyContractBlock = { + id: id.trim(), + type: asString(row.类型 ?? row.type) || "text", + source: + asString(row.来源 ?? row.source) === "program_projection" + ? "program_projection" + : "model_generated", + role: asString(row.职责 ?? row.role), + }; + if (block.source === "program_projection") programBlocks.push(block); + else modelBlocks.push(block); + } + if (!modelBlocks.length && !programBlocks.length) return undefined; + const frontend = parseFrontend(asObject(body.frontend)); + return { + modelBlocks, + programBlocks, + ...(frontend ? { frontend } : {}), + }; +} + +export function replyContractSummary(contract: ReplyDataContract): { + schema: "present.v1"; + model_blocks: Array<{ id: string; type: string; role?: string }>; + program_blocks: string[]; + rule: string; +} { + return { + schema: "present.v1", + model_blocks: contract.modelBlocks.map((block) => ({ + id: block.id, + type: block.type, + ...(block.role ? { role: block.role } : {}), + })), + program_blocks: contract.programBlocks.map((block) => block.id), + rule: "开场和之后每一轮只填写 model_blocks。程序把这份 present.v1 灌进已固定的 frontend,替换示例内容。不要重写 frontend,不要填写 program_blocks。", + }; +} + +/** 开场 JSON 里所有 present.v1 的 blocks。缺模型区时返回错误,供 insert 拒绝。 */ +export function openingContractError( + content: string, + contract: ReplyDataContract, +): string | undefined { + if (!contract.modelBlocks.length) return undefined; + const maps = collectPresentBlockMaps(content); + const need = formatModelBlocks(contract); + if (!maps.length) { + return `有正文组成时,开场必须给出 present.v1,并填写模型生成区:${need}。程序投影区不要填写。`; + } + const missing = new Set(); + for (const blocks of maps) { + for (const block of contract.modelBlocks) { + if (!Object.prototype.hasOwnProperty.call(blocks, block.id)) { + missing.add(block.id); + } + } + } + if (!missing.size) return undefined; + return `开场的 present.v1 未按正文组成填写模型生成区,缺少:${[...missing].join("、")}。需要:${need}。程序投影区不要填写。`; +} + +export function presentSchemaFromContract(contract: ReplyDataContract): { + schema: Record; + loose: boolean; +} { + const properties: Record = {}; + const required: string[] = []; + let loose = false; + for (const block of contract.modelBlocks) { + required.push(block.id); + const built = schemaForBlock(block); + properties[block.id] = built.schema; + if (built.loose) loose = true; + } + return { + loose, + schema: { + type: "object", + additionalProperties: false, + required: ["schema", "blocks"], + properties: { + schema: { type: "string", enum: ["present.v1"] }, + blocks: { + type: "object", + additionalProperties: false, + required, + properties, + }, + }, + }, + }; +} + +export function contractOutputInstruction(contract: ReplyDataContract): string { + const lines = contract.modelBlocks.map((block) => { + const role = block.role ? `:${block.role}` : ""; + return `- ${block.id}(${typeHint(block.type)})${role}`; + }); + const projected = contract.programBlocks.map((block) => block.id); + return ` + +--- + +## 运行时输出协议(按正文组成灌数) + +本局前端已经固定。只输出一个 present.v1 对象,程序把它灌进正文组成,替换示例内容。 +不要输出 HTML、CSS、JS,不要重写模板,不要再套 shell。 + +只填模型生成区: +${lines.join("\n")} +${projected.length ? `不要填写程序投影区:${projected.join("、")}。\n` : ""}list 用字符串数组,没有内容时给 []。markdown 与 text 用字符串。 +不要写追问、askUser、自评或「可验收产物」。`; +} + +function formatModelBlocks(contract: ReplyDataContract): string { + return contract.modelBlocks + .map((block) => `${block.id}(${typeHint(block.type)})`) + .join("、"); +} + +function typeHint(type: string): string { + if (type === "list") return "字符串数组"; + if (type === "markdown" || type === "text") return "字符串"; + if (type === "kv" || type === "table" || type === "topology") return "对象"; + return type || "字符串"; +} + +function schemaForBlock(block: ReplyContractBlock): { + schema: Record; + loose: boolean; +} { + if (block.type === "list") { + return { + loose: false, + schema: { type: "array", items: { type: "string" } }, + }; + } + if (block.type === "markdown" || block.type === "text") { + return { loose: false, schema: { type: "string" } }; + } + return { + loose: true, + schema: { type: "object", additionalProperties: true }, + }; +} + +function collectPresentBlockMaps(content: string): Array> { + const doc = parseObject(content); + if (!doc) return []; + const maps: Array> = []; + walk(doc, maps, 0); + return maps; +} + +function walk(node: unknown, maps: Array>, depth: number): void { + if (depth > 8 || node == null) return; + if (typeof node === "string") { + if (!node.includes("present.v1")) return; + const parsed = parseObject(node); + if (parsed) walk(parsed, maps, depth + 1); + return; + } + if (Array.isArray(node)) { + for (const item of node) walk(item, maps, depth + 1); + return; + } + if (typeof node !== "object") return; + const row = node as Record; + const blocks = asObject(row.blocks); + if ((row.schema === "present.v1" || row.shell != null) && blocks) { + maps.push(blocks); + } + for (const value of Object.values(row)) walk(value, maps, depth + 1); +} + +function parseFrontend(raw: Record | undefined): ReplyFrontend | undefined { + if (!raw) return undefined; + const html = asString(raw.html); + if (!html || !/<\w+/.test(html)) return undefined; + return { + html, + css: asString(raw.css) ?? "", + js: asString(raw.js) ?? "", + }; +} + +function parseObject(raw: string | undefined | null): Record | undefined { + const text = raw?.trim() ?? ""; + if (!text) return undefined; + const jsonText = extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null); + if (!jsonText) return undefined; + try { + const doc = JSON.parse(jsonText) as unknown; + return asObject(doc); + } catch { + return undefined; + } +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.trim(); + return text || undefined; +} diff --git a/src/skills/variable-design-compile.ts b/src/skills/variable-design-compile.ts new file mode 100644 index 0000000..b7206ca --- /dev/null +++ b/src/skills/variable-design-compile.ts @@ -0,0 +1,216 @@ +/** + * 从「设计.变量设计与更新规则」编译运行目录,并种进 变量.当前。 + * 创作产物是合同;设计.变量目录 / 变量.当前 是运行表。 + */ +import { extractJsonObjectText } from "./worker-set-parse.js"; +import { + parseVariableCatalog, + seedVariablesFromCatalog, + serializeVariableCatalog, + upsertVariableField, + VARIABLE_CATALOG_TAG, + type VariableCatalogDoc, + type VariableCatalogField, + type VariableFieldType, +} from "./variable-catalog.js"; + +export const VARIABLE_DESIGN_TAG = "设计.变量设计与更新规则"; + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function asObject(v: unknown): Record | undefined { + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + +function parseType(raw: unknown): VariableFieldType { + const s = asString(raw)?.toLowerCase(); + if (s === "number" || s === "数字" || s === "integer" || s === "int") { + return "number"; + } + if (s === "boolean" || s === "布尔") return "boolean"; + if (s === "enum" || s === "枚举") return "enum"; + return "string"; +} + +/** 解析变量设计产物里的真值列表 → 目录字段(未写入) */ +export function catalogFieldsFromVariableDesign( + raw: string | null | undefined, +): VariableCatalogField[] { + if (!raw?.trim()) return []; + const text = extractJsonObjectText(raw) ?? raw.trim(); + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return []; + } + const root = asObject(doc); + if (!root) return []; + const body = asObject(root.正文) ?? root; + const truths = body.真值 ?? body.fields ?? body.变量; + if (!Array.isArray(truths)) return []; + + const fields: VariableCatalogField[] = []; + const seen = new Set(); + for (const item of truths) { + const row = asObject(item); + if (!row) continue; + const key = + asString(row.id) ?? + asString(row.key) ?? + asString(row.名) ?? + asString(row.name) ?? + asString(row.名称); + if (!key || seen.has(key)) continue; + seen.add(key); + const type = parseType(row.类型 ?? row.type); + const initBox = asObject(row.初始化) ?? asObject(row.initial); + const initial = + initBox?.默认值 ?? + initBox?.default ?? + initBox?.value ?? + row.initial ?? + row.初值 ?? + row.默认值; + const publicity = asString(row.公开性) ?? asString(row.visibility); + const userVisible = + publicity === "hidden" || + publicity === "隐藏" || + row.user_visible === false || + row.对用户可见 === false + ? false + : true; + const note = + asString(row.名称) ?? + asString(row.用途) ?? + asString(row.note) ?? + asString(row.备注); + const { doc: one } = upsertVariableField(null, { + key, + type, + initial, + user_visible: userVisible, + note, + }); + const field = one.fields[0]; + if (field) fields.push(field); + } + return fields; +} + +/** 把设计稿真值并进已有目录(不删目录里多出来的 declare 字段) */ +export function mergeCatalogFromVariableDesign( + existing: VariableCatalogDoc | null, + designRaw: string | null | undefined, +): { + doc: VariableCatalogDoc; + added: string[]; + updated: string[]; +} | null { + const fromDesign = catalogFieldsFromVariableDesign(designRaw); + if (!fromDesign.length) return null; + + let doc: VariableCatalogDoc = existing ?? { + schema: "variable-catalog.v1", + fields: [], + }; + const added: string[] = []; + const updated: string[] = []; + for (const field of fromDesign) { + const before = doc.fields.find((f) => f.key === field.key); + const { doc: next } = upsertVariableField(doc, { + key: field.key, + type: field.type, + initial: field.initial, + user_visible: field.user_visible, + user_editable: field.user_editable, + note: field.note, + }); + doc = next; + if (!before) added.push(field.key); + else updated.push(field.key); + } + return { doc, added, updated }; +} + +export type SyncVariableDesignResult = { + /** 有变更时返回新目录 JSON */ + catalog: string | null; + current: string | null; + initial: string | null; + seededKeys: string[]; + catalogKeys: string[]; + added: string[]; +}; + +/** + * 设计稿 → 目录 → 种/补 变量.当前 与 运行.初始变量。 + * 已有格不覆盖;目录缺键才追加。 + */ +export function syncVariableDesignToRuntime(params: { + designRaw: string | null | undefined; + catalogRaw: string | null | undefined; + currentRaw: string | null | undefined; + initialRaw: string | null | undefined; +}): SyncVariableDesignResult { + const empty: SyncVariableDesignResult = { + catalog: null, + current: null, + initial: null, + seededKeys: [], + catalogKeys: [], + added: [], + }; + const existing = parseVariableCatalog(params.catalogRaw); + const merged = mergeCatalogFromVariableDesign(existing, params.designRaw); + if (!merged) return empty; + + const catalogChanged = + !existing || + serializeVariableCatalog(existing) !== serializeVariableCatalog(merged.doc); + const catalogRaw = catalogChanged + ? serializeVariableCatalog(merged.doc) + : (params.catalogRaw?.trim() ?? serializeVariableCatalog(merged.doc)); + + const seeded = seedVariablesFromCatalog({ + catalogRaw, + currentRaw: params.currentRaw, + initialRaw: params.initialRaw, + actor: "system:variable-design", + }); + + return { + catalog: catalogChanged ? catalogRaw : null, + current: seeded.current, + initial: seeded.initial, + seededKeys: seeded.seededKeys, + catalogKeys: merged.doc.fields.map((f) => f.key), + added: merged.added, + }; +} + +/** 把 sync 结果写回黑板;返回实际写入的 tag */ +export function writeVariableDesignSync( + sync: SyncVariableDesignResult, + writeTag: (tag: string, content: string) => void, +): string[] { + const wrote: string[] = []; + if (sync.catalog) { + writeTag(VARIABLE_CATALOG_TAG, sync.catalog); + wrote.push(VARIABLE_CATALOG_TAG); + } + if (sync.initial) { + writeTag("运行.初始变量", sync.initial); + wrote.push("运行.初始变量"); + } + if (sync.current) { + writeTag("变量.当前", sync.current); + wrote.push("变量.当前"); + } + return wrote; +} diff --git a/src/skills/worker-set-parse.ts b/src/skills/worker-set-parse.ts index adc398e..0282554 100644 --- a/src/skills/worker-set-parse.ts +++ b/src/skills/worker-set-parse.ts @@ -107,7 +107,7 @@ export const DESIGN_STAGE_SKILL_META: Record< "opening-generator": { label: "开局 · 开场白", purpose: - "创作末尾:优先落库设计.开场白与开场变量;否则现写开场;初值与开场同真相。", + "创作末尾:每条开场落库设计.开场白#场景短码;没有变量的卡不写初值。", }, }; diff --git a/src/worker/executor.ts b/src/worker/executor.ts index f1043eb..ebdd8fd 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -21,6 +21,12 @@ import { } from "../parse/json-doc.js"; import { assembleWorkerContext } from "../skills/context-segments.js"; import { isPlayLayerActive } from "../skills/play-turn.js"; +import { isNarratorEnabled } from "../skills/play-slots.js"; +import { + contractOutputInstruction, + parseReplyDataContract, + presentSchemaFromContract, +} from "../skills/reply-contract.js"; import { PRESENT_TAG, PRESENT_JSON_SCHEMA, @@ -829,21 +835,33 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { }); }); +describe("extractMessageParts", () => { + it("keeps reply_user when arguments are an object and id is missing", () => { + const parts = extractMessageParts({ + content: [{ type: "text", text: "同一次请求里的说明" }], + tool_calls: [ + { + type: "function", + function: { name: "reply_user", arguments: { text: "形成了气氛" } }, + }, + ], + }); + expect(parts.toolCalls).toEqual([ + { id: "call-0", name: "reply_user", arguments: JSON.stringify({ text: "形成了气氛" }) }, + ]); + expect(parts.content).toBe("同一次请求里的说明"); + }); +}); + describe("buildRequestBody", () => { const messages = [{ role: "user" as const, content: "hi" }]; @@ -597,6 +615,24 @@ describe("buildRequestBody", () => { expect(body.messages).toBe(messages); }); + it("writes session_id only for OpenRouter", () => { + const openrouter: LlmConfig = { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "k", + model: "google/gemini-3.8-flash", + }; + const direct: LlmConfig = { + baseUrl: "https://api.deepseek.com/v1", + apiKey: "k", + model: "deepseek-v4-pro", + }; + const id = "9b76dcb6-0703-4b0d-ba75-faa985b532b4"; + expect(buildRequestBody(openrouter, messages, { sessionId: id }).session_id).toBe(id); + expect(buildRequestBody(direct, messages, { sessionId: id }).session_id).toBeUndefined(); + expect(buildRequestBody(openrouter, messages, {}).session_id).toBeUndefined(); + expect(openRouterSessionId(openrouter.baseUrl, ` ${"a".repeat(300)} `)).toHaveLength(256); + }); + it("does not forward top_k from an imported ST preset", () => { const config: LlmConfig = { baseUrl: "https://example.test/v1", diff --git a/tests/maintenance-strategy.test.ts b/tests/maintenance-strategy.test.ts new file mode 100644 index 0000000..ffd327b --- /dev/null +++ b/tests/maintenance-strategy.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + applyDictatePlayBind, + bindDictateProductsToPlaySpec, +} from "../src/dictate/play-bind.js"; +import { + applyMaintenanceStrategyToPlaySlots, + normalizeMaintenanceAdvice, + parseMaintenanceAdviceFromVariableDesign, + resolveMaintenanceStrategyFromBoard, + strategyFromAdvice, + MAINTENANCE_STRATEGY_TAG, +} from "../src/skills/maintenance-strategy.js"; +import { defaultPlaySlots } from "../src/skills/play-slots.js"; +import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js"; +import { VARIABLE_DESIGN_TAG } from "../src/skills/variable-design-compile.js"; + +function designWithAdvice(advice: string) { + return JSON.stringify({ + schema: "context-fragment.v1", + 技能: "变量设计与更新规则", + 正文: { + 真值: [ + { + id: "affection", + 名称: "好感", + 类型: "number", + 初始化: { 默认值: 0 }, + 公开性: "public", + }, + ], + 维护复杂度: { + 建议: advice, + 依据: ["测试依据"], + }, + }, + }); +} + +describe("maintenance-strategy", () => { + it("normalizes advice aliases", () => { + expect(normalizeMaintenanceAdvice("auditor_bundle")).toBe("auditor_bundle"); + expect(normalizeMaintenanceAdvice("旁观")).toBe("auditor_bundle"); + expect(normalizeMaintenanceAdvice("inline_tool")).toBe("inline_tool"); + expect(normalizeMaintenanceAdvice("内联")).toBe("inline_tool"); + expect(normalizeMaintenanceAdvice("inline_structured")).toBe( + "inline_structured", + ); + expect(normalizeMaintenanceAdvice("none")).toBe("none"); + }); + + it("parses variable-design maintenance box", () => { + const parsed = parseMaintenanceAdviceFromVariableDesign( + designWithAdvice("auditor_bundle"), + ); + expect(parsed?.advice).toBe("auditor_bundle"); + expect(parsed?.依据).toContain("测试依据"); + }); + + it("defaults to auditor_bundle when truths exist but advice missing", () => { + const raw = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { 真值: [{ id: "a", 类型: "number", 初始化: { 默认值: 1 } }] }, + }); + expect(parseMaintenanceAdviceFromVariableDesign(raw)?.advice).toBe( + "auditor_bundle", + ); + }); + + it("maps advice to auditor/inline flags at strategyFromAdvice level", () => { + expect(strategyFromAdvice("auditor_bundle")).toMatchObject({ + auditor: true, + inline: false, + }); + expect(strategyFromAdvice("inline_tool")).toMatchObject({ + auditor: false, + inline: true, + }); + expect(strategyFromAdvice("none")).toMatchObject({ + auditor: false, + inline: false, + }); + }); + + it("forces auditor_bundle when truths exist even if design said inline", () => { + const board = new Blackboard(); + board.write({ + tag: VARIABLE_DESIGN_TAG, + content: designWithAdvice("inline_tool"), + source: "test", + }); + const strategy = resolveMaintenanceStrategyFromBoard(board); + expect(strategy.advice).toBe("auditor_bundle"); + expect(strategy.auditor).toBe(true); + expect(strategy.inline).toBe(false); + expect(strategy.依据?.some((x) => x.includes("inline_tool"))).toBe(true); + }); + + it("keeps auditor off when there are no truths", () => { + const board = new Blackboard(); + const strategy = resolveMaintenanceStrategyFromBoard(board); + expect(strategy.advice).toBe("none"); + expect(strategy.auditor).toBe(false); + }); + + it("overrides play_slots.auditor from strategy", () => { + const slots = applyMaintenanceStrategyToPlaySlots(defaultPlaySlots(), { + schema: "maintenance-strategy.v1", + advice: "auditor_bundle", + auditor: true, + inline: false, + source: VARIABLE_DESIGN_TAG, + }); + expect(slots.auditor).toBe(true); + expect(slots.gm).toBe(true); + }); +}); + +describe("play-bind applies maintenance strategy", () => { + it("turns auditor on for auditor_bundle", () => { + const board = new Blackboard(); + board.write({ + tag: VARIABLE_DESIGN_TAG, + content: designWithAdvice("auditor_bundle"), + source: "test", + }); + board.write({ + tag: "设计.世界蓝图", + content: "舞台", + source: "test", + }); + const result = bindDictateProductsToPlaySpec(board); + const parsed = parseWorkerSetYaml(result.workerSetJson); + expect(parsed?.play_slots?.auditor).toBe(true); + const strategy = resolveMaintenanceStrategyFromBoard(board); + expect(strategy.advice).toBe("auditor_bundle"); + }); + + it("still opens auditor when design said inline_tool", () => { + const board = new Blackboard(); + board.write({ + tag: VARIABLE_DESIGN_TAG, + content: designWithAdvice("inline_tool"), + source: "test", + }); + board.write({ + tag: "设计.世界蓝图", + content: "舞台", + source: "test", + }); + applyDictatePlayBind(board); + const worker = parseWorkerSetYaml( + board.getContentByTag("设计.worker集") ?? "", + ); + expect(worker?.play_slots?.auditor).toBe(true); + const strategyRaw = board.getContentByTag(MAINTENANCE_STRATEGY_TAG); + expect(strategyRaw).toContain("auditor_bundle"); + expect(strategyRaw).toContain('"auditor": true'); + expect(strategyRaw).toContain("inline_tool"); + }); +}); diff --git a/tests/play-aux.test.ts b/tests/play-aux.test.ts index 3c6ea03..f55427d 100644 --- a/tests/play-aux.test.ts +++ b/tests/play-aux.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { formatTableValue, parseTableDraft } from "../web/play-aux.js"; import { Blackboard } from "../src/blackboard/blackboard.js"; import { stringifyTableDoc, createTableFromValues } from "../src/blackboard/table-cells.js"; import { buildPlayAuxView } from "../src/skills/play-aux.js"; @@ -72,6 +73,8 @@ describe("play-aux view", () => { expect(view.openings).toHaveLength(1); expect(view.openings[0]?.text).toBe("@玩家站在债契前,堂屋很静。"); expect(view.openings[0]?.text).not.toContain("present.v1"); + expect(view.openings[0]?.document).toContain("present.v1"); + expect(view.openings[0]?.document).toContain("@玩家站在债契前,堂屋很静。"); }); it("lists dictate 设计.开场白#slot openings with same-slot 主角设定", () => { @@ -109,6 +112,45 @@ describe("play-aux view", () => { expect(gate?.persona?.name).toBe("阿泽"); }); + it("prefers slotted 设计.开场白#场景短码 over the bare card and the legacy bundle", () => { + const board = new Blackboard(); + board.write({ + tag: "设计.开场白", + content: JSON.stringify({ + schema: "present.v1", + shell: "prose", + blocks: { body: "这张没有短码。" }, + }), + source: "agent", + }); + board.write({ + tag: "设计.开场白#opening-cycle2", + content: JSON.stringify({ + schema: "present.v1", + shell: "status_card", + blocks: { status_card: "天数|阶段" }, + }), + source: "agent", + }); + board.write({ + tag: "设计.开场白与开场变量", + content: JSON.stringify({ + schema: "context-fragment.v1", + 技能: "开场白与开场变量", + 正文: { 开场白全文: "@玩家站在债契前。" }, + }), + source: "agent", + }); + const view = buildPlayAuxView(board); + expect(view.openings).toHaveLength(1); + expect(view.openings[0]?.tag).toBe("设计.开场白#opening-cycle2"); + expect(view.openings[0]?.slot).toBe("opening-cycle2"); + expect(view.openings[0]?.document).toContain("status_card"); + expect(view.openings[0]?.document).toContain("天数|阶段"); + // 无 body 时预览回退整包;选用仍走 document,不会压丢 status_card + expect(view.openings[0]?.document).toBe(view.openings[0]?.text); + }); + it("lists proliferated zero-layer openings for play swipe", () => { const board = new Blackboard(); board.write({ @@ -126,3 +168,38 @@ describe("play-aux view", () => { expect(view.selectedOpeningIndex).toBe(1); }); }); + +describe("play table drafts", () => { + it("keeps numbers, booleans, and JSON values in their original types", () => { + expect(parseTableDraft(1, "12")).toEqual({ ok: true, value: 12 }); + expect(parseTableDraft(1, "nope").ok).toBe(false); + expect(parseTableDraft(false, "是")).toEqual({ ok: true, value: true }); + expect(parseTableDraft(["基础侦察术"], '["基础侦察术","追踪"]')).toEqual({ + ok: true, + value: ["基础侦察术", "追踪"], + }); + expect(parseTableDraft(["基础侦察术"], "[")).toEqual({ ok: false, error: "JSON 不合法" }); + expect(parseTableDraft("空", " 还在 ")).toEqual({ ok: true, value: "还在" }); + }); + + it("pretty-prints long structured values and keeps short ones on one line", () => { + expect(formatTableValue(["基础侦察术"])).toBe('["基础侦察术"]'); + const long = { a: "x".repeat(40), b: [1, 2, 3] }; + expect(formatTableValue(long)).toContain("\n"); + expect(formatTableValue(10)).toBe("10"); + }); + + it("parses JSON strings so nested matrices are editable as objects", () => { + const matrix = + '{"塞琳娜":{"认知度":"有所耳闻","亲密度":-10},"崔岚":{"认知度":"有所耳闻","亲密度":-10}}'; + expect(formatTableValue(matrix)).toContain("\n"); + expect(formatTableValue(matrix)).toContain("塞琳娜"); + expect(parseTableDraft(matrix, formatTableValue(matrix))).toEqual({ + ok: true, + value: { + 塞琳娜: { 认知度: "有所耳闻", 亲密度: -10 }, + 崔岚: { 认知度: "有所耳闻", 亲密度: -10 }, + }, + }); + }); +}); diff --git a/tests/present-frame.test.ts b/tests/present-frame.test.ts index 568415e..4d16f44 100644 --- a/tests/present-frame.test.ts +++ b/tests/present-frame.test.ts @@ -4,6 +4,7 @@ import { PRESENT_FRAME_SANDBOX, buildPresentSrcdoc, isFrontendBundle, + presentFrameSlotHtml, presentSourceBytes, previewPacket, } from "../web/present-frame.js"; @@ -24,7 +25,8 @@ describe("present frame srcdoc", () => { }); expect(srcdoc).toContain(`
`); expect(srcdoc).toContain(`#app { color: #e1e7f0; }`); - expect(srcdoc).toContain("present.onData"); + expect(srcdoc).toContain("present-host"); + expect(srcdoc).toContain("applyPacket"); expect(srcdoc).toContain("示例正文"); expect(srcdoc).toContain(`default-src 'none'`); expect(srcdoc.indexOf("window.present")).toBeLessThan(srcdoc.indexOf("data.blocks.body")); @@ -54,6 +56,112 @@ describe("present frame srcdoc", () => { expect(PRESENT_BYTE_LIMIT).toBe(60 * 1024); }); + it("sizes the frame to content and fills contract slots", () => { + const srcdoc = buildPresentSrcdoc({ + html: `
`, + css: `html,body{height:100vh;overflow:auto}`, + js: `present.onData(function () {});`, + packet: { schema: "present.v1", blocks: { body: "正文", forum_feed: ["一帖"] } }, + slots: [ + { id: "body", type: "markdown" }, + { id: "forum_feed", type: "list" }, + ], + frameId: "pf3", + }); + expect(srcdoc).not.toContain("100vh"); + expect(srcdoc).not.toContain("overflow:auto"); + expect(srcdoc).toContain("overflow:hidden!important"); + expect(srcdoc).toContain("fillSlots"); + expect(srcdoc).toContain("isProgramOwned"); + expect(srcdoc).toContain("forum_feed"); + expect(srcdoc).toContain('"type":"list"'); + }); + + it("turns markdown newlines into paragraphs even if the shell wrote textContent first", () => { + const srcdoc = buildPresentSrcdoc({ + html: `
`, + css: "", + js: `present.onData(function (data) { + document.getElementById("body").textContent = data.blocks.body; + });`, + packet: { + schema: "present.v1", + blocks: { body: "第一段\\n\\n第二段 **加粗**" }, + }, + slots: [{ id: "body", type: "markdown" }], + frameId: "pf4", + }); + // packet JSON keeps real newlines; template sample above used escaped for the test string — + // rebuild with actual newlines: + const withBreaks = buildPresentSrcdoc({ + html: `
`, + css: "", + js: `present.onData(function (data) { + document.getElementById("body").textContent = data.blocks.body; + });`, + packet: { + schema: "present.v1", + blocks: { body: "第一段\n\n第二段 **加粗**" }, + }, + slots: [{ id: "body", type: "markdown" }], + frameId: "pf4b", + }); + const match = withBreaks.match(/function markdown\(raw\) \{[\s\S]*?\n \}/); + expect(match).toBeTruthy(); + const markdown = new Function(`${match![0]}; return markdown;`)(); + const html = markdown("第一段\n\n第二段 **加粗**"); + expect(html).toContain("

第一段

"); + expect(html).toContain("

第二段 加粗

"); + expect(withBreaks).toContain("isProgramOwned"); + expect(srcdoc).toContain("isProgramOwned"); + }); + + it("applies program fill after shell onData so textContent cannot keep a wall of text", () => { + const srcdoc = buildPresentSrcdoc({ + html: `
`, + css: "", + js: `present.onData(function (data) { + document.getElementById("body").textContent = data.blocks.body; + });`, + packet: { schema: "present.v1", blocks: { body: "a\\nb" } }, + slots: [{ id: "body", type: "markdown" }], + frameId: "pf5", + }); + const sealIdx = srcdoc.indexOf("present.__seal"); + const ownedIdx = srcdoc.indexOf("isProgramOwned"); + expect(ownedIdx).toBeGreaterThan(-1); + expect(sealIdx).toBeGreaterThan(-1); + // applyPacket: renderCb then fillSlots + const applyIdx = srcdoc.indexOf("function applyPacket"); + const applyChunk = srcdoc.slice(applyIdx, applyIdx + 450); + expect(applyChunk.indexOf("renderCb")).toBeLessThan(applyChunk.lastIndexOf("fillSlots")); + expect(srcdoc).toContain('t === "kv" || t === "table" || t === "topology"'); + }); + + it("renders list objects by text/label instead of JSON.stringify", () => { + const srcdoc = buildPresentSrcdoc({ + html: `
    `, + css: "", + js: `present.onData(function () {});`, + packet: { schema: "present.v1", blocks: { options: [] } }, + slots: [{ id: "options", type: "list" }], + frameId: "pf6", + }); + const match = srcdoc.match(/function listItemText\(item\) \{[\s\S]*?\n \}/); + expect(match).toBeTruthy(); + const listItemText = new Function(`${match![0]}; return listItemText;`)(); + expect(listItemText({ type: "继续推进1", text: "顺着旧话试探" })).toBe("顺着旧话试探"); + expect(listItemText({ label: "换方向" })).toBe("换方向"); + expect(listItemText("纯字符串")).toBe("纯字符串"); + expect(listItemText('{"type":"x","text":"从字符串JSON取出"}')).toBe("从字符串JSON取出"); + }); + + it("keeps the previous frame height so a remount does not collapse the page", () => { + expect(presentFrameSlotHtml("pf9", 1280)).toContain('style="min-height:1280px"'); + expect(presentFrameSlotHtml("pf9")).not.toContain("min-height"); + expect(presentFrameSlotHtml("pf9", 4)).not.toContain("min-height"); + }); + it("normalizes sample data into present.v1 blocks", () => { expect(previewPacket({ schema: "present.v1", blocks: { body: "x" } })).toEqual({ schema: "present.v1", @@ -61,4 +169,43 @@ describe("present frame srcdoc", () => { }); expect(previewPacket(null)).toEqual({ schema: "present.v1", blocks: {} }); }); + + it("accepts Chinese block ids and expands nested matrix JSON into a table", () => { + const srcdoc = buildPresentSrcdoc({ + html: `
    `, + css: "", + js: "present.onData(function () {});", + packet: { + schema: "present.v1", + blocks: { + 卷宗表: { + source: "heroines_matrix", + rows: [ + { name: "塞琳娜", 亲密度: -10, 服从度: 10 }, + { name: "崔岚", 亲密度: -10, 服从度: 10 }, + ], + }, + 正文: "第一段\n\n第二段", + }, + }, + slots: [ + { id: "卷宗表", type: "table" }, + { id: "正文", type: "markdown" }, + ], + frameId: "pf-zh", + }); + expect(srcdoc).toContain("function isBlockId"); + expect(srcdoc).toContain("present-data-table"); + expect(srcdoc).toContain("matrixMapToRows"); + expect(srcdoc).not.toContain("/^[A-Za-z_]"); + + const writeMatch = srcdoc.match(/function writeDataTable\(el, rows, columns\) \{[\s\S]*?\n \}/); + expect(writeMatch).toBeTruthy(); + const idMatch = srcdoc.match(/function isBlockId\(id\) \{[\s\S]*?\n \}/); + expect(idMatch).toBeTruthy(); + const isBlockId = new Function(`${idMatch![0]}; return isBlockId;`)(); + expect(isBlockId("正文")).toBe(true); + expect(isBlockId("body")).toBe(true); + expect(isBlockId('bad"id')).toBe(false); + }); }); diff --git a/tests/protagonist-bind.test.ts b/tests/protagonist-bind.test.ts index b7736d2..e34d2a9 100644 --- a/tests/protagonist-bind.test.ts +++ b/tests/protagonist-bind.test.ts @@ -203,6 +203,39 @@ describe("protagonist overlay", () => { expect(protagonistTagForOpeningSlot("设计.开场白")).toBe(PROTAGONIST_TAG); }); + it("binds present.v1 meta.用户角色 to the slotted card and follows opening rewrites", async () => { + const { + ensureProtagonistFromPersona, + personaBoundToOpening, + protagonistTagForOpeningSlot, + } = await import("../src/persona/protagonist-bind.js"); + const { splitOpeningDocument } = await import("../src/skills/opening-seal.js"); + + const openingTag = "设计.开场白#debt-hall"; + const card = (简介: string) => + JSON.stringify({ + schema: "present.v1", + blocks: { body: "清晨,@玩家推开堂屋门。" }, + meta: { 用户角色: { 名字: "沈聿", 简介 } }, + }); + + const board = new Blackboard(); + const personaTag = protagonistTagForOpeningSlot(openingTag); + for (const 简介 of ["本地放贷人。", "持有这家债契的放贷人。"]) { + const content = card(简介); + const split = splitOpeningDocument(content); + expect(split.persona?.name).toBe("沈聿"); + expect(split.text).toBe(content); + ensureProtagonistFromPersona(board, split.persona, personaTag, { + overwrite: true, + }); + board.write({ tag: openingTag, content, source: "agent" }); + expect(personaBoundToOpening(board, openingTag, content)?.description).toContain( + 简介, + ); + } + }); + it("overlays opening meta 用户角色 when the user switches to that preset", async () => { const { readSessionProtagonist, overlayPlayPersona } = await import( "../src/persona/protagonist-bind.js" diff --git a/tests/reply-contract.test.ts b/tests/reply-contract.test.ts new file mode 100644 index 0000000..da9337f --- /dev/null +++ b/tests/reply-contract.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { parsePresentPacket } from "../src/skills/present-packet.js"; +import { + openingContractError, + parseReplyDataContract, + presentSchemaFromContract, +} from "../src/skills/reply-contract.js"; + +const replyFormat = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + 数据契约: { + schema: "present.v1", + blocks: { + status_card: { 类型: "kv", 来源: "program_projection", 职责: "属性" }, + body: { 类型: "markdown", 来源: "model_generated", 职责: "主读正文" }, + forum_feed: { 类型: "list", 来源: "model_generated", 职责: "论坛" }, + danmu_stream: { 类型: "list", 来源: "model_generated", 职责: "弹幕" }, + }, + }, + frontend: { html: `
    `, css: "body{}", js: "present.onData(function(){})" }, + }, +}); + +describe("reply data contract", () => { + it("keeps model blocks and drops program projections from the fill schema", () => { + const contract = parseReplyDataContract(replyFormat); + expect(contract?.modelBlocks.map((block) => block.id)).toEqual([ + "body", + "forum_feed", + "danmu_stream", + ]); + expect(contract?.programBlocks.map((block) => block.id)).toEqual(["status_card"]); + expect(contract?.frontend?.html).toContain("app"); + const built = presentSchemaFromContract(contract!); + const blocks = ( + built.schema.properties as { blocks: { required: string[] } } + ).blocks; + expect(blocks.required).toEqual(["body", "forum_feed", "danmu_stream"]); + expect(built.loose).toBe(false); + }); + + it("rejects an old shell packet that only fills body", () => { + const contract = parseReplyDataContract(replyFormat)!; + const error = openingContractError( + JSON.stringify({ + schema: "present.v1", + shell: "prose", + blocks: { body: "清晨,@玩家推开门。" }, + }), + contract, + ); + expect(error).toContain("forum_feed"); + expect(error).toContain("danmu_stream"); + }); + + it("accepts present.v1 that fills every model block", () => { + const contract = parseReplyDataContract(replyFormat)!; + expect( + openingContractError( + JSON.stringify({ + schema: "present.v1", + blocks: { + body: "清晨,@玩家推开门。", + forum_feed: ["有人愿意修外墙吗?"], + danmu_stream: [], + }, + }), + contract, + ), + ).toBeUndefined(); + }); + + it("preserves contract blocks when normalizing a present packet", () => { + const view = parsePresentPacket( + JSON.stringify({ + schema: "present.v1", + blocks: { + body: "正文", + forum_feed: ["一帖"], + danmu_stream: ["一条"], + }, + }), + ); + expect(view.packet.blocks.forum_feed).toEqual(["一帖"]); + expect(view.packet.blocks.danmu_stream).toEqual(["一条"]); + }); +}); diff --git a/tests/variable-present-project.test.ts b/tests/variable-present-project.test.ts new file mode 100644 index 0000000..8e1caff --- /dev/null +++ b/tests/variable-present-project.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import { + catalogFieldsFromVariableDesign, + mergeCatalogFromVariableDesign, + syncVariableDesignToRuntime, + VARIABLE_DESIGN_TAG, +} from "../src/skills/variable-design-compile.js"; +import { + parseVariableCatalog, + VARIABLE_CATALOG_TAG, +} from "../src/skills/variable-catalog.js"; +import { + mergePresentWithProjections, + parseStatusBarProjectionBindings, + projectPresentBlocks, +} from "../src/skills/present-project.js"; +import { parseReplyDataContract } from "../src/skills/reply-contract.js"; +import { parsePresentPacket } from "../src/skills/present-packet.js"; +import { createTableFromValues, stringifyTableDoc } from "../src/blackboard/table-cells.js"; + +const designRaw = JSON.stringify({ + schema: "context-fragment.v1", + 技能: "变量设计与更新规则", + 正文: { + 真值: [ + { + id: "current_target_name", + 名称: "当前目标", + 类型: "string", + 初始化: { 默认值: "沈清欢" }, + 公开性: "public", + }, + { + id: "target_dignity", + 名称: "尊严", + 类型: "number", + 初始化: { 默认值: 100 }, + 公开性: "public", + }, + { + id: "secret_flag", + 名称: "隐藏标记", + 类型: "boolean", + 初始化: { 默认值: false }, + 公开性: "hidden", + }, + ], + }, +}); + +describe("variable-design-compile", () => { + it("parses truths into catalog fields", () => { + const fields = catalogFieldsFromVariableDesign(designRaw); + expect(fields.map((f) => f.key)).toEqual([ + "current_target_name", + "target_dignity", + "secret_flag", + ]); + expect(fields.find((f) => f.key === "target_dignity")?.initial).toBe(100); + expect(fields.find((f) => f.key === "secret_flag")?.user_visible).toBe(false); + }); + + it("merges into an existing catalog without dropping declare fields", () => { + const existing = parseVariableCatalog( + JSON.stringify({ + schema: "variable-catalog.v1", + fields: [ + { + key: "extra_declared", + type: "number", + initial: 1, + user_visible: true, + }, + ], + }), + ); + const merged = mergeCatalogFromVariableDesign(existing, designRaw)!; + expect(merged.doc.fields.map((f) => f.key).sort()).toEqual([ + "current_target_name", + "extra_declared", + "secret_flag", + "target_dignity", + ].sort()); + expect(merged.added).toEqual( + expect.arrayContaining(["current_target_name", "target_dignity", "secret_flag"]), + ); + }); + + it("syncs design → catalog → 变量.当前", () => { + const sync = syncVariableDesignToRuntime({ + designRaw, + catalogRaw: null, + currentRaw: null, + initialRaw: null, + }); + expect(sync.catalog).toContain("current_target_name"); + expect(sync.current).toContain("沈清欢"); + expect(sync.initial).toContain("target_dignity"); + expect(sync.seededKeys).toEqual( + expect.arrayContaining(["current_target_name", "target_dignity", "secret_flag"]), + ); + expect(VARIABLE_DESIGN_TAG).toBe("设计.变量设计与更新规则"); + expect(VARIABLE_CATALOG_TAG).toBe("设计.变量目录"); + }); + + it("does not overwrite existing current cells when reseeding", () => { + const first = syncVariableDesignToRuntime({ + designRaw, + catalogRaw: null, + currentRaw: null, + initialRaw: null, + }); + const patched = createTableFromValues( + { current_target_name: "已改名", target_dignity: 100, secret_flag: false }, + "user", + ); + const second = syncVariableDesignToRuntime({ + designRaw, + catalogRaw: first.catalog, + currentRaw: stringifyTableDoc(patched), + initialRaw: first.initial, + }); + expect(second.current).toBeNull(); + }); +}); + +describe("present-project", () => { + const replyFormat = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + 数据契约: { + schema: "present.v1", + blocks: { + status_card: { 类型: "kv", 来源: "program_projection", 职责: "属性" }, + body: { 类型: "markdown", 来源: "model_generated", 职责: "正文" }, + }, + }, + frontend: { html: "
    ", css: "", js: "present.onData(function(){})" }, + }, + }); + + const statusBar = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + 语义区域: [ + { + id: "status_card", + 内容类型: "kv", + 来源: { + 方式: "program_projection", + ref: "变量.当前", + 字段: ["current_target_name", "target_dignity"], + }, + }, + ], + }, + }); + + it("reads projection bindings from status bar", () => { + const bindings = parseStatusBarProjectionBindings(statusBar); + expect(bindings).toEqual([ + { + id: "status_card", + type: "kv", + ref: "变量.当前", + fields: ["current_target_name", "target_dignity"], + }, + ]); + }); + + it("projects selected fields into program blocks", () => { + const contract = parseReplyDataContract(replyFormat)!; + const current = stringifyTableDoc( + createTableFromValues( + { current_target_name: "沈清欢", target_dignity: 100, other: 1 }, + "system", + ), + ); + const projected = projectPresentBlocks({ + contract, + statusBarRaw: statusBar, + currentVarsRaw: current, + }); + expect(projected.status_card).toEqual({ + current_target_name: "沈清欢", + target_dignity: 100, + }); + }); + + it("falls back to all current vars when status bar has no binding", () => { + const contract = parseReplyDataContract(replyFormat)!; + const current = stringifyTableDoc( + createTableFromValues({ a: 1, b: "x" }, "system"), + ); + const projected = projectPresentBlocks({ + contract, + currentVarsRaw: current, + }); + expect(projected.status_card).toEqual({ a: 1, b: "x" }); + }); + + it("expands JSON-string matrices into table rows for program projection", () => { + const reply = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + 数据契约: { + schema: "present.v1", + blocks: { + heroines: { 类型: "table", 来源: "program_projection", 职责: "矩阵" }, + body: { 类型: "markdown", 来源: "model_generated", 职责: "正文" }, + }, + }, + frontend: { html: "
    ", css: "", js: "present.onData(function(){})" }, + }, + }); + const status = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + 语义区域: [ + { + id: "heroines", + 内容类型: "table", + 来源: { + 方式: "program_projection", + ref: "变量.当前", + 字段: ["heroines_matrix"], + }, + }, + ], + }, + }); + const matrix = { + 塞琳娜: { 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 }, + 崔岚: { 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 }, + }; + const current = stringifyTableDoc( + createTableFromValues( + { heroines_matrix: JSON.stringify(matrix) }, + "system", + ), + ); + const projected = projectPresentBlocks({ + contract: parseReplyDataContract(reply)!, + statusBarRaw: status, + currentVarsRaw: current, + }); + expect(projected.heroines).toEqual({ + source: "heroines_matrix", + rows: [ + { name: "塞琳娜", 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 }, + { name: "崔岚", 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 }, + ], + }); + }); + + it("merges projections into a plain body present packet", () => { + const merged = mergePresentWithProjections( + "一段正文", + { status_card: { hp: 10 } }, + "prose", + ); + const view = parsePresentPacket(merged); + expect(view.packet.blocks.body).toBe("一段正文"); + expect(view.packet.blocks.status_card).toEqual({ hp: 10 }); + }); +}); diff --git a/web/play-aux.js b/web/play-aux.js index f5387ff..8c65610 100644 --- a/web/play-aux.js +++ b/web/play-aux.js @@ -1,12 +1,20 @@ /** - * 游玩辅助 Tab:表格 / 开场与角色 / 对话。 - * 隐藏格默认挡住,点字段才露;手改带格子 rev。 + * 游玩辅助 Tab:表格 / 开场与角色。 + * 隐藏格默认挡住,点字段才露。 + * 可改格直接改本地草稿;只有点保存并确认后才写入。 */ import { parsePresentDoc } from "./present-shells.js"; let playAuxTab = "table"; let revealedHidden = new Set(); -let editingKey = null; +let secretsOpen = false; +let draftsTag = ""; +/** @type {Map} */ +let tableDrafts = new Map(); +/** @type {{ key: string, start: number | null, end: number | null } | null} */ +let tableFocus = null; +let tableSaving = false; +let ignoreNextCapture = false; function esc(s) { return String(s ?? "") @@ -16,30 +24,445 @@ function esc(s) { .replace(/"/g, """); } -function formatValue(value) { - if (value === null || value === undefined) return ""; - if (typeof value === "object") { - try { - return JSON.stringify(value); - } catch { - return String(value); - } +function cssAttr(value) { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); } - return String(value); + return String(value).replace(/["\\]/g, "\\$&"); } -function coerceValue(prev, raw) { - const text = String(raw ?? "").trim(); - if (typeof prev === "boolean") { - if (text === "true" || text === "是" || text === "1") return true; - if (text === "false" || text === "否" || text === "0") return false; - return prev; +export function coerceTableValue(value) { + if (typeof value !== "string") return value; + const t = value.trim(); + if (t.length < 2) return value; + const start = t[0]; + const end = t[t.length - 1]; + if ((start === "{" && end === "}") || (start === "[" && end === "]")) { + try { + return JSON.parse(t); + } catch { + return value; + } } - if (typeof prev === "number") { - const n = Number(text); - return Number.isFinite(n) ? n : prev; + return value; +} + +export function formatTableValue(value) { + const normalized = coerceTableValue(value); + if (normalized === null || normalized === undefined) return ""; + if (typeof normalized === "object") { + try { + const compact = JSON.stringify(normalized); + if (compact.length <= 42) return compact; + return JSON.stringify(normalized, null, 2); + } catch { + return String(normalized); + } } - return text; + return String(normalized); +} + +function previewChange(value) { + const flat = formatTableValue(value).replace(/\s+/g, " ").trim(); + if (!flat) return "空"; + if (flat.length <= 42) return flat; + return `${flat.slice(0, 42)}…`; +} + +function sameValue(a, b) { + const left = coerceTableValue(a); + const right = coerceTableValue(b); + if (Object.is(left, right)) return true; + if (typeof left === "object" || typeof right === "object") { + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } + } + return false; +} + +/** @returns {{ ok: true, value: unknown } | { ok: false, error: string }} */ +export function parseTableDraft(prev, raw) { + const text = String(raw ?? ""); + const trimmed = text.trim(); + const baseline = coerceTableValue(prev); + if (typeof baseline === "boolean") { + if (trimmed === "true" || trimmed === "是" || trimmed === "1") return { ok: true, value: true }; + if (trimmed === "false" || trimmed === "否" || trimmed === "0") return { ok: true, value: false }; + return { ok: false, error: "填是或否" }; + } + if (typeof baseline === "number") { + if (!trimmed) return { ok: false, error: "需要数字" }; + const n = Number(trimmed); + if (!Number.isFinite(n)) return { ok: false, error: "不是数字" }; + return { ok: true, value: n }; + } + if (baseline !== null && typeof baseline === "object") { + if (!trimmed) return { ok: false, error: "需要 JSON" }; + try { + return { ok: true, value: JSON.parse(trimmed) }; + } catch { + return { ok: false, error: "JSON 不合法" }; + } + } + // 原文是 JSON 字符串时,保存为结构化对象,避免继续塞一坨字 + if (typeof prev === "string" && coerceTableValue(prev) !== prev) { + if (!trimmed) return { ok: false, error: "需要 JSON" }; + try { + return { ok: true, value: JSON.parse(trimmed) }; + } catch { + return { ok: false, error: "JSON 不合法" }; + } + } + return { ok: true, value: trimmed }; +} + +function rowOpen(row) { + return row.visibility !== "hidden" || revealedHidden.has(row.key); +} + +function isStructuredValue(value) { + const normalized = coerceTableValue(value); + return normalized !== null && typeof normalized === "object"; +} + +function stacked(row) { + if (typeof coerceTableValue(row.value) === "boolean") return false; + if (isStructuredValue(row.value)) return true; + const text = formatTableValue(row.value); + return text.includes("\n") || [...text].length > 28; +} + +function draftText(row) { + if (tableDrafts.has(row.key)) return tableDrafts.get(row.key) ?? ""; + const normalized = coerceTableValue(row.value); + if (typeof normalized === "boolean") return normalized ? "true" : "false"; + return formatTableValue(row.value); +} + +function leafText(value) { + if (value === null || value === undefined) return "空"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function matrixColumns(rows) { + const cols = []; + const seen = new Set(); + for (const row of rows) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + for (const key of Object.keys(row)) { + if (seen.has(key)) continue; + seen.add(key); + cols.push(key); + } + } + return cols; +} + +function nestedMatrixHtml(rows, nameKey) { + const cols = matrixColumns(rows); + if (!cols.length) return `
    ${esc(formatTableValue(rows))}
    `; + const head = cols.map((col) => `${esc(col)}`).join(""); + const body = rows + .map((row) => { + const cells = cols + .map((col) => { + const raw = row && typeof row === "object" ? row[col] : ""; + const nested = coerceTableValue(raw); + if (nested !== null && typeof nested === "object") { + return `${nestedValueHtml(nested, 1)}`; + } + return `${esc(leafText(raw))}`; + }) + .join(""); + return `${cells}`; + }) + .join(""); + return `
    ${head}${body}
    `; +} + +function namedMatrixHtml(map) { + const rows = Object.entries(map).map(([name, stats]) => { + if (stats && typeof stats === "object" && !Array.isArray(stats)) { + return { name, ...stats }; + } + return { name, value: stats }; + }); + return nestedMatrixHtml(rows); +} + +function objectTreeHtml(value, depth = 0) { + const entries = Object.entries(value); + if (!entries.length) return `{}`; + const items = entries + .map(([key, child]) => { + const nested = coerceTableValue(child); + if (nested !== null && typeof nested === "object" && depth < 2) { + return `
    ${esc(key)}${nestedValueHtml(nested, depth + 1)}
    `; + } + return `
    ${esc(key)}${esc(leafText(nested))}
    `; + }) + .join(""); + return `
    ${items}
    `; +} + +function nestedValueHtml(value, depth = 0) { + const normalized = coerceTableValue(value); + if (normalized === null || normalized === undefined) { + return `空`; + } + if (typeof normalized !== "object") { + return `${esc(String(normalized))}`; + } + if (Array.isArray(normalized)) { + if ( + normalized.length && + normalized.every((item) => item && typeof item === "object" && !Array.isArray(item)) + ) { + return nestedMatrixHtml(normalized); + } + return `
    ${esc(JSON.stringify(normalized, null, 2))}
    `; + } + const entries = Object.entries(normalized); + if ( + entries.length && + entries.every(([, child]) => child && typeof child === "object" && !Array.isArray(child)) + ) { + return namedMatrixHtml(normalized); + } + return objectTreeHtml(normalized, depth); +} + +function caretOf(el) { + try { + if (typeof el.selectionStart !== "number" || typeof el.selectionEnd !== "number") { + return { start: null, end: null }; + } + return { start: el.selectionStart, end: el.selectionEnd }; + } catch { + return { start: null, end: null }; + } +} + +function readControl(el) { + if (el instanceof HTMLInputElement && el.type === "checkbox") { + return el.checked ? "true" : "false"; + } + return el.value; +} + +function bindTableTag(tag) { + const next = tag || ""; + if (draftsTag === next) return; + draftsTag = next; + tableDrafts = new Map(); + tableFocus = null; + tableSaving = false; +} + +function pruneDrafts(table, focusedKey) { + const keys = new Set((table?.rows ?? []).map((row) => row.key)); + for (const key of [...tableDrafts.keys()]) { + if (!keys.has(key)) tableDrafts.delete(key); + } + for (const row of table?.rows ?? []) { + if (!tableDrafts.has(row.key)) continue; + const text = tableDrafts.get(row.key) ?? ""; + const baseline = coerceTableValue(row.value); + const canonical = + typeof baseline === "boolean" ? (baseline ? "true" : "false") : formatTableValue(row.value); + if (text === canonical) { + tableDrafts.delete(row.key); + continue; + } + if (row.key === focusedKey) continue; + const parsed = parseTableDraft(row.value, text); + if (parsed.ok && sameValue(parsed.value, row.value)) tableDrafts.delete(row.key); + } +} + +/** 整页重绘会先清掉侧栏。丢掉 DOM 之前把草稿和光标记下来。 */ +export function capturePlayTableEdits(root) { + if (ignoreNextCapture) { + ignoreNextCapture = false; + return; + } + const scope = root ?? document.getElementById("coord-drawer-body"); + if (!scope) return; + const secrets = scope.querySelector(".play-table-secrets"); + if (secrets) secretsOpen = secrets.open; + const fields = scope.querySelectorAll("[data-table-input]"); + // 侧栏已经被清掉时不要抹掉上一轮记下来的光标 + if (!fields.length) return; + const active = document.activeElement; + let sawField = false; + fields.forEach((el) => { + const key = el.getAttribute("data-table-input"); + if (!key) return; + tableDrafts.set(key, readControl(el)); + if (el === active) { + sawField = true; + tableFocus = { key, ...caretOf(el) }; + } + }); + if (!sawField && active && !scope.contains(active)) tableFocus = null; +} + +function restoreTableFocus(root) { + if (!tableFocus) return; + const active = document.activeElement; + const focusFree = + !active || + active === document.body || + active === document.documentElement || + root.contains(active); + if (!focusFree) return; + const saved = tableFocus; + const el = root.querySelector(`[data-table-input="${cssAttr(saved.key)}"]`); + if (!el) return; + el.focus({ preventScroll: true }); + if (typeof el.setSelectionRange === "function" && saved.start != null && saved.end != null) { + try { + el.setSelectionRange(saved.start, saved.end); + } catch { + /* 某些控件没有可选区 */ + } + } + tableFocus = saved; +} + +function fitTextarea(el) { + el.style.height = "auto"; + const cap = el.classList.contains("is-code") ? 280 : 148; + const next = Math.min(Math.max(el.scrollHeight, 32), cap); + el.style.height = `${next}px`; +} + +function fitInput(el) { + if (el.tagName !== "INPUT" || el.type === "checkbox") return; + const row = el.closest(".play-table-row"); + if (!row || row.classList.contains("is-stack")) return; + row.classList.remove("is-wide"); + const sample = el.value || el.placeholder || ""; + const len = Math.min(28, Math.max(3, [...sample].length)); + el.style.width = `${len + 1.5}ch`; + if (el.scrollWidth > el.clientWidth + 1) { + row.classList.add("is-wide"); + el.style.width = "100%"; + } +} + +function noteTitle(row) { + const note = String(row.note ?? "").trim(); + return note ? ` title="${esc(note)}"` : ""; +} + +function fieldHtml(row, { busy }) { + const text = draftText(row); + const disabled = busy ? "disabled" : ""; + const structured = isStructuredValue(row.value); + const code = structured ? " is-code" : ""; + const baseline = coerceTableValue(row.value); + if (typeof baseline === "boolean") { + const checked = text === "true" || text === "是" || text === "1" ? "checked" : ""; + return ``; + } + if (stacked(row)) { + return ``; + } + const inputMode = typeof baseline === "number" ? ' inputmode="decimal"' : ""; + const placeholder = typeof baseline === "number" ? "数字" : "空"; + return ``; +} + +function readonlyHtml(row, { hidden, revealed }) { + const shown = !hidden || revealed; + const structured = shown && isStructuredValue(row.value); + const stack = shown && (structured || stacked(row)); + if (hidden && !revealed) { + return `
  1. + +
  2. `; + } + const valueHtml = structured + ? nestedValueHtml(row.value) + : `${esc(formatTableValue(row.value) || "空")}`; + return `
  3. +
    + ${esc(row.key)} + ${valueHtml} +
    +
  4. `; +} + +function rowHtml(row, { hidden, revealed, busy }) { + const shown = !hidden || revealed; + if (!shown || !row.editable) return readonlyHtml(row, { hidden, revealed }); + const stack = stacked(row); + return `
  5. + ${fieldHtml(row, { busy })} +
  6. `; +} + +function tablePanelHtml(table, busy) { + if (!table?.rows?.length) { + return `

    本局没有状态表。

    `; + } + const visible = table.rows.filter((r) => r.visibility !== "hidden"); + const hidden = table.rows.filter((r) => r.visibility === "hidden"); + const vis = + visible.length > 0 + ? `
      ${visible + .map((row) => rowHtml(row, { hidden: false, revealed: true, busy })) + .join("")}
    ` + : `

    没有可见字段。

    `; + const hid = hidden.length + ? `
    + 不可见 ${hidden.length} +
      ${hidden + .map((row) => + rowHtml(row, { + hidden: true, + revealed: revealedHidden.has(row.key), + busy, + }), + ) + .join("")}
    +
    ` + : ""; + const editable = table.rows.some((row) => row.editable); + const bar = editable + ? `` + : ""; + return `
    ${vis}${hid}${bar}
    `; } function previewOpening(text) { @@ -58,68 +481,6 @@ function previewOpening(text) { return `${flat.slice(0, 72)}…`; } -function rowHtml(row, { hidden, revealed, editing, busy }) { - const shown = !hidden || revealed; - const val = shown ? formatValue(row.value) : "••••••"; - const edit = - shown && row.editable && editing - ? `
    - - - -
    ` - : ""; - const action = - shown && row.editable && !editing - ? `` - : ""; - return `
  7. - - ${action} - ${edit} -
  8. `; -} - -function tablePanelHtml(table, busy) { - if (!table?.rows?.length) { - return `

    本局没有状态表。

    `; - } - const visible = table.rows.filter((r) => r.visibility !== "hidden"); - const hidden = table.rows.filter((r) => r.visibility === "hidden"); - const vis = - visible.length > 0 - ? `
      ${visible - .map((row) => - rowHtml(row, { - hidden: false, - revealed: true, - editing: editingKey === row.key, - busy, - }), - ) - .join("")}
    ` - : `

    没有可见字段。

    `; - const hid = hidden.length - ? `
    - 不可见 ${hidden.length} -
      ${hidden - .map((row) => - rowHtml(row, { - hidden: true, - revealed: revealedHidden.has(row.key), - editing: editingKey === row.key, - busy, - }), - ) - .join("")}
    -
    ` - : ""; - return `
    ${vis}${hid}
    `; -} - function openingPanelHtml(aux) { const openings = aux?.openings ?? []; if (!openings.length) { @@ -162,6 +523,7 @@ export function fillPlayAux(view, handlers = {}, extra = {}) { const drawerCount = document.getElementById("coord-drawer-count"); if (!rail || !drawerBody) return; rail.hidden = false; + capturePlayTableEdits(drawerBody); const aux = view.playAux; @@ -187,6 +549,7 @@ export function fillPlayAux(view, handlers = {}, extra = {}) { const id = btn.getAttribute("data-play-aux-tab"); if (!id || id === playAuxTab) return; playAuxTab = id; + tableFocus = null; fillPlayAux(view, handlers, extra); }; } @@ -195,10 +558,14 @@ export function fillPlayAux(view, handlers = {}, extra = {}) { const busy = Boolean(extra.busy); if (playAuxTab === "table") { + bindTableTag(aux?.table?.tag || ""); + pruneDrafts(aux?.table, tableFocus?.key); + tableSaving = false; drawerBody.innerHTML = tablePanelHtml(aux?.table, busy); wireTable(drawerBody, aux?.table, handlers, busy, () => fillPlayAux(view, handlers, extra), ); + restoreTableFocus(drawerBody); return; } if (playAuxTab === "opening") { @@ -216,48 +583,177 @@ export function hidePlayAuxTabs() { if (tabs) tabs.hidden = true; } +function syncDirtyUi(root, table, busy) { + let dirty = 0; + let invalid = 0; + let firstInvalid = null; + for (const row of table?.rows ?? []) { + if (!row.editable || !rowOpen(row)) continue; + const li = root.querySelector(`[data-table-key="${cssAttr(row.key)}"]`); + if (!li || li.getAttribute("data-editing") !== "1") continue; + const text = draftText(row); + const canonical = + typeof row.value === "boolean" ? (row.value ? "true" : "false") : formatTableValue(row.value); + const changed = text !== canonical; + const parsed = changed ? parseTableDraft(row.value, text) : { ok: true, value: row.value }; + const bad = changed && !parsed.ok; + li.classList.toggle("is-dirty", changed); + li.classList.toggle("is-invalid", bad); + const err = li.querySelector("[data-table-error]"); + if (err) { + err.hidden = !bad; + err.textContent = bad ? parsed.error : ""; + } + if (changed) dirty += 1; + if (bad) { + invalid += 1; + firstInvalid ??= li.querySelector("[data-table-input]"); + } + } + const bar = root.querySelector(".play-table-bar"); + if (!bar) return { dirty, invalid, firstInvalid }; + bar.hidden = dirty === 0; + const note = bar.querySelector("[data-table-dirty-note]"); + if (note) { + note.textContent = invalid ? `${invalid} 项还不能保存` : `${dirty} 项未保存`; + } + const save = bar.querySelector("[data-table-save]"); + if (save) save.disabled = busy || tableSaving || dirty === 0 || invalid > 0; + const revert = bar.querySelector("[data-table-revert]"); + if (revert) revert.disabled = busy || tableSaving || dirty === 0; + return { dirty, invalid, firstInvalid }; +} + function wireTable(root, table, handlers, busy, rerender) { + root.querySelectorAll("textarea.play-table-input").forEach((el) => fitTextarea(el)); + root.querySelectorAll("input.play-table-input").forEach((el) => fitInput(el)); + root.querySelector(".play-table-secrets")?.addEventListener("toggle", (e) => { + secretsOpen = Boolean(e.currentTarget?.open); + }); root.querySelectorAll("[data-table-reveal]").forEach((btn) => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-table-reveal"); - const row = btn.closest(".play-table-row"); - if (!key || !row) return; - if (row.getAttribute("data-hidden") === "1" && !revealedHidden.has(key)) { - revealedHidden.add(key); - rerender?.(); + if (!key || revealedHidden.has(key)) return; + revealedHidden.add(key); + secretsOpen = true; + tableFocus = { key, start: null, end: null }; + rerender?.(); + }); + }); + + const remember = (el) => { + const key = el.getAttribute("data-table-input"); + if (!key) return; + tableDrafts.set(key, readControl(el)); + tableFocus = { key, ...caretOf(el) }; + if (el.tagName === "TEXTAREA") fitTextarea(el); + else fitInput(el); + syncDirtyUi(root, table, busy); + }; + + root.querySelectorAll("[data-table-input]").forEach((el) => { + el.addEventListener("input", () => remember(el)); + el.addEventListener("change", () => remember(el)); + el.addEventListener("focus", () => remember(el)); + el.addEventListener("keydown", (e) => { + if (e.isComposing || e.keyCode === 229) return; + const key = el.getAttribute("data-table-input"); + if (!key) return; + if (e.key === "Escape") { + e.preventDefault(); + const row = table?.rows?.find((item) => item.key === key); + const canonical = row + ? typeof row.value === "boolean" + ? row.value + ? "true" + : "false" + : formatTableValue(row.value) + : ""; + tableDrafts.delete(key); + if (el.type === "checkbox") el.checked = canonical === "true"; + else el.value = canonical; + tableFocus = { key, start: canonical.length, end: canonical.length }; + syncDirtyUi(root, table, busy); + return; } - }); - }); - root.querySelectorAll("[data-table-fix]").forEach((btn) => { - btn.addEventListener("click", (e) => { + const commit = + (e.key === "Enter" && el.tagName !== "TEXTAREA") || + (e.key === "Enter" && (e.metaKey || e.ctrlKey)); + if (!commit) return; e.preventDefault(); - editingKey = btn.getAttribute("data-table-fix"); + requestSave(); + }); + }); + + root.querySelector("[data-table-revert]")?.addEventListener("click", () => { + if (busy || tableSaving) return; + ignoreNextCapture = true; + tableDrafts = new Map(); + tableFocus = null; + rerender?.(); + }); + root.querySelector("[data-table-save]")?.addEventListener("click", () => { + requestSave(); + }); + syncDirtyUi(root, table, busy); + + function requestSave() { + if (busy || tableSaving || !table) return; + capturePlayTableEdits(root); + const pending = []; + let firstInvalid = null; + for (const row of table.rows) { + if (!row.editable || !rowOpen(row)) continue; + const text = draftText(row); + const canonical = + typeof row.value === "boolean" ? (row.value ? "true" : "false") : formatTableValue(row.value); + if (text === canonical) continue; + const parsed = parseTableDraft(row.value, text); + if (!parsed.ok) { + firstInvalid ??= row.key; + continue; + } + if (sameValue(parsed.value, row.value)) { + tableDrafts.delete(row.key); + continue; + } + pending.push({ row, value: parsed.value }); + } + const state = syncDirtyUi(root, table, busy); + if (state.invalid > 0 || firstInvalid) { + const el = + state.firstInvalid || + root.querySelector(`[data-table-input="${cssAttr(firstInvalid || "")}"]`); + el?.focus(); + return; + } + if (!pending.length) { + ignoreNextCapture = true; rerender?.(); - }); - }); - root.querySelectorAll("[data-table-cancel]").forEach((btn) => { - btn.addEventListener("click", () => { - editingKey = null; - rerender?.(); - }); - }); - root.querySelectorAll("[data-table-edit]").forEach((form) => { - form.addEventListener("submit", (e) => { - e.preventDefault(); - if (busy) return; - const key = form.getAttribute("data-table-edit"); - const row = table?.rows?.find((r) => r.key === key); - if (!key || !row) return; - const next = coerceValue( - row.value, - form.querySelector("input[name=value]")?.value, - ); - editingKey = null; - handlers.onPatchTable?.(table.tag, [ - { key, value: next, expectedRev: row.rev }, - ]); - }); - }); + return; + } + const lines = pending.map( + (item) => + `${item.row.key}:${previewChange(item.row.value)} → ${previewChange(item.value)}`, + ); + const shown = lines.slice(0, 8); + if (lines.length > 8) shown.push(`另有 ${lines.length - 8} 项`); + const head = + pending.length === 1 + ? `保存对「${pending[0].row.key}」的修改?` + : `保存 ${pending.length} 项修改?`; + if (!confirm(`${head}\n\n${shown.join("\n")}`)) return; + tableSaving = true; + syncDirtyUi(root, table, busy); + handlers.onPatchTable?.( + table.tag, + pending.map((item) => ({ + key: item.row.key, + value: item.value, + expectedRev: item.row.rev, + })), + ); + } } function wireOpening(root, handlers, busy) { diff --git a/web/present-frame.js b/web/present-frame.js index d4a81df..92306c6 100644 --- a/web/present-frame.js +++ b/web/present-frame.js @@ -32,15 +32,17 @@ export function previewPacket(sample) { return { schema: "present.v1", blocks: sample }; } -export function queuePresentFrame(bundle, packet) { +export function queuePresentFrame(bundle, packet, slots) { const id = `pf${seq.toString(36)}${(seq += 1).toString(36)}`; - frames.set(id, { bundle, packet }); + frames.set(id, { bundle, packet, slots: Array.isArray(slots) ? slots : [] }); return id; } -export function presentFrameSlotHtml(id) { +export function presentFrameSlotHtml(id, minHeight) { const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, ""); - return `
    `; + const h = Number(minHeight); + const style = Number.isFinite(h) && h > 8 ? ` style="min-height:${Math.ceil(h)}px"` : ""; + return `
    `; } function neutralizeClosing(source, tag) { @@ -54,23 +56,31 @@ function embedJson(value) { .replace(/\u2029/g, "\\u2029"); } -export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frameId }) { +function flattenFrameCss(css) { + return String(css ?? "") + .replace(/\b100(?:dvh|svh|lvh|vh)\b/g, "auto") + .replace(/overflow(-[xy])?\s*:\s*(auto|scroll)\b/g, "overflow$1: visible"); +} + +export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, slots, frameId }) { const safeId = String(frameId).replace(/[^a-zA-Z0-9_-]/g, ""); - const cssSafe = neutralizeClosing(css, "style"); + const cssSafe = neutralizeClosing(flattenFrameCss(css), "style"); const htmlSafe = String(html).replace(/<\/(body|html|head)\s*>/gi, ""); const jsSafe = neutralizeClosing(js, "script"); const packetJson = embedJson(packet ?? { schema: "present.v1", blocks: {} }); + const slotsJson = embedJson(Array.isArray(slots) ? slots : []); return ` - +