把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。 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;
|
||||
|
||||
+110
-21
@@ -140,6 +140,10 @@ import {
|
||||
isDictateModeValue,
|
||||
} from "../dictate/types.js";
|
||||
import { applyDictatePlayBind } from "../dictate/play-bind.js";
|
||||
import {
|
||||
applyMaintenanceStrategyToPlaySlots,
|
||||
resolveMaintenanceStrategyFromBoard,
|
||||
} from "../skills/maintenance-strategy.js";
|
||||
import {
|
||||
dictateProductFamily,
|
||||
mergeGenerationRulesArtifacts,
|
||||
@@ -148,6 +152,11 @@ import {
|
||||
VARIABLE_CATALOG_TAG,
|
||||
seedVariablesFromCatalog,
|
||||
} from "../skills/variable-catalog.js";
|
||||
import {
|
||||
VARIABLE_DESIGN_TAG,
|
||||
syncVariableDesignToRuntime,
|
||||
writeVariableDesignSync,
|
||||
} from "../skills/variable-design-compile.js";
|
||||
import {
|
||||
VALUE_MAP_TAG,
|
||||
reprojectValueMaps,
|
||||
@@ -164,6 +173,11 @@ import {
|
||||
parseShellAdaptationFromReplyFormat,
|
||||
presentOutputFromFallback,
|
||||
} from "../skills/present-packet.js";
|
||||
import { parseReplyDataContract } from "../skills/reply-contract.js";
|
||||
import {
|
||||
mergePresentWithProjections,
|
||||
projectPresentBlocks,
|
||||
} from "../skills/present-project.js";
|
||||
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
||||
import {
|
||||
executeChance,
|
||||
@@ -2049,21 +2063,45 @@ export class PhaseRuntime {
|
||||
// 偏好/文风改由节点产物(设计.用户需求 / 设计.叙事指南*)挂载,不再全量自动摊库
|
||||
if (this.isDictateIntakeMode()) {
|
||||
const bound = applyDictatePlayBind(this.blackboard);
|
||||
if (bound.boundTags.length) {
|
||||
if (!bound.skipped && bound.boundTags.length) {
|
||||
this.onMessage(
|
||||
`[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
|
||||
);
|
||||
} else {
|
||||
} else if (!bound.skipped) {
|
||||
this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)");
|
||||
}
|
||||
const strategyRaw = this.blackboard.getContentByTag("设计.维护策略")?.trim();
|
||||
if (strategyRaw) {
|
||||
try {
|
||||
const s = JSON.parse(strategyRaw) as {
|
||||
advice?: string;
|
||||
auditor?: boolean;
|
||||
inline?: boolean;
|
||||
};
|
||||
if (s.auditor) {
|
||||
this.onMessage(
|
||||
`[维护策略] 旁观副 LLM 填表(play_slots.auditor=true)`,
|
||||
);
|
||||
} else if (s.advice && s.advice !== "none") {
|
||||
this.onMessage(`[维护策略] ${s.advice} → 旁观未开`);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.seedVariablesAndReproject("opening-setup");
|
||||
} else {
|
||||
const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim();
|
||||
const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim();
|
||||
if (!finalTag) {
|
||||
const strategy = resolveMaintenanceStrategyFromBoard(this.blackboard);
|
||||
const play_slots = applyMaintenanceStrategyToPlaySlots(
|
||||
defaultPlaySlots(),
|
||||
strategy,
|
||||
);
|
||||
const spec = draft?.trim()
|
||||
? draft
|
||||
: JSON.stringify({ play_slots: defaultPlaySlots() });
|
||||
: JSON.stringify({ play_slots }, null, 2);
|
||||
this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup");
|
||||
}
|
||||
}
|
||||
@@ -2077,36 +2115,84 @@ export class PhaseRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
/** 目录初值 → 变量.当前;再按 设计.变量映射 重写投影 tag */
|
||||
/** 变量设计 → 目录 → 变量.当前;再按 设计.变量映射 重写投影 tag */
|
||||
private seedVariablesAndReproject(actor: string): void {
|
||||
const seeded = seedVariablesFromCatalog({
|
||||
const designSync = syncVariableDesignToRuntime({
|
||||
designRaw: this.blackboard.getContentByTag(VARIABLE_DESIGN_TAG),
|
||||
catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG),
|
||||
currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
||||
initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
|
||||
actor: `system:${actor}`,
|
||||
});
|
||||
if (seeded.initial) {
|
||||
this.blackboard.write({
|
||||
tag: OPENING_INITIAL_VARS_TAG,
|
||||
content: seeded.initial,
|
||||
source: actor,
|
||||
if (designSync.catalog || designSync.current || designSync.initial) {
|
||||
writeVariableDesignSync(designSync, (tag, content) => {
|
||||
this.blackboard.write({ tag, content, source: actor });
|
||||
});
|
||||
}
|
||||
if (seeded.current) {
|
||||
this.blackboard.write({
|
||||
tag: OPENING_CURRENT_VARS_TAG,
|
||||
content: seeded.current,
|
||||
source: actor,
|
||||
});
|
||||
if (seeded.seededKeys.length) {
|
||||
if (designSync.added.length) {
|
||||
this.onMessage(
|
||||
`[变量] 已种初值:${seeded.seededKeys.join("、")}`,
|
||||
`[变量] 已从变量设计同步目录:${designSync.added.join("、")}`,
|
||||
);
|
||||
}
|
||||
if (designSync.current && designSync.seededKeys.length) {
|
||||
this.onMessage(
|
||||
`[变量] 已种初值:${designSync.seededKeys.join("、")}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const seeded = seedVariablesFromCatalog({
|
||||
catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG),
|
||||
currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
||||
initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
|
||||
actor: `system:${actor}`,
|
||||
});
|
||||
if (seeded.initial) {
|
||||
this.blackboard.write({
|
||||
tag: OPENING_INITIAL_VARS_TAG,
|
||||
content: seeded.initial,
|
||||
source: actor,
|
||||
});
|
||||
}
|
||||
if (seeded.current) {
|
||||
this.blackboard.write({
|
||||
tag: OPENING_CURRENT_VARS_TAG,
|
||||
content: seeded.current,
|
||||
source: actor,
|
||||
});
|
||||
if (seeded.seededKeys.length) {
|
||||
this.onMessage(
|
||||
`[变量] 已种初值:${seeded.seededKeys.join("、")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.applyValueMapProjections(actor);
|
||||
}
|
||||
|
||||
/** 把 program_projection 块从黑板灌进 present 正文 */
|
||||
private enrichPresentWithProjections(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
// 纯 Markdown 终稿保持原文;投影由视图 presentationProjection 灌进壳
|
||||
if (!trimmed.startsWith("{")) return content;
|
||||
const replyFormatRaw =
|
||||
this.blackboard.getContentByTag("设计.正文组成") ??
|
||||
this.blackboard.getContentByTag("设计.0层正文组成") ??
|
||||
this.blackboard.getContentByTag("设计.回复格式");
|
||||
const contract = parseReplyDataContract(replyFormatRaw);
|
||||
if (!contract?.programBlocks.length) return content;
|
||||
const projections = projectPresentBlocks({
|
||||
contract,
|
||||
statusBarRaw:
|
||||
this.blackboard.getContentByTag("设计.监控栏") ??
|
||||
this.blackboard.getContentByTag("设计.回复呈现"),
|
||||
currentVarsRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
||||
topologyRaw: this.blackboard.getContentByTag("运行.拓扑"),
|
||||
readTag: (tag) => this.blackboard.getContentByTag(tag),
|
||||
});
|
||||
if (!Object.keys(projections).length) return content;
|
||||
const shell =
|
||||
parseShellAdaptationFromReplyFormat(replyFormatRaw)?.shell_id ?? "prose";
|
||||
return mergePresentWithProjections(content, projections, shell);
|
||||
}
|
||||
|
||||
/** 按当前真值持续投影映射 tag(非 once) */
|
||||
private applyValueMapProjections(workerId: string): void {
|
||||
if (!this.blackboard.getContentByTag(VALUE_MAP_TAG)?.trim()) return;
|
||||
@@ -2589,10 +2675,13 @@ export class PhaseRuntime {
|
||||
): string {
|
||||
const tableTags = new Set(["运行.初始变量", "变量.当前"]);
|
||||
let toWrite = content;
|
||||
if (tag === "输出.用户展示" || tag === "输出.开场白") {
|
||||
toWrite = this.enrichPresentWithProjections(toWrite);
|
||||
}
|
||||
let prevDoc: TableDoc | null = null;
|
||||
let nextDoc: TableDoc | null = null;
|
||||
if (tableTags.has(tag)) {
|
||||
const patch = parseTableDoc(content);
|
||||
const patch = parseTableDoc(toWrite);
|
||||
if (patch) {
|
||||
prevDoc = parseTableDoc(this.blackboard.getContentByTag(tag));
|
||||
const { doc, skipped } = mergeTableCells({
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+63
-31
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
type OpeningProductRow = {
|
||||
tag: string;
|
||||
content: string;
|
||||
order: number;
|
||||
slot: string;
|
||||
};
|
||||
|
||||
const rows: Array<{
|
||||
tag: string;
|
||||
content: string;
|
||||
order: number;
|
||||
}> = [];
|
||||
function listOpeningProductRows(board: Blackboard): OpeningProductRow[] {
|
||||
const rows: OpeningProductRow[] = [];
|
||||
for (const e of board.listTagIndex()) {
|
||||
const family = dictateProductFamily(e.tag);
|
||||
if (
|
||||
@@ -111,26 +95,74 @@ export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
|
||||
typeof rawOrder === "number" && Number.isFinite(rawOrder)
|
||||
? rawOrder
|
||||
: effectiveDictateOrder({ tag: e.tag });
|
||||
rows.push({ tag: e.tag, content, order });
|
||||
rows.push({
|
||||
tag: e.tag,
|
||||
content,
|
||||
order,
|
||||
slot: dictateProductSlot(e.tag),
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => {
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return a.tag.localeCompare(b.tag, "zh");
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function openingsFromRows(
|
||||
board: Blackboard,
|
||||
rows: OpeningProductRow[],
|
||||
): PlayAuxOpening[] {
|
||||
return rows.map((row, index) => {
|
||||
const split = splitOpeningDocument(row.content);
|
||||
const text = split.text.trim() || row.content;
|
||||
const document = split.text.trim() || row.content;
|
||||
return {
|
||||
index,
|
||||
tag: row.tag,
|
||||
slot: dictateProductSlot(row.tag),
|
||||
text: presentReadablePlain(text) || text,
|
||||
slot: row.slot,
|
||||
document,
|
||||
text: presentReadablePlain(document) || document,
|
||||
persona: personaBoundToOpening(board, row.tag, row.content),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 可切换开场。已有 `设计.开场白#场景短码` 时只认这些带槽位的卡,
|
||||
* 裸 `设计.开场白` 与整包 `设计.开场白与开场变量` 不进切换列表。
|
||||
* 还没有槽位卡时,才回退旧整包候选或裸基名。
|
||||
*/
|
||||
export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
|
||||
const rows = listOpeningProductRows(board);
|
||||
const slotted = rows.filter((row) => row.slot);
|
||||
if (slotted.length) return openingsFromRows(board, slotted);
|
||||
|
||||
const payload = parseOpeningSealPayload(
|
||||
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
||||
0,
|
||||
);
|
||||
if (payload?.candidates.length) {
|
||||
return payload.candidates.map((text, index) => {
|
||||
const one = parseOpeningSealPayload(
|
||||
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
||||
index,
|
||||
);
|
||||
const tag = `${OPENING_SETUP_ARTIFACT_TAG}#${index + 1}`;
|
||||
const document = text.trim();
|
||||
return {
|
||||
index,
|
||||
tag,
|
||||
slot: String(index + 1),
|
||||
document,
|
||||
text: presentReadablePlain(document) || document,
|
||||
persona: one?.persona ?? payload.persona,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return openingsFromRows(board, rows);
|
||||
}
|
||||
|
||||
export function buildPlayAuxView(
|
||||
board: Blackboard,
|
||||
slots?: Record<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:
|
||||
"创作末尾:优先落库设计.开场白与开场变量;否则现写开场;初值与开场同真相。",
|
||||
"创作末尾:每条开场落库设计.开场白#场景短码;没有变量的卡不写初值。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+41
-16
@@ -21,6 +21,12 @@ import {
|
||||
} from "../parse/json-doc.js";
|
||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
||||
import { isPlayLayerActive } from "../skills/play-turn.js";
|
||||
import { isNarratorEnabled } from "../skills/play-slots.js";
|
||||
import {
|
||||
contractOutputInstruction,
|
||||
parseReplyDataContract,
|
||||
presentSchemaFromContract,
|
||||
} from "../skills/reply-contract.js";
|
||||
import {
|
||||
PRESENT_TAG,
|
||||
PRESENT_JSON_SCHEMA,
|
||||
@@ -829,21 +835,33 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<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
|
||||
? playGmBodyOutputInstruction()
|
||||
: playPresent && playShell
|
||||
? playPresentOutputInstruction(playShell)
|
||||
: WORKER_OUTPUT_INSTRUCTION) +
|
||||
(contractSchema && replyContract && !useGmHarness
|
||||
? contractOutputInstruction(replyContract)
|
||||
: playGmBody
|
||||
? playGmBodyOutputInstruction()
|
||||
: playPresent && playShell
|
||||
? playPresentOutputInstruction(playShell)
|
||||
: WORKER_OUTPUT_INSTRUCTION) +
|
||||
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
||||
|
||||
// 任务 messages 即可;preset 夹心由 wrapLlmForSession / PresetLlmProvider 统一装配
|
||||
@@ -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 : {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
name: playPresent ? "submit_present_packet" : "submit_worker_result",
|
||||
schemaIsLoose: !playPresent,
|
||||
schema: contractSchema
|
||||
? contractSchema.schema
|
||||
: playPresent
|
||||
? PRESENT_JSON_SCHEMA
|
||||
: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user