为转述进料单独挂配方目录,避免与节点流程导演共用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 02:48:58 +08:00
co-authored by Cursor
parent e2446b0d97
commit 7f8fdfe6e9
8 changed files with 127 additions and 17 deletions
@@ -1,8 +1,7 @@
# 导演选项目录(用户在新建作品时手动选择;对用户称「导演」/「配方」)
# 节点流程 · 配方目录(与转述进料配方不共享)
# id = recipes/{id}/ 文件夹
# name = 固定中文名(下拉展示)
# declaration = 给人看的短说明
# 内部仍叫 recipe;勿对用户再说「配方」作第二层选项
# recipe.yaml 写方法论:when / core / process / principles + 近期 steps
# 步骤 name 必须 ∈ modules/catalog.yaml(【能力】)
# 选定后写入黑板 tag 创作.选用配方
@@ -0,0 +1,11 @@
# 转述进料 · 配方目录(与节点流程配方不共享)
# id = recipes/转述/{id}/ 文件夹;对用户展示用 name(中文)
# 选定后写入黑板 tag 创作.选用配方;转述模式不种节点 DAG
recipes:
- id: 文本生成器
name: 文本生成器
declaration: 钉需求与文风;无世界推演。正文格式与开场白另步。
- id: 正文组成
name: 正文组成
declaration: 钉呈现格式(壳与分区);开场白按该格式生成
@@ -0,0 +1,25 @@
# 文本生成器 · 转述进料配方
# 钉体验与设定;不写开场叙事本身。正文组成(格式)与开场白(按格式生成)见另配方或同提示分阶段。
when: >-
按用户需求钉清要写什么、给谁看、气质与禁忌;
不要世界推演、不要跨轮状态机、不要把聊天确认段当成开场白
core: >-
产物落需求与文风类上下文;默认无变量、无旁观、无主世界层。
「正文」指展示格式(设计.正文组成),不是开场白;开场白按格式另生成。
process:
- 听用户说清要写什么与交付形态;写入需求/文风等设定产物。
- 缺关键信息就问;不要问卷式盘问。
- 不要去搭世界模拟、变量、舞台骨架。
- 不要把整章叙事只留在聊天确认段里。
principles:
- 正推文本交付规格,勿套世界模拟全套。
- 保留用户原意,少二次创作。
- 格式与开场白分清:正文组成≠开场白。
brief: 文本生成:钉需求与文风(正文格式 / 开场白另步)
steps: []
@@ -0,0 +1,25 @@
# 正文组成 · 转述进料配方
# 只钉「怎么给用户看」的格式;开场白按此格式另生成。
when: >-
已大致知道要什么体验,需要定版式:选壳、分区、各区职责;
或用户在谈呈现/版式/要不要选项坞/监控栏
core: >-
写入设计.正文组成(格式契约)。
这不是开场白、不是故事正文;开场白必须按本格式的可见块生成。
process:
- 对照设定产物,选 shell_id(文本沉浸默认 prose)。
- 写可见块挂到壳区域(至少 body)。
- 需要时写微调(tone_chrome、block_labels、是否显示建议行动)。
- 用户要第一幕时:再按本格式生成设计.开场白(内容),勿把格式 JSON 当开场白展示。
principles:
- 格式权威在设计.正文组成;开场白服从格式。
- 不重议文风遣词(归叙事/需求);本步只交展示契约。
- 产物自洽:只读设计.正文组成就能知道开场/每轮终稿怎么灌数。
brief: 正文组成:钉呈现壳与可见块(格式)→ 开场白按格式生成
steps: []
+2
View File
@@ -52,6 +52,8 @@ const PHASE_UNIT_LABELS: Record<string, string> = {
const SKILL_PACK_LABELS: Record<string, string> = {
"world-simulator": "世界模拟器",
"expand-assistant": "扩写助手",
文本生成器: "文本生成器",
"text-generator": "文本生成器",
};
/** 生命周期 / 相位 */
+43 -15
View File
@@ -49,9 +49,15 @@ export function isRuntimePinnedTag(tag: string): boolean {
}
export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml";
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
/** 转述进料专用配方目录(与节点流程 catalog 不共享) */
export const DICTATE_RECIPE_CATALOG_FILENAME = "recipes/转述/catalog.yaml";
export const DICTATE_RECIPE_DIR = "recipes/转述";
export const DESIGN_STEP_WORKER_ID = "design-step";
export const DESIGN_FLOW_WORKER_ID = "design-flow";
/** 配方所属进料族:节点流程 vs 转述 */
export type RecipeIntakeFamily = "recipe" | "dictate";
const DEFAULT_SKILLS_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../skills",
@@ -225,6 +231,8 @@ export type RecipeCatalogEntry = {
id: string;
name: string;
declaration: string;
/** 条目来自哪本目录;缺省视为节点流程 */
family?: RecipeIntakeFamily;
};
export type RecipeCatalog = {
@@ -1329,7 +1337,10 @@ function indentMultiline(text: string, indent: string): string {
return [lines[0], ...lines.slice(1).map((l) => `${indent}${l}`)].join("\n");
}
export function parseRecipeCatalog(raw: string): RecipeCatalog | null {
export function parseRecipeCatalog(
raw: string,
family: RecipeIntakeFamily = "recipe",
): RecipeCatalog | null {
let doc: unknown;
try {
doc = parseYaml(raw);
@@ -1354,7 +1365,7 @@ export function parseRecipeCatalog(raw: string): RecipeCatalog | null {
? slugFromName(name)
: "";
if (!name || !declaration || !id) continue;
recipes.push({ id, name, declaration });
recipes.push({ id, name, declaration, family });
}
if (recipes.length === 0) return null;
return { recipes };
@@ -1363,11 +1374,16 @@ export function parseRecipeCatalog(raw: string): RecipeCatalog | null {
export async function loadRecipeCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
family: RecipeIntakeFamily = "recipe",
): Promise<RecipeCatalog | null> {
const fullPath = path.join(skillsRoot, skillPackRoot, RECIPE_CATALOG_FILENAME);
const filename =
family === "dictate"
? DICTATE_RECIPE_CATALOG_FILENAME
: RECIPE_CATALOG_FILENAME;
const fullPath = path.join(skillsRoot, skillPackRoot, filename);
try {
const raw = await readFile(fullPath, "utf8");
return parseRecipeCatalog(raw);
return parseRecipeCatalog(raw, family);
} catch {
return null;
}
@@ -1456,13 +1472,17 @@ export async function loadRecipeDetail(
entry: RecipeCatalogEntry,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeDetail> {
const fullPath = path.join(
skillsRoot,
skillPackRoot,
"recipes",
entry.id,
"recipe.yaml",
);
const family = entry.family ?? "recipe";
const fullPath =
family === "dictate"
? path.join(
skillsRoot,
skillPackRoot,
DICTATE_RECIPE_DIR,
entry.id,
"recipe.yaml",
)
: path.join(skillsRoot, skillPackRoot, "recipes", entry.id, "recipe.yaml");
try {
const raw = await readFile(fullPath, "utf8");
return parseRecipeYaml(raw, entry);
@@ -1801,10 +1821,18 @@ export async function resolveSelectedRecipeDetail(params: {
const ref = parseSelectedRecipeRef(params.selectedRecipeRef);
if (!ref) return null;
const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT;
const catalog = await loadRecipeCatalog(params.skillPackRoot, skillsRoot);
const entry = findRecipeCatalogEntry(catalog, ref);
if (!entry) return null;
return loadRecipeDetail(params.skillPackRoot, entry, skillsRoot);
for (const family of ["recipe", "dictate"] as const) {
const catalog = await loadRecipeCatalog(
params.skillPackRoot,
skillsRoot,
family,
);
const entry = findRecipeCatalogEntry(catalog, ref);
if (entry) {
return loadRecipeDetail(params.skillPackRoot, entry, skillsRoot);
}
}
return null;
}
export function validateCreationFlow(
+18
View File
@@ -18,6 +18,7 @@ import {
loadModuleCatalog,
loadModulePrompt,
loadRecipeCatalog,
loadRecipeDetail,
mergeCreationFlowPreservingAccepted,
needsFlowExpansion,
nextPendingStep,
@@ -484,6 +485,7 @@ describe("creation-flow", () => {
const recipes = await loadRecipeCatalog("dialogue/world-simulator");
expect(recipes?.recipes.some((r) => r.name === "世界模拟器")).toBe(true);
expect(recipes?.recipes.some((r) => r.name === "扩写助手")).toBe(true);
expect(recipes?.recipes.some((r) => r.name === "文本生成器")).toBe(false);
const block = formatRecipeCatalogForAgent(recipes!);
expect(block).toContain("须由用户手动选择");
expect(block).toContain("世界模拟器");
@@ -506,6 +508,22 @@ describe("creation-flow", () => {
expect(formatted).not.toContain("调味提示");
});
it("loads dictate recipe catalog separately with Chinese ids", async () => {
const dictate = await loadRecipeCatalog(
"dialogue/world-simulator",
undefined,
"dictate",
);
expect(dictate?.recipes.some((r) => r.id === "文本生成器")).toBe(true);
expect(dictate?.recipes.some((r) => r.name === "文本生成器")).toBe(true);
expect(dictate?.recipes.some((r) => r.name === "世界模拟器")).toBe(false);
const entry = dictate!.recipes.find((r) => r.id === "文本生成器")!;
expect(entry.family).toBe("dictate");
const detail = await loadRecipeDetail("dialogue/world-simulator", entry);
expect(detail.core).toBeTruthy();
expect(detail.seed).toBeNull();
});
it("parses selected recipe ref", () => {
expect(parseSelectedRecipeRef("world-simulator")).toBe("world-simulator");
expect(parseSelectedRecipeRef('{"id":"expand-assistant","name":"扩写助手"}')).toBe(
+2
View File
@@ -57,6 +57,8 @@ export function displaySkillPackLabel(id) {
const map = {
"world-simulator": "世界模拟器",
"expand-assistant": "扩写助手",
文本生成器: "文本生成器",
"text-generator": "文本生成器",
};
return map[id] ?? id;
}