新建作品改为直接进入对话落盘,按本局创作方案写能力,并把楼层呈现和零层卡分开。
输入栏同时可以按分组切换模型。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(产物与变量/映射保留)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ function buildSystemPrompt(params: {
|
||||
|
||||
|
||||
|
||||
任务:只写短、可执行的禁区/纠偏/默认取向;不写文风遣词、不写世界观与剧情。产物供「用户需求」节点选用。
|
||||
任务:只写短、可执行的禁区/纠偏/默认取向;不写文风遣词、不写世界观与剧情。产物供绑定偏好库的能力按需选用。
|
||||
|
||||
|
||||
|
||||
|
||||
+12
-1
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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[][] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user