把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,8 +2115,29 @@ export class PhaseRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
/** 目录初值 → 变量.当前;再按 设计.变量映射 重写投影 tag */
|
||||
/** 变量设计 → 目录 → 变量.当前;再按 设计.变量映射 重写投影 tag */
|
||||
private seedVariablesAndReproject(actor: string): void {
|
||||
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),
|
||||
});
|
||||
if (designSync.catalog || designSync.current || designSync.initial) {
|
||||
writeVariableDesignSync(designSync, (tag, content) => {
|
||||
this.blackboard.write({ tag, content, source: actor });
|
||||
});
|
||||
if (designSync.added.length) {
|
||||
this.onMessage(
|
||||
`[变量] 已从变量设计同步目录:${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),
|
||||
@@ -2104,9 +2163,36 @@ export class PhaseRuntime {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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({
|
||||
|
||||
@@ -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<string, unknown> | undefined {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||
return v as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>)
|
||||
: undefined;
|
||||
const meta =
|
||||
row?.meta && typeof row.meta === "object" && !Array.isArray(row.meta)
|
||||
? (row.meta as Record<string, unknown>)
|
||||
: undefined;
|
||||
const fromJson =
|
||||
parseUserPersonaField(doc) ||
|
||||
parseUserPersonaField(
|
||||
doc && typeof doc === "object" && !Array.isArray(doc)
|
||||
? (doc as Record<string, unknown>).用户角色
|
||||
: undefined,
|
||||
);
|
||||
parseUserPersonaField(row?.用户角色) ||
|
||||
parseUserPersonaField(meta?.用户角色);
|
||||
if (fromJson) return fromJson;
|
||||
} catch {
|
||||
/* not json */
|
||||
|
||||
+61
-29
@@ -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<string, unknown> | 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
type OpeningProductRow = {
|
||||
tag: string;
|
||||
content: string;
|
||||
order: number;
|
||||
}> = [];
|
||||
slot: string;
|
||||
};
|
||||
|
||||
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<string, unknown>,
|
||||
|
||||
@@ -40,9 +40,11 @@ export type ShellAdaptation = {
|
||||
tweaks: PresentShellTweaks;
|
||||
};
|
||||
|
||||
export type PresentBlocks = Partial<
|
||||
Record<PresentRegionId, string | Record<string, unknown> | unknown[]>
|
||||
>;
|
||||
export type PresentBlockValue = string | Record<string, unknown> | unknown[];
|
||||
|
||||
/** 已知壳区域以外的键来自本局正文组成数据契约,必须原样保留。 */
|
||||
export type PresentBlocks = Partial<Record<PresentRegionId, PresentBlockValue>> &
|
||||
Record<string, PresentBlockValue | undefined>;
|
||||
|
||||
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<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeBlocks(raw: unknown): PresentBlocks {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const row = raw as Record<string, unknown>;
|
||||
const out: PresentBlocks = {};
|
||||
const keys: PresentRegionId[] = [
|
||||
"monitor",
|
||||
"header",
|
||||
"body",
|
||||
"footer",
|
||||
"aside",
|
||||
"hidden",
|
||||
];
|
||||
for (const k of keys) {
|
||||
if (row[k] !== undefined) out[k] = row[k] as PresentBlocks[typeof k];
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown> | undefined {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||
return v as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 从回复呈现(设计.监控栏)取出程序投影绑定 */
|
||||
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<string, unknown>,
|
||||
fields?: string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = coerceJsonValue(child);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function coerceValueMap(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
out[key] = coerceJsonValue(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 名字 → 属性对象 的矩阵,压成带 name 列的行数组,方便 table 渲染。 */
|
||||
function matrixMapToRows(
|
||||
map: Record<string, unknown>,
|
||||
nameKey = "name",
|
||||
): Record<string, unknown>[] {
|
||||
return Object.entries(map).map(([name, stats]) => {
|
||||
if (isPlainObject(stats)) return { [nameKey]: name, ...stats };
|
||||
return { [nameKey]: name, value: stats };
|
||||
});
|
||||
}
|
||||
|
||||
function shapeTable(values: Record<string, unknown>): 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<string, unknown>,
|
||||
): 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, string | null | undefined>,
|
||||
): 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<string, unknown> {
|
||||
const ref = params.ref.trim();
|
||||
const fallbacks: Record<string, string | null | undefined> = {
|
||||
"变量.当前": 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<string, unknown> = {};
|
||||
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<string, PresentBlockValue> {
|
||||
const bindings = parseStatusBarProjectionBindings(params.statusBarRaw);
|
||||
const byId = new Map(bindings.map((b) => [b.id, b]));
|
||||
const currentMap = tableValueMap(parseTableDoc(params.currentVarsRaw));
|
||||
const out: Record<string, PresentBlockValue> = {};
|
||||
|
||||
for (const block of params.contract.programBlocks) {
|
||||
const bound = byId.get(block.id);
|
||||
const type = bound?.type || block.type || "kv";
|
||||
let values: Record<string, unknown>;
|
||||
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<string, PresentBlockValue> {
|
||||
return projectPresentBlocks({ contract, currentVarsRaw });
|
||||
}
|
||||
|
||||
export function mergePresentWithProjections(
|
||||
content: string,
|
||||
projections: Record<string, PresentBlockValue>,
|
||||
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<string, PresentBlockValue> {
|
||||
if (!blocks.length) return {};
|
||||
const contract: ReplyDataContract = {
|
||||
modelBlocks: [],
|
||||
programBlocks: blocks,
|
||||
};
|
||||
return projectPresentBlocks({ contract, currentVarsRaw });
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string, unknown>;
|
||||
loose: boolean;
|
||||
} {
|
||||
const properties: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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<Record<string, unknown>> {
|
||||
const doc = parseObject(content);
|
||||
if (!doc) return [];
|
||||
const maps: Array<Record<string, unknown>> = [];
|
||||
walk(doc, maps, 0);
|
||||
return maps;
|
||||
}
|
||||
|
||||
function walk(node: unknown, maps: Array<Record<string, unknown>>, 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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const text = value.trim();
|
||||
return text || undefined;
|
||||
}
|
||||
@@ -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<string, unknown> | undefined {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||
return v as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const DESIGN_STAGE_SKILL_META: Record<
|
||||
"opening-generator": {
|
||||
label: "开局 · 开场白",
|
||||
purpose:
|
||||
"创作末尾:优先落库设计.开场白与开场变量;否则现写开场;初值与开场同真相。",
|
||||
"创作末尾:每条开场落库设计.开场白#场景短码;没有变量的卡不写初值。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+34
-9
@@ -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,17 +835,29 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
isPlayLayerActive(params.slots) && isPlayGmBodyWorker(worker.id);
|
||||
const playPresent =
|
||||
isPlayLayerActive(params.slots) && isPlayPresentWorker(worker.id);
|
||||
const replyFormatRaw =
|
||||
params.blackboard.getContentByTag("设计.正文组成") ??
|
||||
params.blackboard.getContentByTag("设计.0层正文组成") ??
|
||||
params.blackboard.getContentByTag("设计.回复格式");
|
||||
const replyContract = parseReplyDataContract(replyFormatRaw);
|
||||
const narratorOn = isNarratorEnabled(playSlots);
|
||||
const fillByContract =
|
||||
Boolean(replyContract?.frontend && replyContract.modelBlocks.length) &&
|
||||
(playPresent || (playGmBody && !narratorOn));
|
||||
const contractSchema =
|
||||
fillByContract && replyContract
|
||||
? presentSchemaFromContract(replyContract)
|
||||
: undefined;
|
||||
const playShell: PresentShellId | undefined =
|
||||
playPresent || playGmBody
|
||||
? parseShellAdaptationFromReplyFormat(
|
||||
params.blackboard.getContentByTag("设计.正文组成") ??
|
||||
params.blackboard.getContentByTag("设计.回复格式"),
|
||||
)?.shell_id ?? "prose"
|
||||
? parseShellAdaptationFromReplyFormat(replyFormatRaw)?.shell_id ?? "prose"
|
||||
: undefined;
|
||||
|
||||
const systemContent =
|
||||
promptBody +
|
||||
(playGmBody
|
||||
(contractSchema && replyContract && !useGmHarness
|
||||
? contractOutputInstruction(replyContract)
|
||||
: playGmBody
|
||||
? playGmBodyOutputInstruction()
|
||||
: playPresent && playShell
|
||||
? playPresentOutputInstruction(playShell)
|
||||
@@ -888,7 +906,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (playGmBody) {
|
||||
if (playGmBody && !contractSchema) {
|
||||
const completeOpts = {
|
||||
caller: `worker:${worker.id}`,
|
||||
label: worker.name?.trim() || worker.id,
|
||||
@@ -905,12 +923,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
}
|
||||
|
||||
const result = await completeStructured(params.llm, messages, {
|
||||
schema: playPresent ? PRESENT_JSON_SCHEMA : {
|
||||
schema: contractSchema
|
||||
? contractSchema.schema
|
||||
: playPresent
|
||||
? PRESENT_JSON_SCHEMA
|
||||
: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
name: playPresent ? "submit_present_packet" : "submit_worker_result",
|
||||
schemaIsLoose: !playPresent,
|
||||
name:
|
||||
contractSchema || playPresent
|
||||
? "submit_present_packet"
|
||||
: "submit_worker_result",
|
||||
schemaIsLoose: contractSchema ? contractSchema.loose : !playPresent,
|
||||
capabilities: resolveActiveProfile()?.capabilities,
|
||||
caller: `worker:${worker.id}`,
|
||||
label: worker.name?.trim() || worker.id,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
sanitizeReasoningEffort,
|
||||
sanitizeVerbosity,
|
||||
} from "../src/llm/generation-compat.js";
|
||||
import { buildRequestBody } from "../src/llm/client.js";
|
||||
import { buildRequestBody, extractMessageParts, openRouterSessionId } from "../src/llm/client.js";
|
||||
import type { LlmConfig } from "../src/config/env.js";
|
||||
|
||||
const stGeneration = {
|
||||
@@ -410,6 +410,24 @@ describe("fetchWithGenerationCompat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+148
-1
@@ -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(`<div id="app"></div>`);
|
||||
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: `<article data-block="body"></article>`,
|
||||
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: `<article id="body" data-block="body"></article>`,
|
||||
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: `<article id="body" data-block="body"></article>`,
|
||||
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("<p>第一段</p>");
|
||||
expect(html).toContain("<p>第二段 <strong>加粗</strong></p>");
|
||||
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: `<article id="body" data-block="body"></article>`,
|
||||
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: `<ol data-block="options"></ol>`,
|
||||
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: `<section data-block="卷宗表"></section><article data-block="正文"></article>`,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: `<div id="app"></div>`, 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(["一条"]);
|
||||
});
|
||||
});
|
||||
@@ -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: "<div id='app'></div>", 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: "<div></div>", 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 });
|
||||
});
|
||||
});
|
||||
+604
-108
@@ -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<string, string>} */
|
||||
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") {
|
||||
function cssAttr(value) {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
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 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;
|
||||
function nestedMatrixHtml(rows, nameKey) {
|
||||
const cols = matrixColumns(rows);
|
||||
if (!cols.length) return `<pre class="play-table-json">${esc(formatTableValue(rows))}</pre>`;
|
||||
const head = cols.map((col) => `<th>${esc(col)}</th>`).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 `<td>${nestedValueHtml(nested, 1)}</td>`;
|
||||
}
|
||||
if (typeof prev === "number") {
|
||||
const n = Number(text);
|
||||
return Number.isFinite(n) ? n : prev;
|
||||
return `<td>${esc(leafText(raw))}</td>`;
|
||||
})
|
||||
.join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="play-table-nested"${nameKey ? ` data-matrix="${esc(nameKey)}"` : ""}><table class="play-nested-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
return text;
|
||||
|
||||
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 `<span class="play-table-val">{}</span>`;
|
||||
const items = entries
|
||||
.map(([key, child]) => {
|
||||
const nested = coerceTableValue(child);
|
||||
if (nested !== null && typeof nested === "object" && depth < 2) {
|
||||
return `<div class="play-nested-pair"><span class="play-nested-key">${esc(key)}</span>${nestedValueHtml(nested, depth + 1)}</div>`;
|
||||
}
|
||||
return `<div class="play-nested-pair"><span class="play-nested-key">${esc(key)}</span><span class="play-nested-leaf">${esc(leafText(nested))}</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="play-table-tree">${items}</div>`;
|
||||
}
|
||||
|
||||
function nestedValueHtml(value, depth = 0) {
|
||||
const normalized = coerceTableValue(value);
|
||||
if (normalized === null || normalized === undefined) {
|
||||
return `<span class="play-table-val">空</span>`;
|
||||
}
|
||||
if (typeof normalized !== "object") {
|
||||
return `<span class="play-table-val">${esc(String(normalized))}</span>`;
|
||||
}
|
||||
if (Array.isArray(normalized)) {
|
||||
if (
|
||||
normalized.length &&
|
||||
normalized.every((item) => item && typeof item === "object" && !Array.isArray(item))
|
||||
) {
|
||||
return nestedMatrixHtml(normalized);
|
||||
}
|
||||
return `<pre class="play-table-json">${esc(JSON.stringify(normalized, null, 2))}</pre>`;
|
||||
}
|
||||
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 `<label class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
<input type="checkbox" class="play-table-input" data-table-input="${esc(row.key)}" ${checked} ${disabled} />
|
||||
<span class="play-table-field-error" data-table-error hidden></span>
|
||||
</label>`;
|
||||
}
|
||||
if (stacked(row)) {
|
||||
return `<label class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
<textarea class="play-table-input${code}" data-table-input="${esc(row.key)}" rows="1" spellcheck="false" autocomplete="off" ${disabled}>${esc(text)}</textarea>
|
||||
<span class="play-table-field-error" data-table-error hidden></span>
|
||||
</label>`;
|
||||
}
|
||||
const inputMode = typeof baseline === "number" ? ' inputmode="decimal"' : "";
|
||||
const placeholder = typeof baseline === "number" ? "数字" : "空";
|
||||
return `<label class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
<input type="text" class="play-table-input" data-table-input="${esc(row.key)}" value="${esc(text)}" placeholder="${placeholder}" spellcheck="false" autocomplete="off"${inputMode} ${disabled} />
|
||||
<span class="play-table-field-error" data-table-error hidden></span>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
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 `<li class="play-table-row is-secret" data-table-key="${esc(row.key)}" data-hidden="1" data-editing="0">
|
||||
<button type="button" class="play-table-reveal" data-table-reveal="${esc(row.key)}" aria-label="显示 ${esc(row.key)}">
|
||||
<span class="play-table-key">${esc(row.key)}</span>
|
||||
<span class="play-table-val">••••</span>
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
const valueHtml = structured
|
||||
? nestedValueHtml(row.value)
|
||||
: `<span class="play-table-val">${esc(formatTableValue(row.value) || "空")}</span>`;
|
||||
return `<li class="play-table-row${stack ? " is-stack" : " is-inline"}${hidden ? " is-secret is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}" data-editing="0">
|
||||
<div class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
${valueHtml}
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function rowHtml(row, { hidden, revealed, busy }) {
|
||||
const shown = !hidden || revealed;
|
||||
if (!shown || !row.editable) return readonlyHtml(row, { hidden, revealed });
|
||||
const stack = stacked(row);
|
||||
return `<li class="play-table-row${stack ? " is-stack" : " is-inline"}${hidden ? " is-secret is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}" data-editing="1">
|
||||
${fieldHtml(row, { busy })}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function tablePanelHtml(table, busy) {
|
||||
if (!table?.rows?.length) {
|
||||
return `<p class="play-aux-empty">本局没有状态表。</p>`;
|
||||
}
|
||||
const visible = table.rows.filter((r) => r.visibility !== "hidden");
|
||||
const hidden = table.rows.filter((r) => r.visibility === "hidden");
|
||||
const vis =
|
||||
visible.length > 0
|
||||
? `<ul class="play-table-list">${visible
|
||||
.map((row) => rowHtml(row, { hidden: false, revealed: true, busy }))
|
||||
.join("")}</ul>`
|
||||
: `<p class="play-aux-empty">没有可见字段。</p>`;
|
||||
const hid = hidden.length
|
||||
? `<details class="play-table-secrets"${secretsOpen ? " open" : ""}>
|
||||
<summary>不可见 ${hidden.length}</summary>
|
||||
<ul class="play-table-list">${hidden
|
||||
.map((row) =>
|
||||
rowHtml(row, {
|
||||
hidden: true,
|
||||
revealed: revealedHidden.has(row.key),
|
||||
busy,
|
||||
}),
|
||||
)
|
||||
.join("")}</ul>
|
||||
</details>`
|
||||
: "";
|
||||
const editable = table.rows.some((row) => row.editable);
|
||||
const bar = editable
|
||||
? `<div class="play-table-bar" hidden>
|
||||
<span class="play-table-bar-note" data-table-dirty-note></span>
|
||||
<button type="button" class="btn-sm" data-table-revert>还原</button>
|
||||
<button type="button" class="btn-sm btn-primary" data-table-save>保存</button>
|
||||
</div>`
|
||||
: "";
|
||||
return `<div class="play-table" data-table-tag="${esc(table.tag)}">${vis}${hid}${bar}</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<form class="play-table-edit" data-table-edit="${esc(row.key)}">
|
||||
<input type="text" name="value" value="${esc(formatValue(row.value))}" ${busy ? "disabled" : ""} />
|
||||
<button type="submit" class="btn-sm btn-primary" ${busy ? "disabled" : ""}>保存</button>
|
||||
<button type="button" class="btn-sm" data-table-cancel>取消</button>
|
||||
</form>`
|
||||
: "";
|
||||
const action =
|
||||
shown && row.editable && !editing
|
||||
? `<button type="button" class="play-table-fix" data-table-fix="${esc(row.key)}" ${busy ? "disabled" : ""}>改</button>`
|
||||
: "";
|
||||
return `<li class="play-table-row${hidden ? " is-secret" : ""}${revealed ? " is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}">
|
||||
<button type="button" class="play-table-hit" data-table-reveal="${esc(row.key)}" ${hidden && !revealed ? "" : hidden ? "" : "tabindex='-1'"}>
|
||||
<span class="play-table-key">${esc(row.key)}</span>
|
||||
<span class="play-table-val">${esc(val)}</span>
|
||||
</button>
|
||||
${action}
|
||||
${edit}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function tablePanelHtml(table, busy) {
|
||||
if (!table?.rows?.length) {
|
||||
return `<p class="play-aux-empty">本局没有状态表。</p>`;
|
||||
}
|
||||
const visible = table.rows.filter((r) => r.visibility !== "hidden");
|
||||
const hidden = table.rows.filter((r) => r.visibility === "hidden");
|
||||
const vis =
|
||||
visible.length > 0
|
||||
? `<ul class="play-table-list">${visible
|
||||
.map((row) =>
|
||||
rowHtml(row, {
|
||||
hidden: false,
|
||||
revealed: true,
|
||||
editing: editingKey === row.key,
|
||||
busy,
|
||||
}),
|
||||
)
|
||||
.join("")}</ul>`
|
||||
: `<p class="play-aux-empty">没有可见字段。</p>`;
|
||||
const hid = hidden.length
|
||||
? `<details class="play-table-secrets">
|
||||
<summary>不可见 ${hidden.length}</summary>
|
||||
<ul class="play-table-list">${hidden
|
||||
.map((row) =>
|
||||
rowHtml(row, {
|
||||
hidden: true,
|
||||
revealed: revealedHidden.has(row.key),
|
||||
editing: editingKey === row.key,
|
||||
busy,
|
||||
}),
|
||||
)
|
||||
.join("")}</ul>
|
||||
</details>`
|
||||
: "";
|
||||
return `<div class="play-table" data-table-tag="${esc(table.tag)}">${vis}${hid}</div>`;
|
||||
}
|
||||
|
||||
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)) {
|
||||
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();
|
||||
});
|
||||
root.querySelectorAll("[data-table-cancel]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
editingKey = null;
|
||||
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-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,
|
||||
return;
|
||||
}
|
||||
const lines = pending.map(
|
||||
(item) =>
|
||||
`${item.row.key}:${previewChange(item.row.value)} → ${previewChange(item.value)}`,
|
||||
);
|
||||
editingKey = null;
|
||||
handlers.onPatchTable?.(table.tag, [
|
||||
{ key, value: next, expectedRev: row.rev },
|
||||
]);
|
||||
});
|
||||
});
|
||||
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) {
|
||||
|
||||
+393
-8
@@ -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 `<div class="present-frame-host" data-present-id="${safe}"></div>`;
|
||||
const h = Number(minHeight);
|
||||
const style = Number.isFinite(h) && h > 8 ? ` style="min-height:${Math.ceil(h)}px"` : "";
|
||||
return `<div class="present-frame-host" data-present-id="${safe}"${style}></div>`;
|
||||
}
|
||||
|
||||
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 `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; script-src 'unsafe-inline';">
|
||||
<style>html,body{margin:0}</style>
|
||||
<style>${cssSafe}</style>
|
||||
<style>html,body{margin:0!important;height:auto!important;min-height:0!important;max-height:none!important;overflow:hidden!important}[data-block] p,article p,#body p,#piece p{margin:0 0 .75em}[data-block] p:last-child,article p:last-child,#body p:last-child,#piece p:last-child{margin-bottom:0}[data-block] table.present-data-table{width:100%;border-collapse:collapse;font-size:inherit;margin:0 0 .75em}[data-block] table.present-data-table th,[data-block] table.present-data-table td{border:1px solid color-mix(in srgb,currentColor 22%,transparent);padding:.28em .45em;text-align:left;vertical-align:top}[data-block] table.present-data-table th{font-weight:600;opacity:.85}[data-block] .present-kv-nested{margin:.15em 0 .35em .6em;padding-left:.55em;border-left:2px solid color-mix(in srgb,currentColor 18%,transparent);font-size:.95em;white-space:pre-wrap}</style>
|
||||
<script>
|
||||
(function () {
|
||||
var FRAME_ID = ${JSON.stringify(safeId)};
|
||||
var PACKET = ${packetJson};
|
||||
var SLOTS = ${slotsJson};
|
||||
var failed = false;
|
||||
function post(type, extra) {
|
||||
var msg = { source: "present-frame", type: type, id: FRAME_ID };
|
||||
@@ -85,6 +95,8 @@ export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frame
|
||||
}
|
||||
function markdown(raw) {
|
||||
var s = String(raw == null ? "" : raw)
|
||||
.replace(/\\r\\n/g, "\\n")
|
||||
.replace(/\\r/g, "\\n")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
@@ -94,17 +106,364 @@ export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frame
|
||||
return "<p>" + part.replace(/\\n/g, "<br>") + "</p>";
|
||||
}).join("");
|
||||
}
|
||||
function slotType(id) {
|
||||
for (var i = 0; i < SLOTS.length; i++) {
|
||||
if (SLOTS[i] && SLOTS[i].id === id && SLOTS[i].type) return SLOTS[i].type;
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
function normalizeType(type) {
|
||||
if (type === "safe_markdown") return "markdown";
|
||||
return type || "text";
|
||||
}
|
||||
function isProgramOwned(type) {
|
||||
var t = normalizeType(type);
|
||||
return t === "markdown" || t === "text" || t === "list" || t === "kv" || t === "table" || t === "topology";
|
||||
}
|
||||
function isEmpty(value) {
|
||||
if (value == null) return true;
|
||||
if (typeof value === "string") return !value.trim();
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (typeof value === "object") return Object.keys(value).length === 0;
|
||||
return false;
|
||||
}
|
||||
function listItemText(item) {
|
||||
if (item == null) return "";
|
||||
if (typeof item === "string") {
|
||||
var s = item.trim();
|
||||
if (s.charAt(0) === "{" && s.indexOf('"text"') !== -1) {
|
||||
try {
|
||||
var parsed = JSON.parse(s);
|
||||
if (parsed && typeof parsed === "object") return listItemText(parsed);
|
||||
} catch (err) { /* keep raw */ }
|
||||
}
|
||||
return item;
|
||||
}
|
||||
if (typeof item === "object") {
|
||||
if (typeof item.text === "string") return item.text;
|
||||
if (typeof item.label === "string") return item.label;
|
||||
if (typeof item.title === "string") return item.title;
|
||||
if (typeof item.content === "string") return item.content;
|
||||
if (typeof item.name === "string" && typeof item.value === "string") {
|
||||
return item.name + ":" + item.value;
|
||||
}
|
||||
}
|
||||
return typeof item === "string" ? item : JSON.stringify(item);
|
||||
}
|
||||
function isBlockId(id) {
|
||||
if (!id || typeof id !== "string") return false;
|
||||
if (id.length > 128) return false;
|
||||
if (/["'<>\\\\]|[\\x00-\\x1f]/.test(id)) return false;
|
||||
return true;
|
||||
}
|
||||
function blockSelector(id) {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return '[data-block="' + CSS.escape(id) + '"]';
|
||||
}
|
||||
return '[data-block="' + String(id).replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"') + '"]';
|
||||
}
|
||||
function tryParseJson(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
var t = value.trim();
|
||||
if (!t) return value;
|
||||
var start = t.charAt(0);
|
||||
var end = t.charAt(t.length - 1);
|
||||
if (!((start === "{" && end === "}") || (start === "[" && end === "]"))) return value;
|
||||
try { return JSON.parse(t); } catch (err) { return value; }
|
||||
}
|
||||
function cellText(cell) {
|
||||
if (cell == null) return "";
|
||||
if (typeof cell === "string" || typeof cell === "number" || typeof cell === "boolean") {
|
||||
return String(cell);
|
||||
}
|
||||
try { return JSON.stringify(cell); } catch (err) { return String(cell); }
|
||||
}
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function writeDataTable(el, rows, columns) {
|
||||
if (!rows || !rows.length) return false;
|
||||
var cols = columns && columns.length ? columns.slice() : [];
|
||||
if (!cols.length) {
|
||||
var seen = {};
|
||||
rows.forEach(function (row) {
|
||||
if (!isPlainObject(row)) return;
|
||||
Object.keys(row).forEach(function (key) {
|
||||
if (!seen[key]) {
|
||||
seen[key] = true;
|
||||
cols.push(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!cols.length) return false;
|
||||
var table = document.createElement("table");
|
||||
table.className = "present-data-table";
|
||||
var thead = document.createElement("thead");
|
||||
var headRow = document.createElement("tr");
|
||||
cols.forEach(function (col) {
|
||||
var th = document.createElement("th");
|
||||
th.textContent = col;
|
||||
headRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
var tbody = document.createElement("tbody");
|
||||
rows.forEach(function (row) {
|
||||
var tr = document.createElement("tr");
|
||||
cols.forEach(function (col) {
|
||||
var td = document.createElement("td");
|
||||
td.textContent = isPlainObject(row) ? cellText(row[col]) : cellText(row);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
el.appendChild(table);
|
||||
return true;
|
||||
}
|
||||
function matrixMapToRows(map) {
|
||||
return Object.keys(map).map(function (name) {
|
||||
var stats = map[name];
|
||||
if (isPlainObject(stats)) {
|
||||
var row = { name: name };
|
||||
Object.keys(stats).forEach(function (key) { row[key] = stats[key]; });
|
||||
return row;
|
||||
}
|
||||
return { name: name, value: stats };
|
||||
});
|
||||
}
|
||||
function writeNestedKv(el, value, depth) {
|
||||
var data = tryParseJson(value);
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length && data.every(isPlainObject)) {
|
||||
writeDataTable(el, data);
|
||||
return;
|
||||
}
|
||||
var pre = document.createElement("pre");
|
||||
pre.className = "present-kv-nested";
|
||||
pre.textContent = JSON.stringify(data, null, 2);
|
||||
el.appendChild(pre);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(data)) {
|
||||
var plain = document.createElement("p");
|
||||
plain.textContent = cellText(data);
|
||||
el.appendChild(plain);
|
||||
return;
|
||||
}
|
||||
var keys = Object.keys(data);
|
||||
var objectEntries = [];
|
||||
var otherEntries = [];
|
||||
keys.forEach(function (key) {
|
||||
var cell = tryParseJson(data[key]);
|
||||
if (isPlainObject(cell)) objectEntries.push([key, cell]);
|
||||
else otherEntries.push([key, cell]);
|
||||
});
|
||||
if (
|
||||
objectEntries.length >= 1 &&
|
||||
objectEntries.length === keys.length &&
|
||||
objectEntries.every(function (pair) { return isPlainObject(pair[1]); })
|
||||
) {
|
||||
writeDataTable(el, matrixMapToRows(data));
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(data.rows)) {
|
||||
writeDataTable(el, data.rows, Array.isArray(data.columns) ? data.columns : null);
|
||||
return;
|
||||
}
|
||||
otherEntries.concat(objectEntries).forEach(function (pair) {
|
||||
var key = pair[0];
|
||||
var cell = pair[1];
|
||||
if (isPlainObject(cell) || Array.isArray(cell)) {
|
||||
var label = document.createElement("p");
|
||||
label.textContent = key + ":";
|
||||
el.appendChild(label);
|
||||
if (depth < 3) {
|
||||
var nest = document.createElement("div");
|
||||
nest.className = "present-kv-nested";
|
||||
writeNestedKv(nest, cell, depth + 1);
|
||||
el.appendChild(nest);
|
||||
} else {
|
||||
var deep = document.createElement("pre");
|
||||
deep.className = "present-kv-nested";
|
||||
deep.textContent = JSON.stringify(cell, null, 2);
|
||||
el.appendChild(deep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var row = document.createElement("p");
|
||||
row.textContent = key + ":" + cellText(cell);
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
function writeBlock(el, value, type) {
|
||||
var t = normalizeType(type);
|
||||
if (t === "list" && Array.isArray(value)) {
|
||||
var ul = document.createElement("ul");
|
||||
value.forEach(function (item) {
|
||||
var li = document.createElement("li");
|
||||
li.textContent = listItemText(item);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
return;
|
||||
}
|
||||
if (t === "table") {
|
||||
var tableValue = tryParseJson(value);
|
||||
if (Array.isArray(tableValue)) {
|
||||
if (tableValue.length && tableValue.every(isPlainObject)) {
|
||||
writeDataTable(el, tableValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isPlainObject(tableValue)) {
|
||||
if (Array.isArray(tableValue.rows)) {
|
||||
writeDataTable(
|
||||
el,
|
||||
tableValue.rows,
|
||||
Array.isArray(tableValue.columns) ? tableValue.columns : null
|
||||
);
|
||||
return;
|
||||
}
|
||||
var onlyKeys = Object.keys(tableValue);
|
||||
if (onlyKeys.length === 1) {
|
||||
var only = tryParseJson(tableValue[onlyKeys[0]]);
|
||||
if (Array.isArray(only) && only.length && only.every(isPlainObject)) {
|
||||
writeDataTable(el, only);
|
||||
return;
|
||||
}
|
||||
if (isPlainObject(only)) {
|
||||
var nestedKeys = Object.keys(only);
|
||||
if (nestedKeys.length && nestedKeys.every(function (k) { return isPlainObject(only[k]); })) {
|
||||
writeDataTable(el, matrixMapToRows(only));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeNestedKv(el, tableValue, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((t === "kv" || t === "topology") && value && typeof value === "object") {
|
||||
writeNestedKv(el, value, 0);
|
||||
return;
|
||||
}
|
||||
if (t === "markdown" || t === "text" || typeof value === "string") {
|
||||
el.innerHTML = markdown(typeof value === "string" ? value : JSON.stringify(value));
|
||||
return;
|
||||
}
|
||||
el.textContent = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
function alreadyVisible(value) {
|
||||
var sample = "";
|
||||
if (typeof value === "string") sample = value.trim().slice(0, 24);
|
||||
else if (Array.isArray(value) && typeof value[0] === "string") sample = value[0].trim().slice(0, 24);
|
||||
if (sample.length < 8) return false;
|
||||
return (document.body && document.body.innerText ? document.body.innerText : "").indexOf(sample) !== -1;
|
||||
}
|
||||
function fillSlots() {
|
||||
var blocks = PACKET && PACKET.blocks ? PACKET.blocks : {};
|
||||
Object.keys(blocks).forEach(function (id) {
|
||||
if (!isBlockId(id)) return;
|
||||
var value = blocks[id];
|
||||
var type = slotType(id);
|
||||
var el = document.querySelector(blockSelector(id)) || document.getElementById(id);
|
||||
if (el) {
|
||||
if (isEmpty(value)) return;
|
||||
var empty = !el.textContent.trim() && !el.children.length;
|
||||
if (isProgramOwned(type) || empty) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
writeBlock(el, value, type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isEmpty(value) || alreadyVisible(value)) return;
|
||||
var host = document.createElement("section");
|
||||
host.setAttribute("data-block", id);
|
||||
writeBlock(host, value, type);
|
||||
document.body.appendChild(host);
|
||||
});
|
||||
}
|
||||
var renderCb = null;
|
||||
function pathOf(el) {
|
||||
var parts = [];
|
||||
var node = el;
|
||||
while (node && node !== document.documentElement) {
|
||||
var parent = node.parentElement;
|
||||
if (!parent) break;
|
||||
parts.push(Array.prototype.indexOf.call(parent.children, node));
|
||||
node = parent;
|
||||
}
|
||||
return parts.reverse().join("/");
|
||||
}
|
||||
function nodeAt(path) {
|
||||
var parts = String(path || "").split("/");
|
||||
var node = document.documentElement;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (!node || !node.children) return null;
|
||||
node = node.children[Number(parts[i])];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function captureScrolls() {
|
||||
var list = [];
|
||||
var nodes = document.querySelectorAll("*");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i];
|
||||
if (!n.scrollTop && !n.scrollLeft) continue;
|
||||
list.push({ el: n, path: pathOf(n), top: n.scrollTop, left: n.scrollLeft });
|
||||
}
|
||||
list.push({
|
||||
path: "window",
|
||||
top: window.scrollY || 0,
|
||||
left: window.scrollX || 0
|
||||
});
|
||||
return list;
|
||||
}
|
||||
function restoreScrolls(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var row = list[i];
|
||||
if (row.path === "window") {
|
||||
window.scrollTo(row.left || 0, row.top || 0);
|
||||
continue;
|
||||
}
|
||||
var el = row.el && row.el.isConnected ? row.el : nodeAt(row.path);
|
||||
if (!el) continue;
|
||||
el.scrollTop = row.top;
|
||||
el.scrollLeft = row.left;
|
||||
}
|
||||
}
|
||||
function applyPacket(next) {
|
||||
var snaps = captureScrolls();
|
||||
if (next && next.blocks) PACKET = next;
|
||||
if (renderCb) {
|
||||
try { renderCb(PACKET); }
|
||||
catch (err) { reportError(err); }
|
||||
}
|
||||
try { fillSlots(); }
|
||||
catch (err) { reportError(err); }
|
||||
restoreScrolls(snaps);
|
||||
}
|
||||
window.present = {
|
||||
onData: function (cb) {
|
||||
renderCb = cb;
|
||||
try { cb(PACKET); }
|
||||
catch (err) { reportError(err); }
|
||||
},
|
||||
markdown: markdown
|
||||
};
|
||||
window.addEventListener("message", function (event) {
|
||||
var data = event.data;
|
||||
if (!data || data.source !== "present-host" || data.type !== "packet") return;
|
||||
applyPacket(data.packet);
|
||||
});
|
||||
window.addEventListener("error", function (e) {
|
||||
reportError(e.error || e.message || "脚本错误");
|
||||
});
|
||||
window.present.__seal = function () {
|
||||
try { fillSlots(); }
|
||||
catch (err) { reportError(err); }
|
||||
if (failed) return;
|
||||
post("ready");
|
||||
var sendHeight = function () {
|
||||
@@ -147,12 +506,18 @@ function mountOne(host, id, payload) {
|
||||
frame.className = "present-preview-frame";
|
||||
frame.setAttribute("sandbox", PRESENT_FRAME_SANDBOX);
|
||||
frame.setAttribute("referrerpolicy", "no-referrer");
|
||||
frame.setAttribute("scrolling", "no");
|
||||
frame.title = "版式预览";
|
||||
const hinted = Number.parseFloat(host.style.minHeight);
|
||||
if (Number.isFinite(hinted) && hinted > 8) {
|
||||
frame.style.height = `${Math.ceil(hinted)}px`;
|
||||
}
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: payload.bundle.html,
|
||||
css: payload.bundle.css,
|
||||
js: payload.bundle.js,
|
||||
packet: payload.packet,
|
||||
slots: payload.slots,
|
||||
frameId: id,
|
||||
});
|
||||
const section = host.closest(".present-preview");
|
||||
@@ -191,7 +556,13 @@ function mountOne(host, id, payload) {
|
||||
if (data.type === "height" && !frame.hidden) {
|
||||
const height = Number(data.height);
|
||||
if (height > 0) {
|
||||
frame.style.height = `${Math.min(960, Math.max(180, Math.ceil(height)))}px`;
|
||||
const next = Math.max(1, Math.ceil(height));
|
||||
const prev = Math.ceil(Number.parseFloat(frame.style.height) || 0);
|
||||
frame.style.height = `${next}px`;
|
||||
host.style.minHeight = "";
|
||||
if (Math.abs(next - prev) >= 2) {
|
||||
frame.dispatchEvent(new CustomEvent("present-frame-resize", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.type === "ready") {
|
||||
@@ -214,6 +585,20 @@ function mountOne(host, id, payload) {
|
||||
frame.srcdoc = srcdoc;
|
||||
}
|
||||
|
||||
export function updatePresentFramePacket(frame, packet) {
|
||||
const win = frame?.contentWindow;
|
||||
if (!win) return false;
|
||||
try {
|
||||
win.postMessage(
|
||||
{ source: "present-host", type: "packet", packet: previewPacket(packet) },
|
||||
"*",
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function mountPresentFrames(root) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return;
|
||||
root.querySelectorAll(".present-frame-host[data-present-id]").forEach((host) => {
|
||||
|
||||
@@ -101,8 +101,10 @@ export function stripPresentSourceFences(raw) {
|
||||
function normalizeBlocks(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out = {};
|
||||
for (const k of ["monitor", "header", "body", "footer", "aside", "hidden"]) {
|
||||
if (raw[k] !== undefined) out[k] = raw[k];
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === "正文" || key === "监控") continue;
|
||||
if (value == null) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
if (out.body === undefined && raw.正文 !== undefined) out.body = raw.正文;
|
||||
if (out.monitor === undefined && raw.监控 !== undefined) out.monitor = raw.监控;
|
||||
|
||||
Reference in New Issue
Block a user