新建作品改为直接进入对话落盘,按本局创作方案写能力,并把楼层呈现和零层卡分开。

输入栏同时可以按分组切换模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 12:00:46 +08:00
co-authored by Cursor
parent 39fe2732fb
commit c2c9203fb0
87 changed files with 8413 additions and 3180 deletions
+381
View File
@@ -0,0 +1,381 @@
import {
catalogModulesForIntake,
type ModuleCatalog,
type ModuleCatalogEntry,
} from "../skills/creation-flow.js";
import type { DictateProduct } from "./types.js";
export const CREATION_PLAN_TAG = "设计.本局创作方案";
export const CREATION_PLAN_SCHEMA = "creation-plan.v1";
export const CREATION_PLAN_TIERS = [
"必须",
"有必要",
"有一定效果",
"没有意义",
] as const;
export type CreationPlanTier = (typeof CREATION_PLAN_TIERS)[number];
export type CreationPresentationMode = "message" | "zero_layer";
const PRESENTATION_MODULE_IDS: Record<
CreationPresentationMode,
readonly string[]
> = {
message: ["status-bar", "reply-format", "opening-setup"],
zero_layer: [
"zero-layer-status",
"zero-layer-reply-format",
"zero-layer-opening-setup",
],
};
const ALL_PRESENTATION_MODULE_IDS = new Set(
Object.values(PRESENTATION_MODULE_IDS).flat(),
);
export type CreationPlanItem = {
module_id: string;
tier: CreationPlanTier;
why: string;
missing: string;
instances: unknown[];
};
export type CreationPlan = {
schema: typeof CREATION_PLAN_SCHEMA;
技能: string;
brief: string;
/** 缺省兼容旧方案,按普通消息楼层处理。 */
presentation_mode?: CreationPresentationMode;
recipe: {
primary: string;
secondary_traits: string[];
method: string[];
references: string[];
};
items: CreationPlanItem[];
};
/**
* 本局方案只覆盖对话落盘中的内容能力。旧 DAG 收成节点、机械映射节点和方案自身不参与。
* 新内容能力默认纳入;程序步(auto)默认排除。
*/
export const CREATION_PLAN_EXCLUDED_MODULE_IDS = new Set([
"creation-plan",
"narrative",
"variable-context",
"context-order",
"worker-spec",
"refine",
]);
export function creationPlanModules(
catalog: ModuleCatalog | null | undefined,
presentationMode: CreationPresentationMode = "message",
): ModuleCatalogEntry[] {
if (!catalog) return [];
const selectedPresentation = new Set(
PRESENTATION_MODULE_IDS[presentationMode],
);
return catalogModulesForIntake(catalog, "dictate").filter(
(module) =>
!module.auto &&
!CREATION_PLAN_EXCLUDED_MODULE_IDS.has(module.id) &&
(!ALL_PRESENTATION_MODULE_IDS.has(module.id) ||
selectedPresentation.has(module.id)),
);
}
function presentationModeOf(
plan: Pick<CreationPlan, "presentation_mode">,
): CreationPresentationMode {
return plan.presentation_mode === "zero_layer" ? "zero_layer" : "message";
}
export function parseCreationPlan(raw: string | undefined | null): CreationPlan | null {
if (!raw?.trim()) return null;
try {
const doc = JSON.parse(raw) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const row = doc as Record<string, unknown>;
if (row.schema !== CREATION_PLAN_SCHEMA || !Array.isArray(row.items)) {
return null;
}
const recipe = row.recipe;
if (
typeof row.技能 !== "string" ||
typeof row.brief !== "string" ||
(row.presentation_mode !== undefined &&
row.presentation_mode !== "message" &&
row.presentation_mode !== "zero_layer") ||
!recipe ||
typeof recipe !== "object" ||
Array.isArray(recipe)
) {
return null;
}
const recipeRow = recipe as Record<string, unknown>;
if (
typeof recipeRow.primary !== "string" ||
!isStringArray(recipeRow.secondary_traits) ||
!isStringArray(recipeRow.method) ||
!isStringArray(recipeRow.references)
) {
return null;
}
const validItems = row.items.every((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const value = item as Record<string, unknown>;
return (
typeof value.module_id === "string" &&
CREATION_PLAN_TIERS.includes(value.tier as CreationPlanTier) &&
typeof value.why === "string" &&
typeof value.missing === "string" &&
Array.isArray(value.instances)
);
});
if (!validItems) return null;
return row as CreationPlan;
} catch {
return null;
}
}
export type CreationPlanValidation = {
ok: boolean;
missingIds: string[];
duplicateIds: string[];
unknownIds: string[];
error?: string;
plan?: CreationPlan;
};
export function validateCreationPlan(
raw: string,
catalog: ModuleCatalog | null | undefined,
): CreationPlanValidation {
const plan = parseCreationPlan(raw);
if (!plan) {
return {
ok: false,
missingIds: creationPlanModules(catalog).map((m) => m.id),
duplicateIds: [],
unknownIds: [],
error: `本局创作方案必须是合法的 ${CREATION_PLAN_SCHEMA} JSON,且含 items 数组`,
};
}
const presentationMode = presentationModeOf(plan);
const required = creationPlanModules(catalog, presentationMode).map((m) => m.id);
if (required.length === 0) {
return {
ok: false,
missingIds: [],
duplicateIds: [],
unknownIds: [],
error: "能力目录不可用,无法校验本局创作方案",
};
}
const ids = plan.items
.map((item) => String(item?.module_id ?? "").trim())
.filter(Boolean);
const counts = new Map<string, number>();
for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
const allowed = new Set(required);
const missingIds = required.filter((id) => !counts.has(id));
const duplicateIds = [...counts]
.filter(([, count]) => count > 1)
.map(([id]) => id);
const unknownIds = [...counts.keys()].filter((id) => !allowed.has(id));
const invalidTierIds = plan.items
.filter(
(item) =>
!CREATION_PLAN_TIERS.includes(item?.tier as CreationPlanTier),
)
.map((item) => String(item?.module_id ?? "(空 id)"));
const problems: string[] = [];
if (missingIds.length) problems.push(`漏项:${missingIds.join("、")}`);
if (duplicateIds.length) problems.push(`重复:${duplicateIds.join("、")}`);
if (unknownIds.length) problems.push(`未知:${unknownIds.join("、")}`);
if (invalidTierIds.length) {
problems.push(`档位无效:${invalidTierIds.join("、")}`);
}
return {
ok: problems.length === 0,
missingIds,
duplicateIds,
unknownIds,
...(problems.length ? { error: `本局创作方案能力覆盖校验失败;${problems.join(";")}` } : {}),
plan,
};
}
export function formatCreationPlanCapabilityCatalog(
catalog: ModuleCatalog | null | undefined,
): string {
const allContent = catalogModulesForIntake(catalog ?? { modules: [] }, "dictate").filter(
(module) =>
!module.auto && !CREATION_PLAN_EXCLUDED_MODULE_IDS.has(module.id),
);
const formatModule = (module: ModuleCatalogEntry) => {
const boundary = compact(module.boundary ?? "", 180);
return [
`- ${module.id}(${module.name}):${compact(module.declaration, 120)}`,
...(boundary ? [` 边界:${boundary}`] : []),
].join("\n");
};
const commonLines = allContent
.filter((module) => !ALL_PRESENTATION_MODULE_IDS.has(module.id))
.map(formatModule);
const byId = new Map(allContent.map((module) => [module.id, module]));
const modeLines = (mode: CreationPresentationMode) =>
PRESENTATION_MODULE_IDS[mode]
.map((id) => byId.get(id))
.filter((module): module is ModuleCatalogEntry => Boolean(module))
.map(formatModule);
if (commonLines.length === 0) return "";
return [
"【本局创作方案 · 能力目录】",
"先选择 presentation_mode=`message|zero_layer`。items 必须覆盖全部公共能力,并且只覆盖所选呈现模式的三项能力;不得同时选两套。分档看能力对抽出的核心体验和可运行性的贡献:用户没点名但缺了体验会明显变差的,标进「必须」或「有必要」。数组顺序不代表执行顺序。",
"【公共能力】",
commonLines.join("\n"),
"【message · 普通消息楼层】",
modeLines("message").join("\n"),
"【zero_layer · 持久 0 层卡】",
modeLines("zero_layer").join("\n"),
].join("\n");
}
export function formatCreationPlanProgress(params: {
plan: CreationPlan;
catalog: ModuleCatalog | null | undefined;
products: readonly DictateProduct[];
}): string {
const modules = new Map(
creationPlanModules(
params.catalog,
presentationModeOf(params.plan),
).map((module) => [module.id, module]),
);
const productTags = params.products.map((product) => product.tag);
const productByTag = new Map(params.products.map((product) => [product.tag, product]));
const groups = new Map<CreationPlanTier, string[]>(
CREATION_PLAN_TIERS.map((tier) => [tier, []]),
);
for (const item of params.plan.items) {
const module = modules.get(item.module_id);
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
const landedTags = productTags.filter(
(tag) => tag === module.artifact || tag.startsWith(`${module.artifact}#`),
);
const scores = landedTags
.map((tag) => {
const product = productByTag.get(tag);
const metadataScores = product?.selfScore?.dims.map((dim) => dim.score) ?? [];
return metadataScores.length
? Math.min(...metadataScores)
: lowestSelfScore(product?.content);
})
.filter((score): score is number => score !== undefined);
const scoreText = scores.length ? `,最低自评分 ${Math.min(...scores)}` : "";
const state = landedTags.length ? `已落${scoreText}` : "未落";
const missing = item.missing?.trim() ? `;缺口:${item.missing.trim()}` : "";
const lines = [
`- ${item.module_id}(${module.name})〔${state}〕:${item.why}${missing}`,
];
if (item.tier !== "没有意义") {
const when = oneLine(module.when ?? "");
const whenNot = oneLine(module.when_not ?? "");
if (when) lines.push(` 何时用:${when}`);
if (whenNot) lines.push(` 何时不用:${whenNot}`);
}
groups.get(item.tier)!.push(...lines);
}
return [
"【本局创作方案 · 当前决策】",
JSON.stringify(
{
schema: params.plan.schema,
brief: params.plan.brief,
presentation_mode: presentationModeOf(params.plan),
recipe: params.plan.recipe,
},
null,
2,
),
"",
"【能力状态】未落的「必须」和「有必要」是收口前的核对清单,不是本轮待办。前三档附带何时用 / 何时不用,方案改写或补全后仍对照它们决定本轮写哪项;可反复能力已落一条也保留。「没有意义」只列名称,不附使用条件。",
...CREATION_PLAN_TIERS.flatMap((tier) => [
`### ${tier}`,
...(groups.get(tier)?.length ? groups.get(tier)! : ["- (无)"]),
]),
].join("\n");
}
export function requiredCreationPlanGaps(params: {
planRaw: string | undefined;
catalog: ModuleCatalog | null | undefined;
products: readonly DictateProduct[];
}): string[] {
const plan = parseCreationPlan(params.planRaw);
if (!plan) return [];
const modules = new Map(
creationPlanModules(
params.catalog,
presentationModeOf(plan),
).map((module) => [module.id, module]),
);
return plan.items
.filter((item) => item.tier === "必须")
.filter((item) => {
const artifact = modules.get(item.module_id)?.artifact;
return (
!artifact ||
!params.products.some(
(product) =>
product.tag === artifact || product.tag.startsWith(`${artifact}#`),
)
);
})
.map((item) => item.module_id);
}
function lowestSelfScore(raw: string | undefined): number | undefined {
if (!raw?.trim()) return undefined;
try {
const doc = JSON.parse(raw) as Record<string, unknown>;
const modern = (doc.self_score as { dims?: Array<{ score?: unknown }> } | undefined)
?.dims;
const legacy = (doc.自评 as { 维度?: Array<{ 分数?: unknown }> } | undefined)
?.维度;
const values = [
...(modern ?? []).map((dim) => dim.score),
...(legacy ?? []).map((dim) => dim.分数),
]
.map(Number)
.filter(Number.isFinite);
return values.length ? Math.min(...values) : undefined;
} catch {
return undefined;
}
}
function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function compact(text: string, max: number): string {
const line = oneLine(text);
if (line.length <= max) return line;
return `${line.slice(0, max - 1)}…`;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
+200
View File
@@ -0,0 +1,200 @@
/**
* 体验是否锚定:程序按三件打分,不读模型自评 / 完备度。
* 权威字段是产物里的「体验锚定」;缺则回退到美学正文里对应的短句。
*/
import { tryParseJsonDoc } from "../parse/json-doc.js";
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领与交互范式";
export const EXPERIENCE_ANCHOR_RECIPE_NAMES = ["快穿短局", "数据化跑团体验"] as const;
const STANCES = ["完全代入", "部分代入", "旁观", "操控", "跟着体验"] as const;
const PLACEHOLDER =
/^(未定|待定|无|没有|不知道|不详|未知|待补|待探|仍混沌|核心明确|稍后|以后再说|待写|…+|\.{2,}|TBD|tbd|—+|-+|null|undefined)$/i;
export type AnchorItemId = "站位" | "核心感觉" | "轮转停点";
export type ExperienceAnchorItem = {
id: AnchorItemId;
ok: boolean;
/** 通过时记下程序采信的短句 */
value?: string;
reason: string;
};
export type ExperienceAnchorScore = {
total: 3;
passed: number;
anchored: boolean;
items: ExperienceAnchorItem[];
};
export function recipeRequiresExperienceAnchor(recipeName?: string): boolean {
const name = recipeName?.trim() ?? "";
return (EXPERIENCE_ANCHOR_RECIPE_NAMES as readonly string[]).includes(name);
}
export function isDictateOpeningTag(tag: string): boolean {
const t = tag.trim();
return (
t === "设计.开场白" ||
t.startsWith("设计.开场白#") ||
t === "设计.开场白与开场变量" ||
t === "设计.0层开场白与初态"
);
}
export function scoreExperienceAnchor(content: string | undefined | null): ExperienceAnchorScore {
const raw = content?.trim() ?? "";
const doc = raw ? asRecord(tryParseJsonDoc(raw)) : undefined;
const anchor = asRecord(doc?.体验锚定) ?? asRecord(asRecord(doc?.正文)?.体验锚定);
const body = asRecord(doc?.正文);
const stance = scoreStance(
firstText(
anchor?.站位,
dig(body, ["设定逻辑", "参与方式", "用户与user关系"]),
dig(body, ["交互范式", "前置配置", "用户与user关系"]),
labeled(raw, "站位"),
),
);
const feeling = scoreFeeling(
firstText(
anchor?.核心感觉,
dig(body, ["设定逻辑", "内容维度", "核心感觉", "已知"]),
dig(body, ["美学纲领", "体验内核"]),
labeled(raw, "核心感觉"),
),
);
const turn = scoreTurnStop(
firstText(
anchor?.轮转停点,
dig(body, ["交互范式", "描写权限", "思考与抉择"]),
labeled(raw, "轮转停点"),
),
);
const items = [stance, feeling, turn];
const passed = items.filter((item) => item.ok).length;
return { total: 3, passed, anchored: passed === 3, items };
}
export function formatExperienceAnchorBlock(score: ExperienceAnchorScore): string {
const lines = [
"## 体验锚定(程序打分,不采用模型自评)",
`分数:${score.passed}/${score.total}`,
`已锚定:${score.anchored ? "是" : "否"}`,
];
for (const item of score.items) {
const shown = item.ok && item.value ? `(${clip(item.value)})` : `(${item.reason})`;
lines.push(`- ${item.id}:${item.ok ? "通过" : "未通过"}${shown}`);
}
lines.push(
score.anchored
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领与交互范式」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
);
return lines.join("\n");
}
export function experienceAnchorToolPayload(score: ExperienceAnchorScore): {
passed: number;
total: 3;
anchored: boolean;
missing: AnchorItemId[];
} {
return {
passed: score.passed,
total: 3,
anchored: score.anchored,
missing: score.items.filter((item) => !item.ok).map((item) => item.id),
};
}
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
const missing = score.items.filter((item) => !item.ok).map((item) => item.id);
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领与交互范式」的「体验锚定」,再写开场。`;
}
function scoreStance(raw: string | undefined): ExperienceAnchorItem {
const id: AnchorItemId = "站位";
const t = clean(raw);
if (!t) return { id, ok: false, reason: "空或未定" };
if (/还是|或者/.test(t)) return { id, ok: false, reason: "还是问句,没有选定" };
const hit = STANCES.find((s) => t === s || t.startsWith(s));
if (hit) return { id, ok: true, value: hit, reason: "选定" };
if (t.includes("棋子") || t.includes("观察")) {
return { id, ok: true, value: "旁观", reason: "选定" };
}
return { id, ok: false, reason: "须是完全代入、部分代入、旁观、操控之一" };
}
function scoreFeeling(raw: string | undefined): ExperienceAnchorItem {
const id: AnchorItemId = "核心感觉";
const t = clean(raw);
if (!t || t.length < 4) return { id, ok: false, reason: "空、未定,或短于一句" };
return { id, ok: true, value: t, reason: "有一句" };
}
function scoreTurnStop(raw: string | undefined): ExperienceAnchorItem {
const id: AnchorItemId = "轮转停点";
const t = clean(raw);
if (!t || t.length < 8) return { id, ok: false, reason: "空、未定,或没写清何时停" };
if (!/停|等/.test(t)) return { id, ok: false, reason: "没写停笔或等用户" };
return { id, ok: true, value: t, reason: "写了何时停" };
}
function clean(raw: string | undefined): string {
const t = raw?.trim().replace(/\s+/g, " ") ?? "";
if (!t || PLACEHOLDER.test(t)) return "";
return t;
}
function firstText(...candidates: unknown[]): string | undefined {
for (const candidate of candidates) {
const text = textOf(candidate);
if (text) return text;
}
return undefined;
}
function textOf(value: unknown): string | undefined {
if (typeof value === "string") {
const t = value.trim();
return t || undefined;
}
const row = asRecord(value);
if (!row) return undefined;
for (const key of ["结论", "已知", "体验内核"]) {
if (typeof row[key] === "string" && row[key].trim()) return row[key].trim();
}
return undefined;
}
function labeled(raw: string, label: string): string | undefined {
if (!raw || raw.startsWith("{") || raw.startsWith("[")) return undefined;
const re = new RegExp(`(?:^|\\n)\\s*${label}\\s*[::]\\s*([^\\n]+)`);
return re.exec(raw)?.[1]?.trim();
}
function dig(root: Record<string, unknown> | undefined, path: string[]): unknown {
let cur: unknown = root;
for (const key of path) {
const row = asRecord(cur);
if (!row) return undefined;
cur = row[key];
}
return cur;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function clip(text: string): string {
const t = text.trim();
return t.length > 36 ? `${t.slice(0, 36)}…` : t;
}
+38
View File
@@ -2,7 +2,9 @@ export { buildDictateSystemPrompt, DICTATE_SYSTEM_PROMPT } from "./prompt.js";
export {
CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE,
DICTATE_LAYER_META_KEY,
DICTATE_ORDER_META_KEY,
DICTATE_SELF_SCORE_META_KEY,
isDictateModeValue,
isAllowedProductTag,
defaultDictateOrder,
@@ -11,6 +13,7 @@ export {
sortDictateProducts,
parseDictateOrder,
type DictateProduct,
type DictateSelfScore,
type DictateChatTurn,
} from "./types.js";
export {
@@ -19,6 +22,40 @@ export {
} from "./context.js";
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
export { runDictateTurn, type DictateTurnResult } from "./turn.js";
export {
CREATION_PLAN_TAG,
CREATION_PLAN_SCHEMA,
CREATION_PLAN_TIERS,
CREATION_PLAN_EXCLUDED_MODULE_IDS,
creationPlanModules,
parseCreationPlan,
validateCreationPlan,
formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress,
requiredCreationPlanGaps,
type CreationPlan,
type CreationPlanItem,
type CreationPresentationMode,
type CreationPlanTier,
type CreationPlanValidation,
} from "./creation-plan.js";
export {
DICTATE_MODULE_READ_STATE_TAG,
buildDictateModuleReadIndex,
parseDictateModuleReadState,
serializeDictateModuleReadState,
readLibraryEntries,
type DictateModuleReadPayload,
type DictateModuleReadState,
} from "./read-module.js";
export {
AESTHETICS_PRODUCT_TAG,
scoreExperienceAnchor,
formatExperienceAnchorBlock,
recipeRequiresExperienceAnchor,
isDictateOpeningTag,
type ExperienceAnchorScore,
} from "./experience-anchor.js";
export {
DICTATE_INSERT_TAG_ALIASES,
DICTATE_MULTI_MODULE_REPLY_HINT,
@@ -48,6 +85,7 @@ export {
mergeGenerationRulesArtifacts,
resolveRepeatableInsertTag,
sanitizeRepeatableSlot,
splitConcreteInstanceContents,
} from "./repeatable-tags.js";
export {
VARIABLE_CATALOG_TAG,
+23 -9
View File
@@ -22,7 +22,7 @@ export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
export const DICTATE_MULTI_MODULE_REPLY_HINT =
"本轮带回了多个 reply_module。末尾可见回复:每个模块各写一块 markdown,标题用 ## 模块 · {名称}(与该 reply_module 一致);块间空一行;每块只写该模块的确认 / 薄弱点 / 追问。全部模块写完后,可另起一段写 1 条「建议下一刀」(条件须已接近成立)。缺某一块则补上后再结束本轮。";
"本轮写入了多个产物。末尾用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。";
const MAX_PROBE_CHARS = 1200;
const MAX_PRINCIPLES_CHARS = 900;
@@ -73,6 +73,15 @@ export function extractSelfScoreDimensionNames(outputFence: string): string[] {
return names.slice(0, 8);
}
function extractScoreFenceDimensionNames(scoreFence: string): string[] {
const names: string[] = [];
for (const match of scoreFence.matchAll(/^([^#\-\s][^:\n]{0,30}):\s*$/gm)) {
const name = match[1]?.trim();
if (name && !names.includes(name)) names.push(name);
}
return names.slice(0, 8);
}
function clip(text: string, max: number): string {
const t = text.trim();
if (t.length <= max) return t;
@@ -91,7 +100,12 @@ export function formatDictateUserFacingBrief(params: {
const probe = getModuleSection(sections, "probe");
const principles = getModuleSection(sections, "principles");
const output = getModuleSection(sections, "output");
const dims = output ? extractSelfScoreDimensionNames(output) : [];
const score = getModuleSection(sections, "score");
const dims = score
? extractScoreFenceDimensionNames(score)
: output
? extractSelfScoreDimensionNames(output)
: [];
if (!probe && !principles && dims.length === 0) return "";
@@ -101,19 +115,19 @@ export function formatDictateUserFacingBrief(params: {
if (dims.length) includes.push(`自评关注(${dims.join("、")})`);
const parts: string[] = [
`# 模块 · ${params.module.name}`,
`# 本轮关注 · ${params.module.name}`,
"",
`回答时请附带本模块(落盘 tag:\`${params.tag}\`)。`,
`用于组织写入 \`${params.tag}\` 后的自然回复,不要求固定标题或独立模块段。`,
"",
"本模块内容包括:",
...includes.map((x) => `- ${x}`),
"",
"## 写入用户回复时做什么",
`1. 用二级标题 \`## 模块 · ${params.module.name}\` 开一段(多模块时本段只写本模块)。`,
"2. 确认:2~4 句复述本模块已落要点,让用户能判断对不对。",
"3. 薄弱点:对照下方「自评关注」口头点 1 处不够硬的地方(不必交打分表或 JSON)。",
"4. 追问:1~2 点;每点给建议选项或短场景,用户可直接采用或改写。材料已够则追问可空,改写「建议下一刀」。",
"5. 不粘贴产物全文;不复述 tool JSON。",
"1. 自然回应用户本轮值得保留、需要纠正或存在矛盾的内容。",
"2. 简要说明形成或改写了什么;完整 JSON 由产物卡展示。",
"3. 结合 self_score 说明最强处与关键缺口,不复述整张评分表。",
"4. 只有低分缺口确需用户决定时才提问,并给具体选项或短场景。",
"5. 同轮多个产物时合并回应,不逐模块套模板;不粘贴产物全文或 tool JSON。",
];
if (probe) {
+27 -6
View File
@@ -15,6 +15,7 @@ import {
type ContextOrderProjection,
type ContextOrderSlot,
} from "../skills/context-order.js";
import { parseContextFragment } from "../skills/context-fragment.js";
import {
WORKER_SET_DRAFT_TAG,
WORKER_SET_FINAL_TAG,
@@ -22,7 +23,6 @@ import {
import {
defaultPlaySlots,
enabledPlaySlotIds,
parsePlaySlots,
refForSlot,
type PlaySlotsConfig,
} from "../skills/play-slots.js";
@@ -49,6 +49,7 @@ import {
const SKIP_BIND_TAGS = new Set([
"设计.开场白",
"设计.开场白与开场变量",
"设计.0层开场白与初态",
"设计.worker集",
"设计.worker集.草稿",
CONTEXT_ORDER_TAG,
@@ -71,7 +72,7 @@ function shouldSkipBindTag(tag: string): boolean {
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
const STYLE_TAG_RE =
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/;
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复呈现|回复格式|监控栏|篇幅|结构/;
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
@@ -87,6 +88,11 @@ function isStyleProductTag(tag: string): boolean {
return STYLE_TAG_RE.test(tag);
}
function isFrequentlyChangingProduct(product: DictateProduct): boolean {
const stability = parseContextFragment(product.content)?.稳变;
return stability === "semi" || stability === "volatile";
}
/** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */
export function collectDictateBindProducts(board: Blackboard): DictateProduct[] {
const products: DictateProduct[] = [];
@@ -157,20 +163,34 @@ function buildGmInserts(params: {
insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"),
];
let o = 1;
for (const p of params.products) {
if (!params.includeStyle && isStyleProductTag(p.tag)) continue;
const included = params.products.filter(
(p) => params.includeStyle || !isStyleProductTag(p.tag),
);
const stable = included.filter((p) => !isFrequentlyChangingProduct(p));
const changing = included.filter(isFrequentlyChangingProduct);
for (const p of stable) {
inserts.push(
insert(
o++,
p.tag,
"full",
`对话落盘固定产物〔order=${effectiveDictateOrder(p)}〕`,
`稳定产物;同频率按重要性序〔order=${effectiveDictateOrder(p)}〕`,
),
);
}
inserts.push(
insert(o++, DIALOGUE_HISTORY_TAG, "summary", "对话历史(按投影裁剪)"),
);
for (const p of changing) {
inserts.push(
insert(
o++,
p.tag,
"full",
`较常变化产物;置于历史后〔order=${effectiveDictateOrder(p)}〕`,
),
);
}
for (const tag of params.runtimeTags) {
inserts.push(
insert(
@@ -280,7 +300,8 @@ export function buildDictateContextOrder(params: {
return renumberContextOrder({
schema: CONTEXT_ORDER_SCHEMA,
brief: "对话落盘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入",
brief:
"程序按变化频率分组,并在同组内按重要性排列:稳定产物 → 对话.历史 → 较常变化产物与当前状态 → 本轮输入",
play_slots: params.playSlots,
slots,
});
+42 -80
View File
@@ -6,45 +6,55 @@
*/
export function buildDictateSystemPrompt(opts?: {
/** 已有方案 + 分档状态;存在时不再注入全能力目录 */
creationPlanState?: string;
/** 尚无方案时使用的短能力目录 */
capabilityCatalog?: string;
/** 尚无方案时可引用的旧配方案例目录 */
recipeExamples?: string;
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
recipeName?: string;
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
recipeBrief?: string;
/** 来自 modules catalog meta:何时落盘 / insert 目标 */
/** @deprecated 使用 capabilityCatalog */
moduleGuide?: string;
}): string {
const recipeBlock =
const legacyExample =
opts?.recipeName || opts?.recipeBrief
? `
## 本局配方
名称:${opts.recipeName ?? "(未命名)"}
${opts.recipeBrief ? `要点:\n${opts.recipeBrief}` : ""}
按上述适用与原则整理产物;材料只够做本配方时,就只做本配方范围内的事。
能力「何时落盘」以【能力 · 何时落盘】为准;本配方只给顺序骨架,不代替各能力调用条件。
【旧配方案例 · 兼容参考】
${opts.recipeName ? `- ${opts.recipeName}` : ""}
${opts.recipeBrief ?? ""}
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。
`
: "";
const decisionBlock = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
const moduleBlock = opts?.moduleGuide?.trim()
? `
${opts.moduleGuide.trim()}
`
: "";
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个:先 read_module,再连续 insert。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,按已有材料给 2~3 个选项来问,选定或改写后再写入。写开场或结束创作前,列出仍未落的「必须」和「有必要」,问用户哪些还没设计、要补哪几项或明确不要。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`;使用条件仍留在能力旁。`
: `【当前状态 · 尚无本局创作方案】
先抽出用户要的核心体验,再对照能力目录主动标出有帮助的能力,生成 \`creation-plan.v1\`,用 insert 写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献:用户没点名、但缺了体验会明显变差的,标进「必须」或「有必要」,具体还没定的选择写进 missing。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。
return `你是对话落盘创作助手:把用户原话整理成可落盘规格,并用可见回复让用户确认、补洞、选下一刀。作用对象是本局创作上下文(对话可清空,产物保留)。
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"}
${opts?.recipeExamples?.trim() ? `\n\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""}
${legacyExample}`;
return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
## 落盘节奏(先读)
1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物,改为短确认或问挡住的 1 点。
2. **同轮可多次 insert / declare_***(一步并行或多个 tool 轮次都行):用户本轮材料已够、且多个能力的「何时用」都成立时,把这些都落掉;每个成功的 insert 各回一份 \`reply_module\`,可见回复按模块分段附带。
3. **不抢跑未齐条件的下游**(这才是「别一口气铺满」的意思):条件未成立的能力本轮不落,写进「建议下一刀」等用户补料或上游齐了再落。配方顺序是骨架;能不能落以各能力「何时用 / 何时不用」为准。
${decisionBlock}
## 落盘(仅允许 toolcall)
## 写入规则
设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。
### insert — 写入/覆盖固定产物
- position:\`用户.*\` / \`设计.*\`(优先用【能力 · 何时落盘】里的落盘 tag)
- content:正文;order 可选(越小越靠前;越常改越大)
- position:\`用户.*\` / \`设计.*\`
- content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大)
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
- **〔可反复〕须拆分**:生成规则 / 具体实例等每条增殖用 \`基名#唯一id\`(如 \`设计.生成规则#ex-girlfriend-card\`、\`设计.具体实例#lei-ying\`)。id 用 rule_id / batch_id / 姓名 slug。禁止反复 insert 基名覆盖旧条;改某一条才 insert 同一完整 tag。
- 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag
- 只有方案中该填的内容都完成后才写开场白;先单独调用 prepare_opening,让程序编译信息可见范围、游玩执行方式、上下文排序和运行配置;工具成功后的下一步再生成并 insert 开场白
- 开场白与生成规则、具体实例一样可增殖:每条入口 insert \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
### delete — 删除固定产物
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
@@ -54,7 +64,6 @@ ${opts.moduleGuide.trim()}
### declare_variable — 跨轮真值
- key / type / initial / user_visible(用户是否可见)
- 只立必须跨轮记住的状态;派生文案不要立成可写变量
- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不 declare
- 移除字段 → undeclare_variable
### declare_map — 真值→投影上下文
@@ -63,65 +72,18 @@ ${opts.moduleGuide.trim()}
- 槽位固定、内容随真值换档;旧档离开上下文
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
## 对用户可见回复
tool 跑完后写给用户看的 markdown。作用:确认本轮落了什么、点出薄弱处、用可选追问引导补洞或下一刀。
## 推进原则
1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。有方案时,写哪项先对照该能力的何时用 / 何时不用。
2. 写任何内容能力前先调用 read_module 读取当前提示版本;需要库条目时,再用 read_library_entry 读取当前能力绑定库条目(默认 1~2;确需更多须给 reason)。不要凭目录短说明猜产物。
3. 本轮只写入用户这句话已经说清的能力,通常 2~3 个。对这些能力先 read_module,再 insert / declare_*。每次 insert 都是独立产物写入。
4. 写某能力前以 read_module 返回的 task / principles / output / score 为准;尚未读到专用提示时,不猜造其复杂结构。能力提示里的「材料足够便直接成稿」只用于这句已经说清的能力。
5. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
6. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
执行顺序:
1. 先完成本轮需要的 insert / declare_*。
2. 收集各次 insert 成功返回的 \`reply_module\`(若有;每个模块一份独立 markdown)。
3. 按下列规格写自然语言(粘贴产物全文或 tool JSON 都算失败):
**有 1 个 reply_module**
- 用二级标题 \`## 模块 · {名称}\`(名称与该 reply_module 标题一致)
- 段内按该模块的「写入用户回复时做什么」写满:确认要点 → 口头薄弱点 → 追问 1~2 点(每点含建议选项或短场景)
**有 N 个 reply_module(N≥2)**
- 同轮多次 insert,或一步并行多个,都会各回一份 \`reply_module\`
- 每个模块各写一块,标题互不相同的 \`## 模块 · {名称}\`;块与块之间空一行
- 每一块只服务该模块;全部模块写完后,可另起一段写「建议下一刀」(仅 1 条、且条件已接近成立)
**没有 reply_module**
- 用 2~4 句确认本轮写入的 tag 要点,并建议条件已近的下一刀;若落盘被挡住,只问挡住的 1 点
## 分清落盘目标
- 正文组成(格式)→ insert \`设计.正文组成\`(须「回复呈现」等上游条件已满足,见能力表)
- 开场白(内容)→ insert \`设计.开场白\` 或 \`设计.开场白#场景短码\`(多开场互不覆盖;改某一条则 insert 同一完整 tag);落档/开玩同步 \`输出.开场白\`
- 代入名片 → insert \`设计.主角设定\` 或 \`设计.主角设定#同短码\`(与开场白同槽即绑定该开局;无槽则全局默认)
- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.故事推进\`(大纲扩写);文风细则另 insert \`设计.叙事指南\`
- 好感分档性格等 → declare_variable + declare_map;分档文案走映射,不 insert 整表散文
${recipeBlock}${moduleBlock}
## 按用户本轮意图择一为主(仍须过「何时用」)
落盘后的可见回复一律按「对用户可见回复」写;下列各节只约定本轮落什么。
### A · 钉体验 / 设定
优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。
### B · 钉正文格式
「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。
### C · 生成开场白
收口:体验已锚定。首条可用 \`设计.开场白\`;再写别的开局用 \`设计.开场白#场景短码\`(与生成规则同款:同 tag 覆盖,新短码新建)。禁止用 \`## 岔路\` 塞进同一份。
**形状:** 只要叙事正文。禁止把 \`用户角色.名字\` / \`用户角色.简介\` 写进开场白。代入名片 → \`设计.主角设定\` 或 \`设计.主角设定#同短码\`。
**正文格式:** 已有 \`设计.正文组成\` 时,正文必须是 present.v1 JSON(\`shell\` 与正文组成一致,主读写 \`blocks.body\`),不要只交裸 Markdown;尚未钉正文组成时才用 Markdown。
**content 规格:** 正文至少一处字面 \`@玩家\` 作用户角色名位;姓名位用 \`@玩家\`,不写死具体姓名;全文不能只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。
进玩时在游玩辅助「开场」Tab 点选开局;有绑定名片则可切到该预设。
### D · 钉变量与映射
仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。配方名带「数据化」也不自动开跑 D。
### E · 文本生成器(创作收料 → 开玩挂载)
必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.故事推进\`(用户输入用法:大纲扩写;扮演停笔关掉)。文风细则对照「叙事指南」何时用再 insert \`设计.叙事指南\`。缺范例/要点/故事推进任一就先补产物,再说可以开玩。创作期不写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。
### F · 快穿短局
仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介 → insert \`设计.主角设定\`(不要写进开场白)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。
## 常用序感
- 需求/美学等不变设定:靠前(负)
- 正文组成:中间(0)
- 开场白:靠后(正)
## 设计.正文组成
JSON:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`。文本局默认 prose;至少一块 body。
## 用户可见回复
自然回应用户本轮有效判断、矛盾和值得保留之处,再简要说明形成或改写了什么。完整 JSON 由产物卡展示,不粘贴到回复。
写正文组成或 0 层正文组成时,在回复里直接给出一份隔离内容供预览:与写入的 frontend 同一套骨架,示例内容已经放进去,单独可看。产物仍只保存 frontend 与数据契约。
这句话没覆盖到的「必须」或「有必要」,用 2~3 个从用户原话推出的选项询问,不先写成产物。已经说清并写入的,说明形成了什么,不再另找问题。同轮多个已说清产物只做一次综合回应,不逐模块套固定标题。
## 其它
对话明显过长时 clear_dialogue(产物与变量/映射保留)。
+258
View File
@@ -0,0 +1,258 @@
import { createHash } from "node:crypto";
import {
getModuleSection,
loadModulePrompt,
parseModulePromptSections,
type ModuleCatalog,
type ModuleCatalogEntry,
} from "../skills/creation-flow.js";
import {
loadShellRefCatalog,
type ShellRefEntry,
} from "../skills/shell-ref-catalog.js";
import {
formatMarkdownSafeSubsetForPrompt,
loadMarkdownSafeSubsetCatalog,
} from "../skills/markdown-safe-subset.js";
import { getLibrary } from "../libraries/registry.js";
import {
getPreference,
listActivePreferences,
} from "../preference/store.js";
import {
getStylePack,
listActiveStylePacks,
} from "../style-pack/store.js";
export const DICTATE_MODULE_READ_STATE_TAG = "运行.对话落盘.已读能力";
export type DictateModuleReadPayload = {
module_id: string;
name: string;
artifact: string;
version: string;
task: string;
principles: string;
output: string;
score: string;
score_dimensions: string[];
libraries: Array<{
library_id: string;
label: string;
entries: Array<{ id: string; name?: string; summary: string }>;
}>;
constraints: string[];
presentation_references?: {
shells: ShellRefEntry[];
markdown_safe_subset: string;
iframe_contract: string;
};
};
export type DictateModuleReadState = {
schema: "dictate-module-read.v1";
modules: Record<string, string>;
active_module_id?: string;
};
export async function buildDictateModuleReadIndex(params: {
skillPackRoot: string;
catalog: ModuleCatalog;
}): Promise<Map<string, DictateModuleReadPayload>> {
const index = new Map<string, DictateModuleReadPayload>();
const [shells, markdown] = await Promise.all([
loadShellRefCatalog(params.skillPackRoot),
loadMarkdownSafeSubsetCatalog(params.skillPackRoot),
]);
for (const module of params.catalog.modules) {
const prompt = await loadModulePrompt(params.skillPackRoot, module.id);
if (!prompt) continue;
const sections = parseModulePromptSections(prompt);
const payload: DictateModuleReadPayload = {
module_id: module.id,
name: module.name,
artifact: module.artifact,
version: modulePromptVersion(sections.blocks),
task: getModuleSection(sections, "task") ?? "",
principles: getModuleSection(sections, "principles") ?? "",
output: getModuleSection(sections, "output") ?? "",
score: getModuleSection(sections, "score") ?? "",
score_dimensions: extractScoreDimensions(
getModuleSection(sections, "score") ?? "",
),
libraries: libraryDirectories(module),
constraints: moduleConstraints(module),
...(module.id === "reply-format" || module.id === "zero-layer-reply-format"
? {
presentation_references: {
shells,
markdown_safe_subset: markdown
? formatMarkdownSafeSubsetForPrompt(markdown)
: "",
iframe_contract:
module.id === "reply-format"
? "正文组成生成一次 frontend 模板与数据契约;游玩每轮只传 present.v1 blocks,由程序装进已固定的壳。iframe 使用 sandbox=\"allow-scripts\",不含 allow-same-origin;模板只通过 present.onData(cb) 收数据,不提供 action 回传。html+css+js 合计不超过 60 KB;未知 blocks 键必须保留。"
: "0层正文组成生成一次持久前端;之后每轮只提交 slot_updates。模板通过 zeroLayer.onInit 与 zeroLayer.onPatch 收数据,不提供 action 回传。html+css+js 合计不超过 60 KB。",
},
}
: {}),
};
index.set(module.id, payload);
index.set(module.name, payload);
}
return index;
}
export function parseDictateModuleReadState(
raw: string | undefined | null,
): DictateModuleReadState {
if (raw?.trim()) {
try {
const row = JSON.parse(raw) as DictateModuleReadState;
if (
row?.schema === "dictate-module-read.v1" &&
row.modules &&
typeof row.modules === "object"
) {
return row;
}
} catch {
// Reset malformed internal state.
}
}
return { schema: "dictate-module-read.v1", modules: {} };
}
export function serializeDictateModuleReadState(
state: DictateModuleReadState,
): string {
return JSON.stringify(state);
}
export function readLibraryEntries(params: {
module: DictateModuleReadPayload;
libraryId: string;
entryIds: string[];
reason?: string;
}): { entries?: unknown[]; error?: string } {
const libraryId = params.libraryId.trim();
const bound = params.module.libraries.some(
(library) => library.library_id === libraryId,
);
if (!bound) {
return { error: `能力 ${params.module.module_id} 未绑定库 ${libraryId}` };
}
const ids = [...new Set(params.entryIds.map((id) => id.trim()).filter(Boolean))];
if (ids.length === 0) return { error: "entry_ids 不能为空" };
if (ids.length > 2 && !params.reason?.trim()) {
return { error: "默认最多读取两个库条目;读取更多时必须提供 reason" };
}
if (libraryId === "preferences") {
const entries = ids.map(getPreference);
const missing = ids.filter((_, index) => !entries[index]);
if (missing.length) return { error: `偏好库无条目:${missing.join("、")}` };
return {
entries: entries.map((entry) => ({
id: entry!.id,
content: entry!.content,
})),
};
}
if (libraryId === "style-packs") {
const entries = ids.map(getStylePack);
const missing = ids.filter((_, index) => !entries[index]);
if (missing.length) return { error: `文风库无条目:${missing.join("、")}` };
return {
entries: entries.map((entry) => ({
id: entry!.id,
name: entry!.name,
content: entry!.content,
...(entry!.samples ? { samples: entry!.samples } : {}),
})),
};
}
return { error: `库 ${libraryId} 尚不支持按条目读取` };
}
function modulePromptVersion(blocks: Record<string, string>): string {
const source = ["task", "principles", "output", "score"]
.map((id) => `${id}\n${blocks[id]?.trim() ?? ""}`)
.join("\n---\n");
return createHash("sha256").update(source).digest("hex").slice(0, 16);
}
function libraryDirectories(module: ModuleCatalogEntry): DictateModuleReadPayload["libraries"] {
return (module.libraries ?? []).map((id) => {
const provider = getLibrary(id);
if (id === "preferences") {
return {
library_id: id,
label: provider?.label ?? id,
entries: listActivePreferences().map((entry) => ({
id: entry.id,
summary: clip(entry.content),
})),
};
}
if (id === "style-packs") {
return {
library_id: id,
label: provider?.label ?? id,
entries: listActiveStylePacks().map((entry) => ({
id: entry.id,
name: entry.name,
summary: clip(entry.content),
})),
};
}
return { library_id: id, label: provider?.label ?? id, entries: [] };
});
}
function repeatableLandLine(module: ModuleCatalogEntry): string {
if (module.id === "opening-setup") {
return "产物可反复:每条开场使用 设计.开场白#场景短码;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
}
if (module.id === "zero-layer-opening-setup") {
return "产物可反复:每条开场使用 设计.0层开场白与初态#场景短码;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
}
if (module.id === "generation-rules") {
return "产物可反复:每条规则单独落盘,使用 设计.生成规则#规则名;同完整 tag 才覆盖";
}
if (module.id === "concrete-instances") {
return "产物可反复:每条实例单独落盘,使用 设计.具体实例#名称;同完整 tag 才覆盖";
}
if (module.repeatable) {
return `产物可反复:每条单独落盘,使用 ${module.artifact}#唯一id;同完整 tag 才覆盖`;
}
return `产物 tag:${module.artifact}`;
}
function moduleConstraints(module: ModuleCatalogEntry): string[] {
const constraints = [repeatableLandLine(module)];
if (module.id === "generation-rules") {
constraints.push(
"产物层由生命周期决定:只预生成=intermediate;仅游玩期 / 预生成并游玩期=final",
);
constraints.push("每个产物只写一条规则");
} else if (module.layer) {
constraints.push(`默认产物层:${module.layer}`);
}
if (module.mount?.length) constraints.push(`默认挂载:${module.mount.join("、")}`);
if (module.auto) constraints.push("机械程序节点,不受读取门禁");
return constraints;
}
function clip(text: string, max = 140): string {
const compact = text.replace(/\s+/g, " ").trim();
return compact.length <= max ? compact : `${compact.slice(0, max - 1)}…`;
}
function extractScoreDimensions(score: string): string[] {
const names: string[] = [];
for (const match of score.matchAll(/^([^#\-\s][^:\n]{0,30}):\s*$/gm)) {
const name = match[1]?.trim();
if (name && !names.includes(name)) names.push(name);
}
return names;
}
+96 -25
View File
@@ -1,7 +1,7 @@
/**
* 对话落盘 · 可增殖产物 tag 拆分
*
* catalog 标 repeatable 的能力(生成规则 / 具体实例)共用同一 artifact 基名。
* catalog 标 repeatable 的能力(生成规则 / 具体实例 / 拓扑图谱等)共用同一 artifact 基名。
* 对话里若反复 insert 基 tag,后写会整份覆盖先写。
* 约定:基名#槽位(如 设计.具体实例#lei-ying),每条增殖各占一 tag。
*/
@@ -48,6 +48,85 @@ export function buildRepeatableProductTag(
return s ? `${base}${REPEATABLE_TAG_SEP}${s}` : base;
}
function nameFromRecord(row: unknown): string | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
const record = row as Record<string, unknown>;
return (
asString(record.姓名) ??
asString(record.名称) ??
asString(record.名字) ??
asString(record.name) ??
asString(record.id)
);
}
function concreteInstanceName(body: Record<string, unknown>): string | undefined {
const direct =
asString(body.名称) ??
asString(body.实例名) ??
asString(body.batch_id) ??
asString(body.批次id) ??
asString(body.id);
if (direct) return direct;
const fromRecord = nameFromRecord(body.记录);
if (fromRecord) return fromRecord;
const rows = Array.isArray(body.records)
? body.records
: Array.isArray(body.记录)
? body.记录
: [];
for (const row of rows) {
const name = nameFromRecord(row);
if (name) return name;
}
return undefined;
}
function concreteRecordRows(body: Record<string, unknown>): unknown[] | null {
if (Array.isArray(body.records) && body.records.length > 1) return body.records;
if (Array.isArray(body.记录) && body.记录.length > 1) return body.记录;
return null;
}
/** 一条具体实例正文里若含多条记录,拆成每条一份。不足两条时原样返回。 */
export function splitConcreteInstanceContents(content: string): string[] {
const parsed = tryParseJsonDoc(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return [content];
}
const doc = parsed as Record<string, unknown>;
const bodyRaw = doc.正文;
if (!bodyRaw || typeof bodyRaw !== "object" || Array.isArray(bodyRaw)) {
return [content];
}
const body = bodyRaw as Record<string, unknown>;
const rows = concreteRecordRows(body);
if (!rows) return [content];
const rule =
asString(body.规则) ?? asString(body.规则名) ?? asString(body.rule_id);
const used = new Set<string>();
return rows.map((row, index) => {
const base = nameFromRecord(row) ?? `第${index + 1}条`;
let name = base;
let n = 2;
while (used.has(name)) {
name = `${base}-${n}`;
n += 1;
}
used.add(name);
const nextBody: Record<string, unknown> = {
...(rule ? { 规则: rule } : {}),
名称: name,
记录: row && typeof row === "object" && !Array.isArray(row) ? row : { 内容: String(row) },
};
if (Array.isArray(body.批次条件) && body.批次条件.length) {
nextBody.批次条件 = body.批次条件;
}
return JSON.stringify({ ...doc, brief: name, 正文: nextBody }, null, 2);
});
}
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
@@ -65,7 +144,7 @@ function bodyOfFragment(content: string): Record<string, unknown> | null {
}
/**
* 从正文抽出建议槽位:生成规则 → rule_id;具体实例 → batch_id / 首条姓名。
* 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。
*/
export function extractRepeatableSlotFromContent(
family: string,
@@ -77,7 +156,11 @@ export function extractRepeatableSlotFromContent(
if (base === "设计.生成规则" || /生成规则$/.test(base)) {
const top =
asString(body.rule_id) ?? asString(body.id) ?? asString(body.规则id);
asString(body.规则名) ??
asString(body.rule_id) ??
asString(body.id) ??
asString(body.规则id) ??
asString(body.名称);
if (top) return sanitizeRepeatableSlot(top) || undefined;
const rules = body.rules;
if (Array.isArray(rules)) {
@@ -85,7 +168,10 @@ export function extractRepeatableSlotFromContent(
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>;
const id =
asString(r.rule_id) ?? asString(r.id) ?? asString(r.规则id);
asString(r.规则名) ??
asString(r.rule_id) ??
asString(r.id) ??
asString(r.规则id);
if (id) return sanitizeRepeatableSlot(id) || undefined;
}
}
@@ -95,27 +181,12 @@ export function extractRepeatableSlotFromContent(
}
if (base === "设计.具体实例" || /具体实例$/.test(base)) {
const batch =
asString(body.batch_id) ??
asString(body.批次id) ??
asString(body.id);
if (batch) return sanitizeRepeatableSlot(batch) || undefined;
const records = body.records;
if (Array.isArray(records)) {
for (const row of records) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>;
const name =
asString(r.姓名) ??
asString(r.name) ??
asString(r.名称) ??
asString(r.id);
if (name) return sanitizeRepeatableSlot(name) || undefined;
}
}
const ruleId = asString(body.rule_id);
const named = concreteInstanceName(body);
if (named) return sanitizeRepeatableSlot(named) || undefined;
const ruleId =
asString(body.规则) ?? asString(body.规则名) ?? asString(body.rule_id);
if (ruleId) {
return sanitizeRepeatableSlot(`${ruleId}-batch`) || undefined;
return sanitizeRepeatableSlot(`${ruleId}-1`) || undefined;
}
return undefined;
}
@@ -157,7 +228,7 @@ export function resolveRepeatableInsertTag(params: {
const otherSiblings = siblings.filter((t) => t !== family);
if (otherSiblings.length > 0 && raw === family) {
return {
error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用 rule_id,具体实例用 batch_id/姓名,开场白/主角设定用场景短码),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`,
error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用规则名,具体实例用名称,开场白/主角设定用场景短码),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`,
};
}
return { tag: family };
+91 -1
View File
@@ -4,6 +4,70 @@ import type { ToolDefinition } from "../llm/client.js";
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
*/
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
{
type: "function",
function: {
name: "read_module",
description:
"读取一个内容能力当前版本的任务、原则、产物规格、评分规则、绑定库目录与程序约束。写该能力产物前必须先读取当前版本。",
parameters: {
type: "object",
properties: {
module_id: {
type: "string",
description: "能力 id 或中文名",
},
reason: {
type: "string",
description: "本轮为何读取(一句话,可选)",
},
},
required: ["module_id"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "read_library_entry",
description:
"读取当前能力绑定库中本轮选中的条目。默认 1~2;叙事指南等多文风确需第三项时须提供 reason 并说明适用场合。",
parameters: {
type: "object",
properties: {
library_id: {
type: "string",
description: "当前能力绑定的库 id",
},
entry_ids: {
type: "array",
items: { type: "string" },
description: "本轮选中的条目 id,默认最多两个",
},
reason: {
type: "string",
description: "读取超过两个条目时必填",
},
},
required: ["library_id", "entry_ids"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "prepare_opening",
description:
"用户该填的内容均已完成后、生成开场白之前单独调用。程序依次编译信息可见范围、游玩执行方式、上下文排序和运行配置;返回成功后再在下一步生成并 insert 开场白。",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
@@ -26,7 +90,33 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
order: {
type: "number",
description:
"相对顺序(可负)。越小越靠前。越常改越大。省略则沿用原序或默认。",
"同一变化频率组内的重要性顺序(可负)。基础材料用较小值,越接近生成时使用的合同越大;省略则沿用原序或默认。",
},
self_score: {
type: "object",
description:
"能力已有 score 规则时必填;维度名须完全一致,1~5 分,分数不高于 3 时必须写 gap。",
properties: {
dims: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
score: { type: "number" },
gap: { type: "string" },
options: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "score"],
additionalProperties: false,
},
},
},
required: ["dims"],
additionalProperties: false,
},
},
required: ["position", "content"],
+556 -44
View File
@@ -1,6 +1,7 @@
import type { LlmProvider } from "../llm/client.js";
import {
createLocalLlmDriver,
DriverIterationLimitError,
type AgentDriver,
} from "../runtime/driver.js";
import { buildDictateMessages } from "./context.js";
@@ -9,11 +10,31 @@ import { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
import {
isAllowedProductTag,
parseDictateOrder,
type DictateSelfScore,
type DictateChatTurn,
type DictateProduct,
} from "./types.js";
import { DICTATE_MULTI_MODULE_REPLY_HINT } from "./insert-feedback.js";
import { resolveRepeatableInsertTag } from "./repeatable-tags.js";
import {
DICTATE_MULTI_MODULE_REPLY_HINT,
resolveModuleForInsertTag,
} from "./insert-feedback.js";
import { dictateProductFamily, resolveRepeatableInsertTag, splitConcreteInstanceContents } from "./repeatable-tags.js";
import type { ModuleCatalog } from "../skills/creation-flow.js";
import {
CREATION_PLAN_TAG,
formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress,
parseCreationPlan,
requiredCreationPlanGaps,
validateCreationPlan,
} from "./creation-plan.js";
import {
DICTATE_MODULE_READ_STATE_TAG,
parseDictateModuleReadState,
readLibraryEntries,
serializeDictateModuleReadState,
type DictateModuleReadPayload,
} from "./read-module.js";
import {
VARIABLE_CATALOG_TAG,
parseVariableCatalog,
@@ -34,11 +55,18 @@ import {
parseTableDoc,
stringifyTableDoc,
} from "../blackboard/table-cells.js";
import { isDictateOpeningTag } from "./experience-anchor.js";
import { canonicalizeInsertedContent } from "../parse/json-doc.js";
export type DictateTurnHandlers = {
listProducts: () => DictateProduct[];
/** order 省略时由实现沿用原序或默认 */
writeProduct: (tag: string, content: string, order?: number) => void;
writeProduct: (
tag: string,
content: string,
order?: number,
metadata?: { layer?: "intermediate" | "final"; selfScore?: DictateSelfScore },
) => void;
/** 删除固定产物 tag;返回是否曾存在 */
deleteProduct: (tag: string) => boolean;
/** 读/写变量目录与映射(程序校验后的 JSON) */
@@ -46,6 +74,8 @@ export type DictateTurnHandlers = {
writeTag: (tag: string, content: string) => void;
/** 删除任意黑板 tag(投影 / 变量.当前 等) */
deleteTag: (tag: string) => boolean;
/** 写入开场白前运行确定性收口工具,保证运行配置先于开场落盘。 */
prepareOpening?: () => void;
clearDialogue: () => void;
onToolCall?: (name: string, detail: string) => void;
onThinkingDelta?: (delta: string) => void;
@@ -65,6 +95,16 @@ export type DictateTurnResult = {
clearedDialogue: boolean;
};
/**
* 一轮对话落盘的工具上限。
* 串行模型往往一轮只调一个工具:读能力再写入,方案里十余项,再加上收尾回复。
*/
export const DICTATE_DRIVER_MAX_ITERATIONS = 36;
/** 接近上限时写进工具结果,让模型停下来回复用户。 */
export const DICTATE_TURN_LIMIT_HINT =
"本轮工具次数已接近上限。停止调用工具,直接回复用户。已写入的保留;未写的能力下一轮继续。";
/**
* 跑一轮对话落盘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。
*/
@@ -77,6 +117,12 @@ export async function runDictateTurn(params: {
recipeBrief?: string;
/** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */
moduleGuide?: string;
/** 尚无本局方案时显示的旧配方案例目录 */
recipeExamples?: string;
/** 用于本局方案覆盖校验及方案态能力状态 */
moduleCatalog?: ModuleCatalog | null;
/** 当前能力提示的可读内容与版本;存在时启用写前读取门禁 */
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
repeatableFamilies?: ReadonlySet<string> | readonly string[];
driver?: AgentDriver;
@@ -85,6 +131,7 @@ export async function runDictateTurn(params: {
const wroteTags: string[] = [];
const deletedTags: string[] = [];
let clearedDialogue = false;
let openingPrepared = false;
let dialogue = [...params.dialogue];
const repeatableFamilies = new Set(
[...(params.repeatableFamilies ?? [])].map((t) => String(t).trim()).filter(Boolean),
@@ -92,33 +139,146 @@ export async function runDictateTurn(params: {
const systemPrompt =
params.systemPrompt ??
buildDictateSystemPrompt({
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
moduleGuide: params.moduleGuide,
});
(() => {
const products = params.handlers.listProducts();
const plan = parseCreationPlan(
products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
);
return buildDictateSystemPrompt({
...(plan
? {
creationPlanState: formatCreationPlanProgress({
plan,
catalog: params.moduleCatalog,
products,
}),
}
: {
capabilityCatalog:
formatCreationPlanCapabilityCatalog(params.moduleCatalog) ||
params.moduleGuide,
recipeExamples: params.recipeExamples,
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
}),
});
})();
const assembled = buildDictateMessages({
systemPrompt,
products: params.handlers.listProducts(),
dialogue,
});
const userBlock = assembled.filter((m) => m.role !== "system");
let toolSteps = 0;
const run = await driver.run({
let run;
try {
run = await driver.run({
system: systemPrompt,
messages: userBlock,
tools: DICTATE_TOOL_DEFINITIONS,
caller: "dictate_agent",
label: "对话落盘",
maxIterations: DICTATE_DRIVER_MAX_ITERATIONS,
onThinkingDelta: params.handlers.onThinkingDelta,
onThinkingDone: params.handlers.onThinkingDone,
handleStep: (calls) => {
toolSteps += 1;
const moduleNamesThisStep: string[] = [];
const results = calls.map((call) => {
const args = safeParseArgs(call.arguments);
if (
call.name === "declare_variable" ||
call.name === "declare_map" ||
call.name === "delete" ||
call.name === "undeclare_variable" ||
call.name === "remove_map"
) {
openingPrepared = false;
}
if (call.name === "read_module") {
const ref = String(args.module_id ?? "").trim();
const module = params.moduleReadIndex?.get(ref);
if (!module) {
const err = `未找到能力:${ref || "空"}`;
params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: err }) };
}
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
state.modules[module.module_id] = module.version;
state.active_module_id = module.module_id;
params.handlers.writeTag(
DICTATE_MODULE_READ_STATE_TAG,
serializeDictateModuleReadState(state),
);
params.handlers.onToolCall?.(
call.name,
`${module.module_id}@${module.version}`,
);
return {
callId: call.id,
content: JSON.stringify({ ok: true, ...module }),
};
}
if (call.name === "read_library_entry") {
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
const module = state.active_module_id
? params.moduleReadIndex?.get(state.active_module_id)
: undefined;
if (!module) {
const err = "请先 read_module 读取要使用该库的能力";
params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: err }) };
}
const result = readLibraryEntries({
module,
libraryId: String(args.library_id ?? ""),
entryIds: Array.isArray(args.entry_ids)
? args.entry_ids.map(String)
: [],
reason: typeof args.reason === "string" ? args.reason : undefined,
});
params.handlers.onToolCall?.(
call.name,
result.error ?? `${args.library_id}: ${result.entries?.length ?? 0}`,
);
return {
callId: call.id,
content: JSON.stringify(
result.error
? { error: result.error }
: {
ok: true,
module_id: module.module_id,
library_id: String(args.library_id ?? ""),
entries: result.entries,
},
),
};
}
if (call.name === "prepare_opening") {
params.handlers.prepareOpening?.();
openingPrepared = true;
params.handlers.onToolCall?.(
call.name,
"信息可见范围 → 游玩执行方式 → 上下文排序 → 运行配置组装",
);
return {
callId: call.id,
content: JSON.stringify({
ok: true,
runtime_prepared: true,
next: "现在生成并 insert 开场白",
}),
};
}
if (call.name === "insert" || call.name === "write_product") {
let tag = String(args.position ?? args.tag ?? "").trim();
const content = String(args.content ?? "");
const content = canonicalizeInsertedContent(String(args.content ?? ""));
if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) {
const err = `请用 declare_variable / declare_map,不要 insert ${tag}`;
params.handlers.onToolCall?.(call.name, err);
@@ -134,24 +294,193 @@ export async function runDictateTurn(params: {
params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: err }) };
}
if (repeatableFamilies.size) {
const resolved = resolveRepeatableInsertTag({
tag,
content,
existingTags: params.handlers.listProducts().map((p) => p.tag),
repeatableFamilies,
});
if ("error" in resolved) {
params.handlers.onToolCall?.(call.name, resolved.error);
const targetModule = resolveModuleForInsertTag(
tag,
params.moduleCatalog,
);
if (
targetModule &&
params.moduleReadIndex &&
requiresModuleRead(targetModule.id, targetModule.auto)
) {
const current = params.moduleReadIndex.get(targetModule.id);
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
if (!current || state.modules[targetModule.id] !== current.version) {
const err = `写入 ${tag} 前必须先 read_module("${targetModule.id}") 读取当前提示版本`;
params.handlers.onToolCall?.(call.name, err);
return {
callId: call.id,
content: JSON.stringify({ error: resolved.error }),
content: JSON.stringify({
error: err,
required_module_id: targetModule.id,
required_version: current?.version,
}),
};
}
tag = resolved.tag;
}
const currentModule = targetModule
? params.moduleReadIndex?.get(targetModule.id)
: undefined;
const selfScore = parseAndValidateSelfScore(
args.self_score,
currentModule?.score_dimensions ?? [],
);
if ("error" in selfScore) {
params.handlers.onToolCall?.(call.name, selfScore.error);
return {
callId: call.id,
content: JSON.stringify({ error: selfScore.error }),
};
}
if (tag === CREATION_PLAN_TAG) {
const validation = validateCreationPlan(content, params.moduleCatalog);
if (!validation.ok) {
const err = validation.error ?? "本局创作方案校验失败";
params.handlers.onToolCall?.(call.name, err);
return {
callId: call.id,
content: JSON.stringify({
error: err,
missing_ids: validation.missingIds,
duplicate_ids: validation.duplicateIds,
unknown_ids: validation.unknownIds,
}),
};
}
}
if (repeatableFamilies.size) {
const family = dictateProductFamily(tag);
const pieces =
targetModule?.id === "concrete-instances" ||
family === "设计.具体实例"
? splitConcreteInstanceContents(content)
: [content];
const baseTag = pieces.length > 1 ? "设计.具体实例" : tag;
const positions: string[] = [];
const resolvedPieces: Array<{ tag: string; content: string }> = [];
for (const piece of pieces) {
const resolved = resolveRepeatableInsertTag({
tag: baseTag,
content: piece,
existingTags: [
...params.handlers.listProducts().map((p) => p.tag),
...resolvedPieces.map((item) => item.tag),
],
repeatableFamilies,
});
if ("error" in resolved) {
params.handlers.onToolCall?.(call.name, resolved.error);
return {
callId: call.id,
content: JSON.stringify({ error: resolved.error }),
};
}
resolvedPieces.push({ tag: resolved.tag, content: piece });
}
let replyModule: string | undefined;
for (const [index, piece] of resolvedPieces.entries()) {
const pieceTag = piece.tag;
const order = parseDictateOrder(args.order);
const productsBeforeWrite = params.handlers.listProducts();
const isOpening = isDictateOpeningTag(pieceTag);
const explicitlyPrepared = openingPrepared;
if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.();
}
params.handlers.writeProduct(pieceTag, piece.content, order, {
...(targetModule
? {
layer: resolveInsertLayer({
moduleId: targetModule.id,
catalogLayer: targetModule.layer,
content: piece.content,
}),
}
: {}),
...(selfScore.value ? { selfScore: selfScore.value } : {}),
});
if (!isOpening) openingPrepared = false;
wroteTags.push(pieceTag);
positions.push(pieceTag);
const detail =
order === undefined ? pieceTag : `${pieceTag} order=${order}`;
params.handlers.onToolCall?.(call.name, detail);
replyModule =
params.handlers.lookupInsertFeedback?.(pieceTag)?.trim() ||
replyModule;
const requiredGaps = isDictateOpeningTag(pieceTag)
? requiredCreationPlanGaps({
planRaw: productsBeforeWrite.find(
(product) => product.tag === CREATION_PLAN_TAG,
)?.content,
catalog: params.moduleCatalog,
products: [
...productsBeforeWrite.filter((product) => product.tag !== pieceTag),
{ tag: pieceTag, content: piece.content, order },
],
})
: [];
if (index === resolvedPieces.length - 1) {
if (replyModule) {
const title =
replyModule.match(/^#\s*(?:模块|本轮关注)\s*·\s*(.+)$/m)?.[1]?.trim() ||
pieceTag;
moduleNamesThisStep.push(title);
}
return {
callId: call.id,
content: JSON.stringify({
ok: true,
position: positions[0],
...(positions.length > 1 ? { positions, split: positions.length } : {}),
...(isOpening
? {
runtime_prepared_before_opening: true,
preparation_mode: explicitlyPrepared
? "prepare_opening"
: "implicit_fallback",
}
: {}),
...(order !== undefined ? { order } : {}),
...(requiredGaps.length
? {
warning: `仍有“必须”能力未落:${requiredGaps.join("、")}。开场白已写入,请在准备开玩前提醒用户补齐或调整方案。`,
missing_required_module_ids: requiredGaps,
}
: {}),
...(replyModule
? {
reply_module: replyModule,
reply_hint: DICTATE_MULTI_MODULE_REPLY_HINT,
}
: {}),
}),
};
}
}
}
const order = parseDictateOrder(args.order);
params.handlers.writeProduct(tag, content, order);
const productsBeforeWrite = params.handlers.listProducts();
const isOpening = isDictateOpeningTag(tag);
const explicitlyPrepared = openingPrepared;
if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.();
}
params.handlers.writeProduct(tag, content, order, {
...(targetModule
? {
layer: resolveInsertLayer({
moduleId: targetModule.id,
catalogLayer: targetModule.layer,
content,
}),
}
: {}),
...(selfScore.value ? { selfScore: selfScore.value } : {}),
});
if (!isOpening) openingPrepared = false;
wroteTags.push(tag);
const detail =
order === undefined ? tag : `${tag} order=${order}`;
@@ -160,15 +489,41 @@ export async function runDictateTurn(params: {
params.handlers.lookupInsertFeedback?.(tag)?.trim() || undefined;
if (replyModule) {
const title =
replyModule.match(/^#\s*模块\s*·\s*(.+)$/m)?.[1]?.trim() || tag;
replyModule.match(/^#\s*(?:模块|本轮关注)\s*·\s*(.+)$/m)?.[1]?.trim() || tag;
moduleNamesThisStep.push(title);
}
const requiredGaps = isDictateOpeningTag(tag)
? requiredCreationPlanGaps({
planRaw: productsBeforeWrite.find(
(product) => product.tag === CREATION_PLAN_TAG,
)?.content,
catalog: params.moduleCatalog,
products: [
...productsBeforeWrite.filter((product) => product.tag !== tag),
{ tag, content, order },
],
})
: [];
return {
callId: call.id,
content: JSON.stringify({
ok: true,
position: tag,
...(isOpening
? {
runtime_prepared_before_opening: true,
preparation_mode: explicitlyPrepared
? "prepare_opening"
: "implicit_fallback",
}
: {}),
...(order !== undefined ? { order } : {}),
...(requiredGaps.length
? {
warning: `仍有“必须”能力未落:${requiredGaps.join("、")}。开场白已写入,请在准备开玩前提醒用户补齐或调整方案。`,
missing_required_module_ids: requiredGaps,
}
: {}),
...(replyModule
? {
reply_module: replyModule,
@@ -376,33 +731,36 @@ export async function runDictateTurn(params: {
}
}
if (toolSteps >= DICTATE_DRIVER_MAX_ITERATIONS - 2) {
noteTurnLimit(results);
}
return { kind: "continue" as const, results };
},
});
let reply = "";
if (run.stop.kind === "text") {
reply = run.stop.content.trim();
}
if (!reply) {
if (wroteTags.length || deletedTags.length) {
const parts: string[] = [];
if (wroteTags.length) {
parts.push(`已写入:${[...new Set(wroteTags)].join("、")}`);
}
if (deletedTags.length) {
parts.push(`已删除:${[...new Set(deletedTags)].join("、")}`);
}
reply = `${parts.join(";")}。还有要补充的吗?`;
} else if (clearedDialogue) {
reply = "对话已清空,产物都还在。可以继续说。";
} else {
reply = "我在听,请继续说你想要的体验或设定。";
}
} catch (err) {
if (!(err instanceof DriverIterationLimitError)) throw err;
return {
reply: composeDictateReply({
wroteTags,
deletedTags,
clearedDialogue,
capped: true,
}),
iterations: err.iterations,
wroteTags: [...new Set(wroteTags)],
deletedTags: [...new Set(deletedTags)],
clearedDialogue,
};
}
return {
reply,
reply: composeDictateReply({
stopText: run.stop.kind === "text" ? run.stop.content : "",
wroteTags,
deletedTags,
clearedDialogue,
}),
iterations: run.iterations,
wroteTags: [...new Set(wroteTags)],
deletedTags: [...new Set(deletedTags)],
@@ -410,6 +768,52 @@ export async function runDictateTurn(params: {
};
}
function noteTurnLimit(
results: Array<{ callId: string; content: string }>,
): void {
const last = results.at(-1);
if (!last) return;
try {
const parsed = JSON.parse(last.content) as Record<string, unknown>;
parsed.turn_limit = DICTATE_TURN_LIMIT_HINT;
last.content = JSON.stringify(parsed);
} catch {
last.content = `${last.content}\n${DICTATE_TURN_LIMIT_HINT}`;
}
}
function composeDictateReply(params: {
stopText?: string;
wroteTags: string[];
deletedTags: string[];
clearedDialogue: boolean;
capped?: boolean;
}): string {
let reply = params.stopText?.trim() ?? "";
if (!reply) {
if (params.wroteTags.length || params.deletedTags.length) {
const parts: string[] = [];
if (params.wroteTags.length) {
parts.push(`已写入:${[...new Set(params.wroteTags)].join("、")}`);
}
if (params.deletedTags.length) {
parts.push(`已删除:${[...new Set(params.deletedTags)].join("、")}`);
}
reply = `${parts.join(";")}。还有要补充的吗?`;
} else if (params.clearedDialogue) {
reply = "对话已清空,产物都还在。可以继续说。";
} else if (params.capped) {
reply = "这轮工具次数已用完,还没有新的产物。可以说要先补哪一块。";
} else {
reply = "我在听,请继续说你想要的体验或设定。";
}
}
if (params.capped && !reply.includes("工具次数已用完")) {
reply = `${reply} 这轮工具次数已用完,其余下一轮继续。`;
}
return reply;
}
function safeParseArgs(raw: string): Record<string, unknown> {
try {
const parsed = JSON.parse(raw || "{}");
@@ -421,3 +825,111 @@ function safeParseArgs(raw: string): Record<string, unknown> {
return {};
}
}
function requiresModuleRead(moduleId: string, auto?: boolean): boolean {
if (auto) return false;
return !new Set([
"narrative",
"variable-context",
"context-order",
"worker-spec",
"refine",
]).has(moduleId);
}
function resolveInsertLayer(params: {
moduleId: string;
catalogLayer?: "intermediate" | "final";
content: string;
}): "intermediate" | "final" {
if (params.moduleId === "generation-rules") {
const lifecycle = normalizeGenerationRuleLifecycle(
extractGenerationRuleLifecycle(params.content),
);
if (lifecycle === "seed_only") return "intermediate";
if (lifecycle === "runtime_only" || lifecycle === "seed_and_runtime") {
return "final";
}
}
return params.catalogLayer ?? "final";
}
function extractGenerationRuleLifecycle(
content: string,
): string | undefined {
try {
const parsed = JSON.parse(content) as {
正文?: { 生命周期?: unknown };
生命周期?: unknown;
};
const raw = parsed.正文?.生命周期 ?? parsed.生命周期;
return typeof raw === "string" ? raw.trim() : undefined;
} catch {
return undefined;
}
}
function normalizeGenerationRuleLifecycle(
raw: string | undefined,
): string | undefined {
if (!raw) return undefined;
if (raw === "只预生成" || raw === "seed_only") return "seed_only";
if (raw === "仅游玩期" || raw === "runtime_only") return "runtime_only";
if (raw === "预生成并游玩期" || raw === "seed_and_runtime") {
return "seed_and_runtime";
}
return raw;
}
function parseAndValidateSelfScore(
raw: unknown,
expectedNames: readonly string[],
): { value?: DictateSelfScore } | { error: string } {
if (expectedNames.length === 0) return {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { error: `self_score 必填;维度:${expectedNames.join("、")}` };
}
const dims = (raw as { dims?: unknown }).dims;
if (!Array.isArray(dims)) {
return { error: "self_score.dims 必须是数组" };
}
const parsed: DictateSelfScore["dims"] = [];
for (const item of dims) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return { error: "self_score.dims 含无效条目" };
}
const row = item as Record<string, unknown>;
const name = typeof row.name === "string" ? row.name.trim() : "";
const score = Number(row.score);
const gap = typeof row.gap === "string" ? row.gap.trim() : "";
const options = Array.isArray(row.options)
? row.options.map(String).map((option) => option.trim()).filter(Boolean)
: [];
if (!name || !Number.isInteger(score) || score < 1 || score > 5) {
return { error: "self_score 每项须含合法 name 与 1~5 整数 score" };
}
if (score <= 3 && !gap) {
return { error: `self_score「${name}」不高于 3 分时必须写 gap` };
}
if (options.length > 0 && (options.length < 2 || options.length > 3)) {
return { error: `self_score「${name}」的 options 必须为 2~3 项` };
}
parsed.push({
name,
score,
...(gap ? { gap } : {}),
...(options.length ? { options } : {}),
});
}
const actual = parsed.map((dim) => dim.name);
if (
actual.length !== expectedNames.length ||
expectedNames.some((name) => !actual.includes(name)) ||
actual.some((name) => !expectedNames.includes(name))
) {
return {
error: `self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
};
}
return { value: { dims: parsed } };
}
+19 -3
View File
@@ -5,11 +5,25 @@ export const DICTATE_MODE_VALUE = "dictate";
/** 产物相对序写在黑板 item.metadata 里 */
export const DICTATE_ORDER_META_KEY = "dictateOrder";
export const DICTATE_LAYER_META_KEY = "dictateLayer";
export const DICTATE_SELF_SCORE_META_KEY = "dictateSelfScore";
export type DictateSelfScore = {
dims: Array<{
name: string;
score: number;
gap?: string;
options?: string[];
}>;
};
export type DictateProduct = {
tag: string;
content: string;
/** 相对顺序:负数靠前,正数靠后;缺省时按 tag 启发式 */
/** 产物用途层;目录默认值可被具体产物生命周期覆盖 */
layer?: "intermediate" | "final";
selfScore?: DictateSelfScore;
/** 同一变化频率组内的重要性顺序:负数靠前,正数更靠近实际使用位置 */
order?: number;
};
@@ -31,13 +45,15 @@ export function isAllowedProductTag(tag: string): boolean {
}
/**
* 未显式给 order 时的默认相对序。
* 越稳定/重要越靠前(更负);越常改越靠后(更正)。
* 未显式给 order 时的默认重要性序。
* 变化频率由产物的「稳变」另行分组;同组内基础材料在前,
* 越接近本轮生成合同的内容越靠后(更正)。
* 可增殖拆分 tag(基名#槽)按基名归类。
*/
export function defaultDictateOrder(tag: string): number {
const t = tag.trim();
const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
if (family === "设计.本局创作方案") return -50;
if (family === "用户.需求" || t === "用户.需求") return -40;
if (family === "设计.变量目录" || family === "设计.变量映射") return -25;
if (family === "设计.模仿范例" || family === "设计.模仿要点") return -28;
+65
View File
@@ -136,11 +136,63 @@ export function looksLikeProductJson(value: unknown): boolean {
return PRODUCT_HINT_KEYS.some((k) => k in row);
}
/**
* 根对象被提前写成 `} , "下一键"` 时丢掉这个 `}`。
* 只在 JSON.parse 失败后使用;合法对象不会出现这种根级衔接。
*/
export function repairPrematureRootClose(s: string): string {
const text = String(s || "");
let out = "";
let depth = 0;
let inString = false;
let escaped = false;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (inString) {
out += c;
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inString = false;
continue;
}
if (c === '"') {
inString = true;
out += c;
continue;
}
if (c === "{") {
depth += 1;
out += c;
continue;
}
if (c === "}") {
if (depth === 1 && continuesAsSiblingKey(text, i + 1)) continue;
if (depth > 0) depth -= 1;
out += c;
continue;
}
out += c;
}
return out;
}
function continuesAsSiblingKey(text: string, from: number): boolean {
let j = from;
while (j < text.length && /\s/.test(text[j]!)) j += 1;
if (text[j] !== ",") return false;
j += 1;
while (j < text.length && /\s/.test(text[j]!)) j += 1;
return text[j] === '"';
}
function parseSlice(slice: string): unknown {
const repaired = repairPrematureRootClose(slice);
return (
tryParse(slice) ??
tryParse(softenJsonText(slice)) ??
tryParse(softenJsonText(convertStructuralSmartQuotes(slice))) ??
tryParse(repaired) ??
tryParse(softenJsonText(repaired)) ??
tryParse(repairTruncatedJsonObject(slice)) ??
tryParse(softenJsonText(repairTruncatedJsonObject(slice)))
);
@@ -224,3 +276,16 @@ export function selectJsonPayload(content: string, reasoning?: string): string {
if (thinkOk) return think;
return body || think;
}
/** insert 的 content 若是 JSON 文本,抽成规范对象再落盘。散文原样保留。 */
export function canonicalizeInsertedContent(content: string): string {
const trimmed = content.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return content;
const parsed = tryParseJsonDoc(trimmed);
if (!parsed || typeof parsed !== "object") return content;
if (!Array.isArray(parsed)) {
const row = parsed as Record<string, unknown>;
if (row.schema === "context-fragment-v1") row.schema = "context-fragment.v1";
}
return JSON.stringify(parsed, null, 2);
}
+1 -1
View File
@@ -96,7 +96,7 @@ function buildSystemPrompt(params: {
任务:只写短、可执行的禁区/纠偏/默认取向;不写文风遣词、不写世界观与剧情。产物供「用户需求」节点选用。
任务:只写短、可执行的禁区/纠偏/默认取向;不写文风遣词、不写世界观与剧情。产物供绑定偏好库的能力按需选用。
+12 -1
View File
@@ -58,6 +58,17 @@ export type AgentDriver = {
export const DEFAULT_DRIVER_MAX_ITERATIONS = 12;
/** 工具循环用尽轮次仍未收口。 */
export class DriverIterationLimitError extends Error {
readonly iterations: number;
constructor(maxIterations: number) {
super(`Driver exceeded ${maxIterations} iterations`);
this.name = "DriverIterationLimitError";
this.iterations = maxIterations;
}
}
function assistantMessageFromToolCalls(
content: string | null,
toolCalls: DriverToolCall[],
@@ -158,7 +169,7 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
}
}
throw new Error(`Driver exceeded ${maxIterations} iterations`);
throw new DriverIterationLimitError(maxIterations);
},
};
}
+4 -9
View File
@@ -341,9 +341,9 @@ export async function handleBooksApi(
title?: string;
/** 编排器包 id(registry name) */
orchestratorId?: string;
/** 用户手动选定的配方 id(内部 recipe) */
/** 旧客户端可传;新建作品改由创作对话判断路径 */
recipeId?: string;
/** recipe=配方流(默认);dictate=转述整理直填 */
/** 旧客户端兼容;新建作品统一使用 dictate */
creationMode?: "recipe" | "dictate";
};
const skills = await listSkills();
@@ -363,16 +363,11 @@ export async function handleBooksApi(
orchestratorId: director.name,
orchestratorName: director.name,
});
const creationMode =
body.creationMode === "dictate" ? "dictate" : "recipe";
if (!body.recipeId?.trim()) {
json(res, 400, { error: "请选择配方" });
return true;
}
const creationMode = "dictate";
const session = await sessionManager.createForBook(
book.id,
director.name,
body.recipeId.trim(),
body.recipeId?.trim() || undefined,
{ creationMode },
);
json(res, 201, { book: getBook(book.id) ?? book, session });
+102 -17
View File
@@ -133,6 +133,7 @@ import {
findRecipeCatalogEntry,
formatCreationFlowForUser,
formatDictateRecipeBrief,
formatRecipeExamplesForCreationPlan,
formatModuleCatalogForDictate,
findModuleByName,
findStepByRef,
@@ -167,7 +168,11 @@ import {
import {
CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE,
DICTATE_LAYER_META_KEY,
DICTATE_ORDER_META_KEY,
DICTATE_SELF_SCORE_META_KEY,
applyDictatePlayBind,
buildDictateModuleReadIndex,
buildDictateInsertFeedbackIndex,
defaultDictateOrder,
extractDictateDialogue,
@@ -559,9 +564,9 @@ export class SessionManager {
content: expanded,
source: "user",
});
const first = s.messages[0];
if (first?.title === "开场白") {
s.messages[0] = this.playOpeningMessage(expanded);
const openingAt = s.messages.findIndex((m) => m.title === "开场白");
if (openingAt >= 0) {
s.messages[openingAt] = this.playOpeningMessage(expanded);
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
@@ -788,7 +793,17 @@ export class SessionManager {
typeof rawOrder === "number" && Number.isFinite(rawOrder)
? rawOrder
: undefined;
products.push({ tag: e.tag, content, order });
const layer = item?.metadata?.[DICTATE_LAYER_META_KEY];
const selfScore = item?.metadata?.[DICTATE_SELF_SCORE_META_KEY];
products.push({
tag: e.tag,
content,
order,
...(layer === "intermediate" || layer === "final" ? { layer } : {}),
...(selfScore && typeof selfScore === "object"
? { selfScore: selfScore as DictateProduct["selfScore"] }
: {}),
});
}
return sortDictateProducts(products);
}
@@ -837,20 +852,32 @@ export class SessionManager {
recipeView,
recipeOptions: s.recipeOptions,
});
const moduleGuide = await resolveDictateModuleGuideForSession({
const moduleCatalog = await resolveDictateModuleCatalogForSession({
skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog: s.moduleCatalog,
});
if (moduleCatalog) s.moduleCatalog = moduleCatalog;
const moduleGuide = moduleCatalog
? formatModuleCatalogForDictate(moduleCatalog)
: undefined;
const insertFeedbackIndex = await resolveDictateInsertFeedbackIndexForSession({
skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog: s.moduleCatalog,
moduleCatalog,
});
const repeatableFamilies = [
...(s.moduleCatalog?.modules ?? [])
...(moduleCatalog?.modules ?? [])
.filter((m) => m.repeatable === true && m.artifact?.trim())
.map((m) => m.artifact!.trim()),
...DICTATE_OPENING_REPEATABLE_FAMILIES,
];
const recipeExamples = await resolveDictateRecipeExamplesForSession({
skillName: s.runtime.getActiveSkill()?.name,
recipeOptions: s.recipeOptions,
});
const moduleReadIndex = await resolveDictateModuleReadIndexForSession({
skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog,
});
const result = await runDictateTurn({
llm,
@@ -858,10 +885,13 @@ export class SessionManager {
recipeName: recipeView?.name,
recipeBrief,
moduleGuide,
recipeExamples,
moduleCatalog,
moduleReadIndex,
repeatableFamilies,
handlers: {
listProducts: () => this.listDictateProducts(s),
writeProduct: (tag, content, order) => {
writeProduct: (tag, content, order, metadata) => {
const board = s.runtime.getBlackboard();
let writeContent = content;
if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) {
@@ -894,6 +924,12 @@ export class SessionManager {
metadata: {
...(existing?.metadata ?? {}),
[DICTATE_ORDER_META_KEY]: resolved,
...(metadata?.layer
? { [DICTATE_LAYER_META_KEY]: metadata.layer }
: {}),
...(metadata?.selfScore
? { [DICTATE_SELF_SCORE_META_KEY]: metadata.selfScore }
: {}),
},
});
},
@@ -912,6 +948,9 @@ export class SessionManager {
});
},
deleteTag: (tag) => s.runtime.getBlackboard().deleteByTag(tag),
prepareOpening: () => {
applyDictatePlayBind(s.runtime.getBlackboard());
},
clearDialogue: () => {
const kept = this.listDictateProducts(s);
const lastUser = [...s.messages]
@@ -962,7 +1001,9 @@ export class SessionManager {
},
});
this.clearAgentThinking(s.runtime.getSession().id);
const sessionId = s.runtime.getSession().id;
const thinking = this.agentThinkingLive.get(sessionId)?.trim() || undefined;
this.clearAgentThinking(sessionId);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
@@ -977,6 +1018,7 @@ export class SessionManager {
actor: "orchestrator",
title: "转述整理",
body: result.reply,
...(thinking ? { thinking } : {}),
});
s.runtime.enterDictateIntake();
}
@@ -4346,27 +4388,70 @@ async function resolveDictateRecipeBriefForSession(params: {
return view.declaration?.trim() || undefined;
}
/** 对话落盘:注入能力何时落盘(modules meta);优先会话已载 catalog */
async function resolveDictateModuleGuideForSession(params: {
/** 对话落盘:加载可混合的参考案例,不把任何案例当作已选路径。 */
async function resolveDictateRecipeExamplesForSession(params: {
skillName?: string | null;
recipeOptions?: RecipeCatalogEntry[];
}): Promise<string | undefined> {
const entries = (params.recipeOptions ?? []).filter(
(entry) => entry.family === "dictate",
);
const skillName = params.skillName?.trim();
if (!skillName || entries.length === 0) return undefined;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return undefined;
const details = await Promise.all(
entries.map((entry) => loadRecipeDetail(packRoot, entry)),
);
return formatRecipeExamplesForCreationPlan(details) || undefined;
} catch (err) {
console.error("[会话] 加载本局创作方案参考案例失败", err);
return undefined;
}
}
/** 对话落盘:取得含 prompt meta 的能力目录;优先复用会话已载目录 */
async function resolveDictateModuleCatalogForSession(params: {
skillName?: string | null;
moduleCatalog?: ModuleCatalog | null;
}): Promise<string | undefined> {
}): Promise<ModuleCatalog | null> {
let catalog = params.moduleCatalog ?? null;
if (!catalog?.modules?.length) {
const skillName = params.skillName?.trim();
if (!skillName) return undefined;
if (!skillName) return null;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return undefined;
if (!packRoot) return null;
catalog = await loadModuleCatalog(packRoot);
} catch (err) {
console.error("[会话] 加载转述能力目录失败", err);
return undefined;
return null;
}
}
if (!catalog?.modules?.length) return undefined;
return formatModuleCatalogForDictate(catalog);
return catalog?.modules?.length ? catalog : null;
}
async function resolveDictateModuleReadIndexForSession(params: {
skillName?: string | null;
moduleCatalog?: ModuleCatalog | null;
}) {
const skillName = params.skillName?.trim();
if (!skillName || !params.moduleCatalog?.modules.length) return undefined;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return undefined;
return await buildDictateModuleReadIndex({
skillPackRoot: packRoot,
catalog: params.moduleCatalog,
});
} catch (err) {
console.error("[会话] 构建能力读取索引失败", err);
return undefined;
}
}
/** 对话落盘:insert(tag) → 用户可见规范索引 */
+179 -24
View File
@@ -11,9 +11,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import {
collectLibraryIdsFromModules,
formatBoundLibrariesForPrompt,
} from "../libraries/index.js";
buildRepeatableProductTag,
dictateProductSlot,
} from "../dictate/repeatable-tags.js";
import { tryParseJsonDoc } from "../parse/json-doc.js";
export const CREATION_FLOW_TAG = "设计.创作流程";
@@ -179,6 +179,10 @@ export type ModuleCatalogEntry = {
declaration: string;
/** 执行期产物 tag(流程 JSON 不写;程序映射) */
artifact: string;
/** 默认产物层;生成规则可在运行时按 lifecycle_intent 覆盖 */
layer?: "intermediate" | "final";
/** 默认游玩挂载目标;具体字段仍由投影器裁剪 */
mount?: string[];
/**
* 可选:默认可反复编排进流程(如生成规则、具体实例)。
* 程序不硬拦;给编排与校验提示。
@@ -286,6 +290,16 @@ export type RecipeDetail = {
brief?: string;
/** 适用什么体验/任务 */
when?: string;
/** 对话落盘案例:可识别的用户信号 */
signals?: string[];
/** 对话落盘案例:可混合、可改写的方法,不表示步骤顺序 */
method?: string[];
/** 对话落盘案例:能力档位的默认倾向,不覆盖本局判断 */
tierTendencies?: Array<{
module_id: string;
tier: "必须" | "有必要" | "有一定效果" | "没有意义";
why: string;
}>;
/** 整套设计方法的核心思路与最终目标 */
core?: string;
/** 设计流程:如何增量选型、何时收成(字符串或条目列表) */
@@ -961,6 +975,11 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
const params = parseModuleParamSpecs(m.params);
const intake = parseModuleIntake(m.intake);
const libraries = parseModuleLibraries(m.libraries);
const layer =
m.layer === "intermediate" || m.layer === "final"
? m.layer
: undefined;
const mount = parseModuleLibraries(m.mount);
modules.push({
id,
name,
@@ -974,6 +993,8 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
...(params ? { params } : {}),
...(intake ? { intake } : {}),
...(libraries ? { libraries } : {}),
...(layer ? { layer } : {}),
...(mount ? { mount } : {}),
});
}
if (modules.length === 0) return null;
@@ -1027,6 +1048,7 @@ export const MODULE_SECTION_IDS = [
"principles",
"probe",
"output",
"score",
"checklist",
"examples",
] as const;
@@ -1158,6 +1180,7 @@ export function formatModulePromptForLlm(promptMd: string): string {
"principles",
"probe",
"output",
"score",
"checklist",
"examples",
];
@@ -1303,18 +1326,25 @@ function slugFromName(name: string): string {
具体实例: "concrete-instances",
拓扑图谱: "topology",
回复呈现: "status-bar",
"0层回复呈现": "zero-layer-status",
设计状态栏: "status-bar",
设计监控栏: "status-bar",
变量设计与更新规则: "variable-design",
信息可见范围: "variable-context",
变量控制上下文: "variable-context",
设计回复格式: "reply-format",
正文组成: "reply-format",
"0层正文组成": "zero-layer-reply-format",
随机范围整理: "random-range",
游玩执行方式: "worker-spec",
游玩拓扑: "worker-spec",
"Worker 规格": "worker-spec", // 旧称,等同游玩拓扑
上下文排序: "context-order",
运行配置组装: "refine",
细化终稿: "refine",
开场白与开场变量: "opening-setup",
开场白: "opening-setup",
"0层开场白与初态": "zero-layer-opening-setup",
};
return map[name] ?? name;
}
@@ -1387,6 +1417,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
/** 对话落盘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */
const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
"variable-context",
"worker-spec",
"context-order",
"refine",
@@ -1420,19 +1451,17 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
});
const head = [
"【能力 · 何时落盘】",
"对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落该能力,改为短确认或问挡住的 1 点。",
"同轮可多次落盘:凡本轮材料已够且「何时用」成立的能力,都可 insert / declare_*(可并行);每个 insert 各回 reply_module,可见回复分段附带。",
"不抢跑:条件未成立的下游本轮不落,放进「建议下一刀」。",
"对照下方「何时用 / 何时不用 / 边界」与已有产物:用户这句已经说清、条件也成立的能力才 insert(或 declare_*),一轮通常 2~3 个。说清指用户已经给出要写的内容,不是能从题材推演出来。",
"「必须」和「有必要」里用户还没提到的:本轮不落盘,按已有材料给 2~3 个选项询问,选定后再写。含糊或未到时机的下游同样先问,不先写。",
"同轮写入的每份产物各回 reply_module,可见回复综合成一次。",
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
"标〔收口〕的开场白:美学与必要上游齐后再写;未齐则先补上游。",
"标〔可反复〕的(生成规则/具体实例等):每条增殖用「基名#唯一id」拆分落盘(如 设计.具体实例#lei-ying);禁止反复 insert 基名覆盖旧条。改某一条则 insert 同一完整 tag。",
"标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。",
"标〔可反复〕的(生成规则、具体实例、开场白、拓扑等):每条单独落盘,用「基名#唯一id」(生成规则用规则名,具体实例用名称,开场白用场景短码)。同完整 tag 才覆盖,禁止反复写基名盖掉别的条。开场全部保留;游玩时左右切换,选中的一条作为 0 层。",
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
lines.join("\n"),
].join("\n");
const libIds = collectLibraryIdsFromModules(dictateModules);
const libBlock = formatBoundLibrariesForPrompt(libIds).trim();
return libBlock ? `${head}\n\n${libBlock}` : head;
return head;
}
function dictateLandHint(m: ModuleCatalogEntry): string {
@@ -1443,17 +1472,20 @@ function dictateLandHint(m: ModuleCatalogEntry): string {
return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」";
}
if (m.id === "opening-setup") {
return "insert 「设计.开场白」(对话落盘简化 tag;初值与开场同真相)";
return "insert 「设计.开场白#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
}
if (m.id === "zero-layer-opening-setup") {
return "insert 「设计.0层开场白与初态#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
}
if (m.artifact?.trim() && m.repeatable) {
const art = m.artifact.trim();
const slotHint =
m.id === "generation-rules"
? "rule_id"
? "规则名"
: m.id === "concrete-instances"
? "batch_id或姓名"
? "名称"
: "唯一id";
return `insert 「${art}#${slotHint}」——可增殖:每条对象/批次各占一 tag;同完整 tag 才覆盖改写,禁止反复写基名「${art}」覆盖`;
return `insert 「${art}#${slotHint}」——每条单独落盘;同完整 tag 才覆盖,禁止反复写基名「${art}」盖掉别的条`;
}
if (m.artifact?.trim()) {
return `insert 「${m.artifact.trim()}」`;
@@ -1555,6 +1587,9 @@ export function parseRecipeYaml(
const core = coerceYamlTextField(row.core);
const process = normalizeRecipeListOrText(row.process);
const principles = normalizeRecipeListOrText(row.principles);
const signals = normalizeStringList(row.signals);
const method = normalizeStringList(row.method);
const tierTendencies = parseRecipeTierTendencies(row.tier_tendencies);
const hint =
typeof row.hint === "string" && row.hint.trim()
? row.hint.trim()
@@ -1581,6 +1616,9 @@ export function parseRecipeYaml(
declaration: meta.declaration,
...(brief ? { brief } : {}),
when,
...(signals ? { signals } : {}),
...(method ? { method } : {}),
...(tierTendencies ? { tierTendencies } : {}),
...(core ? { core } : {}),
...(process ? { process } : {}),
...(principles ? { principles } : {}),
@@ -1603,6 +1641,37 @@ function normalizeRecipeListOrText(
return undefined;
}
function normalizeStringList(raw: unknown): string[] | undefined {
if (!Array.isArray(raw)) return undefined;
const items = raw
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
return items.length ? items : undefined;
}
function parseRecipeTierTendencies(
raw: unknown,
): RecipeDetail["tierTendencies"] | undefined {
if (!Array.isArray(raw)) return undefined;
const tiers = new Set(["必须", "有必要", "有一定效果", "没有意义"]);
const out: NonNullable<RecipeDetail["tierTendencies"]> = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const moduleId =
typeof row.module_id === "string" ? row.module_id.trim() : "";
const tier = typeof row.tier === "string" ? row.tier.trim() : "";
const why = typeof row.why === "string" ? row.why.trim() : "";
if (!moduleId || !tiers.has(tier) || !why) continue;
out.push({
module_id: moduleId,
tier: tier as NonNullable<RecipeDetail["tierTendencies"]>[number]["tier"],
why,
});
}
return out.length ? out : undefined;
}
export async function loadRecipeDetail(
skillPackRoot: string,
entry: RecipeCatalogEntry,
@@ -1719,6 +1788,42 @@ export function formatDictateRecipeBrief(detail: RecipeDetail): string {
return lines.join("\n").trim();
}
/** 本局创作方案使用的旧配方案例目录:只给识别信号、方法与档位倾向,不提供固定流程。 */
export function formatRecipeExamplesForCreationPlan(
details: readonly RecipeDetail[],
): string {
return details
.filter(
(detail) =>
detail.signals?.length ||
detail.method?.length ||
detail.tierTendencies?.length,
)
.map((detail) => {
const lines = [
`- ${detail.id}(${detail.name}):${detail.brief ?? detail.declaration}`,
];
if (detail.signals?.length) {
lines.push(` 适用信号:${detail.signals.join(";")}`);
}
if (detail.method?.length) {
lines.push(` 方法:${detail.method.join(";")}`);
}
if (detail.tierTendencies?.length) {
lines.push(
` 档位倾向:${detail.tierTendencies
.map(
(item) =>
`${item.module_id}=${item.tier}(${item.why})`,
)
.join(";")}`,
);
}
return lines.join("\n");
})
.join("\n");
}
/** 注入 design-flow:用户已选配方(方法论 + 开局起点) */
export function formatSelectedRecipeForAgent(detail: RecipeDetail): string {
const lines: string[] = [
@@ -1915,6 +2020,7 @@ export function formatFlowProgressForAgent(params: {
const closerPending =
catalog && flow
? flow.steps.filter((step) => {
if (isPrototypeStep(step)) return false;
const mod = catalog.modules.find((m) => m.name === step.name);
return Boolean(mod?.closer) && !isStepAccepted(step, accepted);
})
@@ -2146,6 +2252,39 @@ export function artifactTagForStep(
return catalog?.modules.find((m) => m.name === name)?.artifact ?? null;
}
/**
* 可增殖实例的落盘 tag。生成规则、具体实例、开场白、拓扑各自占一条,
* 不写基名,避免后写的实例盖掉先写的。
* 槽位优先用步骤 id 的 #后缀(稳定),否则用规则名 / 这一条。
*/
export function repeatableInstanceProductTag(input: {
module: Pick<ModuleCatalogEntry, "id" | "name" | "artifact" | "repeatable">;
step: Pick<CreationFlowStep, "id" | "role" | "params">;
}): string | null {
if (input.step.role === "prototype") return null;
const repeatable =
input.module.repeatable === true ||
input.module.id === "opening-setup" ||
input.module.id === "zero-layer-opening-setup";
if (!repeatable) return null;
const family =
input.module.id === "opening-setup"
? "设计.开场白"
: input.module.id === "zero-layer-opening-setup"
? "设计.0层开场白与初态"
: input.module.artifact?.trim() || "";
if (!family) return null;
const slot =
dictateProductSlot(input.step.id) ||
flowParamText(input.step.params, "rule_id") ||
flowParamText(input.step.params, "batch_goal") ||
(input.step.id.trim() && input.step.id.trim() !== input.module.name
? input.step.id.trim()
: "");
if (!slot) return null;
return buildRepeatableProductTag(family, slot);
}
export function finalizeCreationFlow(
flow: CreationFlow,
catalog: ModuleCatalog | null | undefined,
@@ -2239,6 +2378,9 @@ function titleFromAcceptedContent(content: unknown): string | undefined {
const row = asTitleRecord(content);
if (!row) return undefined;
const body = asTitleRecord(row.正文) ?? row;
if (typeof body.名称 === "string" && body.名称.trim()) {
return clampFlowTitle(body.名称);
}
const pinned = asTitleRecord(body.本步参数);
if (typeof pinned?.target === "string" && pinned.target.trim()) {
return clampFlowTitle(pinned.target);
@@ -2253,6 +2395,9 @@ function titleFromAcceptedContent(content: unknown): string | undefined {
if (typeof rec?.对象 === "string" && rec.对象.trim()) {
return clampFlowTitle(rec.对象);
}
if (typeof rec?.名称 === "string" && rec.名称.trim()) {
return clampFlowTitle(rec.名称);
}
}
if (typeof row.brief === "string" && row.brief.trim() && row.brief.trim().length <= FLOW_TITLE_MAX) {
return row.brief.trim();
@@ -2674,7 +2819,7 @@ export function listRepeatableSpawns(
acceptedStepIds: readonly string[],
): CreationFlowSpawnView[] {
return catalogModulesForIntake(catalog, "recipe")
.filter((m) => m.repeatable === true && !m.closer)
.filter((m) => m.repeatable === true)
.filter(
(m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)),
)
@@ -2776,7 +2921,8 @@ export function spawnInstanceFromPrototype(params: {
{ ...params.flow, steps },
params.catalog,
);
if (closerAt >= 0) {
// 开场自身是可增殖收口:新实例跟在原型后面,不要写回原型的 depends_on
if (closerAt >= 0 && steps[closerAt]!.id !== prototype.id) {
const closer = steps[closerAt]!;
if (!closer.depends_on.includes(id)) {
steps[closerAt] = {
@@ -2958,8 +3104,11 @@ export function spawnRepeatableCreationStep(params: {
const mod = findModuleByName(params.catalog, name);
if (!mod) return { error: `未知能力:${name}` };
if (mod.repeatable !== true) return { error: `「${name}」不能反复新开` };
if (isCloserModule(mod, name)) return { error: `「${name}」是收口,不能增殖` };
if (mod.repeatable !== true) {
return isCloserModule(mod, name)
? { error: `「${name}」是收口,不能增殖` }
: { error: `「${name}」不能反复新开` };
}
const blocked = repeatableSpawnBlockedReason(
params.flow,
params.catalog,
@@ -3065,10 +3214,14 @@ export function dependencyArtifactTags(
if (!catalog) return [];
const tags: string[] = [];
for (const dep of step.depends_on) {
const depName = flow
? findStepByRef(flow, dep)?.name ?? dep
: dep;
const art = artifactTagForStep(depName, catalog);
const depStep = flow ? findStepByRef(flow, dep) : undefined;
const depName = depStep?.name ?? dep;
const mod = findModuleByName(catalog, depName);
const slotted =
depStep && mod
? repeatableInstanceProductTag({ module: mod, step: depStep })
: null;
const art = slotted || artifactTagForStep(depName, catalog);
if (art) tags.push(art);
}
return [...new Set(tags)];
@@ -3130,7 +3283,9 @@ export async function resolveDesignStepBinding(params: {
const inherit =
Boolean(params.inheritExisting) || isReviseStep(step);
const inheritTag = inherit ? module.artifact : null;
const inheritTag = inherit
? repeatableInstanceProductTag({ module, step }) ?? module.artifact ?? null
: null;
const opening =
inherit || module.auto
? null
+16 -8
View File
@@ -515,6 +515,7 @@ export async function loadWorkerSkillWithContext(
if (workerId.trim() === "design-step" && skill.skillPackRoot) {
const {
resolveDesignStepBinding,
repeatableInstanceProductTag,
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
formatPriorArtifactContext,
@@ -550,18 +551,20 @@ export async function loadWorkerSkillWithContext(
const { formatPlayAgentRosterForPrompt } = await import("./play-slots.js");
rosterBlock = `${formatPlayAgentRosterForPrompt()}\n\n`;
}
if (binding.module.id === "reply-format") {
const {
loadPresentShellCatalog,
formatPresentShellsForPrompt,
} = await import("./present-shell-catalog.js");
const shells = await loadPresentShellCatalog(
if (
binding.module.id === "reply-format" ||
binding.module.id === "zero-layer-reply-format"
) {
const { loadShellRefCatalog, formatShellRefsForPrompt } = await import(
"./shell-ref-catalog.js"
);
const shells = await loadShellRefCatalog(
skill.skillPackRoot,
skillsRoot,
);
const parts: string[] = [];
if (shells.length) {
parts.push(formatPresentShellsForPrompt(shells));
parts.push(formatShellRefsForPrompt(shells));
}
const {
loadMarkdownSafeSubsetCatalog,
@@ -605,7 +608,12 @@ export async function loadWorkerSkillWithContext(
...(inheritTag ? [inheritTag] : []),
];
const inputTags = [...new Set(baseInputs)];
const outputTags = [binding.module.artifact];
const outputTags = [
repeatableInstanceProductTag({
module: binding.module,
step: binding.step,
}) ?? binding.module.artifact,
];
const depSegments = depTags.map((tag, i) => ({
id: `dep-${i}`,
tier: "static" as const,
+21 -7
View File
@@ -55,12 +55,20 @@ function findRuleBody(
): Record<string, unknown> | undefined {
const body = parsed.正文;
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
const rules = (body as Record<string, unknown>).rules;
const bodyRec = body as Record<string, unknown>;
const directId =
asString(bodyRec.规则名) ??
asString(bodyRec.rule_id) ??
asString(bodyRec.id) ??
asString(bodyRec.规则id);
if (directId === ruleId) return bodyRec;
const rules = bodyRec.rules;
if (!Array.isArray(rules)) return undefined;
for (const row of rules) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>;
const id =
asString(r.规则名) ??
asString(r.rule_id) ??
asString(r.id) ??
asString(r.规则id);
@@ -94,16 +102,22 @@ export function buildChanceBatchFromGenerationRule(
if (!isElementPool(pool)) continue;
const poolId =
asString(pool.pool_id) ?? asString(pool.池id) ?? asString(pool.名称);
const entries = (pool.条目 ?? pool.entries) as PoolEntry[] | undefined;
const entries = (pool.条目 ?? pool.entries) as unknown[] | undefined;
if (!poolId || !Array.isArray(entries) || !entries.length) continue;
const items = entries
.map((e, idx) => {
const id = asString(e.id) ?? asString(e.内容) ?? `item-${idx}`;
if (typeof e === "string") {
const id = e.trim();
return id ? { id } : undefined;
}
if (!e || typeof e !== "object" || Array.isArray(e)) return undefined;
const row = e as PoolEntry;
const id = asString(row.id) ?? asString(row.内容) ?? `item-${idx}`;
const weight =
typeof e.权重 === "number"
? e.权重
: typeof e.weight === "number"
? e.weight
typeof row.权重 === "number"
? row.权重
: typeof row.weight === "number"
? row.weight
: undefined;
return id ? { id, weight } : undefined;
})
+33 -7
View File
@@ -21,6 +21,8 @@ import {
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
/** 对话落盘开场正文族(可 设计.开场白#槽位 增殖) */
export const OPENING_PRODUCT_FAMILY = "设计.开场白";
/** 0 层开场族(可 设计.0层开场白与初态#槽位 增殖) */
export const ZERO_LAYER_OPENING_FAMILY = "设计.0层开场白与初态";
export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
@@ -89,6 +91,7 @@ function asTrimmed(v: unknown): string | undefined {
}
type OpeningCandidateEntry = {
id: string;
text: string;
persona: OpeningUserPersona | null;
variables: OpeningVariable[];
@@ -148,6 +151,7 @@ function collectCandidateEntries(raw: unknown): OpeningCandidateEntry[] {
if (typeof item === "string" && item.trim()) {
const split = splitYamlFrontmatter(item);
out.push({
id: "",
text: asOpeningBodyText(split.text) || split.text,
persona: split.persona,
variables: [],
@@ -174,6 +178,7 @@ function collectCandidateEntries(raw: unknown): OpeningCandidateEntry[] {
) ||
split.persona;
out.push({
id: asTrimmed(row.id) || asTrimmed(row.候选id) || "",
text: asOpeningBodyText(split.text) || split.text,
persona,
variables: variablesFromCandidate(row),
@@ -379,6 +384,22 @@ export function readSealedOpeningPersona(
return parseUserPersonaField(raw) || parseOpeningUserPersona(raw);
}
/** 默认候选排到第 0 条,游玩的左右下标才能对上「第一次进入 0 层的那条」。 */
function orderOpeningCandidates(
entries: OpeningCandidateEntry[],
defaultId: string | undefined,
): OpeningCandidateEntry[] {
const id = defaultId?.trim();
if (!id) return entries;
const index = entries.findIndex((entry) => entry.id === id);
if (index <= 0) return entries;
return [
entries[index]!,
...entries.slice(0, index),
...entries.slice(index + 1),
];
}
function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
return null;
@@ -402,16 +423,21 @@ export function parseOpeningSealPayload(
}
})();
const primary =
asOpeningBodyText(body?.开场白全文) ||
asOpeningBodyText(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string"
? asOpeningBodyText(fragment.正文)
: undefined);
const fromList = orderOpeningCandidates(
collectCandidateEntries(body?.开场白候选),
asTrimmed(body?.默认候选id) || asTrimmed(body?.默认候选ID),
);
// 候选里的正文已是那一屏。开场白全文是旧稿副本,有候选时不再另算一条。
const primary = fromList.length
? undefined
: asOpeningBodyText(body?.开场白全文) ||
asOpeningBodyText(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string"
? asOpeningBodyText(fragment.正文)
: undefined);
const defaultPersona = parseUserPersonaField(body?.用户角色);
const sharedVariables = collectVariables(body?.开场变量);
const fromList = collectCandidateEntries(body?.开场白候选);
const candidates: string[] = [];
const personas: Array<OpeningUserPersona | null> = [];
const variableSets: OpeningVariable[][] = [];
+8 -1
View File
@@ -18,6 +18,7 @@ import {
OPENING_PERSONA_CHOICE_TAG,
OPENING_PRODUCT_FAMILY,
OPENING_SETUP_ARTIFACT_TAG,
ZERO_LAYER_OPENING_FAMILY,
SLOT_OPENING_SELECTED_INDEX,
parseOpeningSealPayload,
splitOpeningDocument,
@@ -95,7 +96,13 @@ export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
order: number;
}> = [];
for (const e of board.listTagIndex()) {
if (dictateProductFamily(e.tag) !== OPENING_PRODUCT_FAMILY) continue;
const family = dictateProductFamily(e.tag);
if (
family !== OPENING_PRODUCT_FAMILY &&
family !== ZERO_LAYER_OPENING_FAMILY
) {
continue;
}
const item = board.getLatestByTag(e.tag);
const content = item?.content?.trim() ?? "";
if (!content) continue;
+97
View File
@@ -0,0 +1,97 @@
/**
* 壳参考库:shell-refs/{id}/prompt.md。
* 正文组成与 0 层正文组成读取后,按本局启用的区改一版并固定。
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
getModuleSection,
parseModulePromptSections,
} from "./creation-flow.js";
import { parsePresentShellCatalog } from "./present-shell-catalog.js";
const DEFAULT_SKILLS_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../skills",
);
export const SHELL_REF_CATALOG_FILENAME = "shell-refs/catalog.yaml";
export type ShellRefEntry = {
id: string;
name: string;
intro: string;
layout: string;
/** 固定摆放与这一次示例填法。样本里的名称、栏目、内部小节按本局改写。 */
note: string;
sample: string;
};
export async function loadShellRefCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<ShellRefEntry[]> {
const catalogPath = path.join(
skillsRoot,
skillPackRoot,
SHELL_REF_CATALOG_FILENAME,
);
let raw: string;
try {
raw = await readFile(catalogPath, "utf8");
} catch {
return [];
}
const index = parsePresentShellCatalog(raw);
const out: ShellRefEntry[] = [];
for (const entry of index) {
const promptPath = path.join(
skillsRoot,
skillPackRoot,
"shell-refs",
entry.id,
"prompt.md",
);
let promptMd = "";
try {
promptMd = await readFile(promptPath, "utf8");
} catch {
continue;
}
const sections = parseModulePromptSections(promptMd);
const intro = getModuleSection(sections, "intro") ?? "";
const layout = getModuleSection(sections, "layout") ?? "";
const note = getModuleSection(sections, "note") ?? "";
const sample = getModuleSection(sections, "sample") ?? "";
if (!intro || !layout || !note || !sample) continue;
out.push({
id: entry.id,
name: entry.name,
intro,
layout,
note,
sample,
});
}
return out;
}
export function formatShellRefsForPrompt(
shells: readonly ShellRefEntry[],
): string {
const lines = [
"## 【壳参考库】程序从仓库动态装载",
"",
"选一只与本局已启用区域最接近的参考。备注写两类:不替换的前端结构,以及可按本局用途替换的部位。本局没有的区从样本骨架删除并重排,不留空位。美化用配色、字体层级、边框、间距和简单内联 SVG,按本次能写稳的程度完成。创作时生成一次前端并固定,游玩只灌入各区内容。",
"",
];
for (const shell of shells) {
lines.push(`### \`${shell.id}\` · ${shell.name}`, "");
lines.push(shell.intro, "");
lines.push("布局:", "", "```", shell.layout, "```", "");
lines.push("备注:", "", shell.note, "");
lines.push("前端样本:", "", "```", shell.sample, "```", "");
}
return lines.join("\n").trim();
}