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

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

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;