落地偏好库与文风库存盘及注册表,便于创作节点按绑定注入。

先定无检索的节点绑定模型,替代原先未实现的检索方案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 00:58:19 +08:00
co-authored by Cursor
parent dd0a9e87d8
commit 126874b65e
15 changed files with 1667 additions and 44 deletions
+31
View File
@@ -0,0 +1,31 @@
/**
* 文风库 → 提示词块(由 libraries 注册表按节点绑定调用)。
*/
import {
listActiveStylePacks,
type StylePackEntry,
} from "./store.js";
export function formatStylePackCatalogForPrompt(
entries?: StylePackEntry[],
): string {
const packs = (entries ?? listActiveStylePacks()).filter((e) =>
e.content.trim(),
);
if (!packs.length) return "";
const blocks = packs.map((p, i) => {
const body = p.content.trim();
const clipped =
body.length > 2400 ? `${body.slice(0, 2400)}\n…(已截断)` : body;
return `### ${i + 1}. ${p.name.trim()}\n\n${clipped}`;
});
return `## 【文风库 · 可选用】
跨卡「怎么写」包。用户点名、贴样本对上、或明确「用库里某某」时:优先据此落成本步产物里「怎么写」的部分,再按本局美学微调;不要重做文风问卷。
库内只规定遣词/示范/笔墨/禁忌/写法档;不写世界观与推进。无匹配则照常追问,勿强行套用。
${blocks.join("\n\n")}`;
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 文风提取:对话 + 样本 → 可入库的「怎么写」草稿。
*/
import type { LlmProvider } from "../llm/client.js";
import { completeStructured } from "../llm/structured-complete.js";
export type StyleExtractMessage = {
role: "user" | "assistant";
content: string;
};
export type StyleExtractDraft = {
name: string;
content: string;
ready: boolean;
};
export type StyleExtractResult = {
reply: string;
draft: StyleExtractDraft;
};
const EXTRACT_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["reply", "name", "content", "ready"],
properties: {
reply: { type: "string" },
name: { type: "string" },
content: { type: "string" },
ready: { type: "boolean" },
},
} as const;
function buildSystemPrompt(params: {
samples: string;
draftName: string;
draftContent: string;
}): string {
const samples = params.samples.trim() || "(未贴样本)";
const draft = params.draftContent.trim()
? `当前草稿名:${params.draftName.trim() || "(未命名)"}\n\n${params.draftContent.trim()}`
: "(尚无草稿)";
return `你正在帮用户从对话与样本中提取一份**跨卡可复用文风包**。
任务:只规定「怎么写」,不写世界观、剧情、角色设定、推进方式。产物供叙事指南等节点直接采用。
content 应覆盖(有则写、无则略):
- 叙事纲领(体裁/调性一句话)
- 风格与遣词(原则 + 至少 1 段示范)
- 笔墨焦点(详写/略写)
- 禁忌与不偏好(绝对禁忌 ≠ 不偏好)
- 写法档(官能/爽文/恐怖/情色等;无切换则写「无切换」)
- 示例对话(若样本或用户给出)
规则:
1. reply:用人话简短回应;缺料时问 1~2 个具体点,可给选项。
2. name:短名(如「锋利爽文」「冷静官能」);未成形可空串。
3. content:可执行正文(Markdown 小节即可);禁止写本局剧情。
4. ready=true 仅当遣词+示范+禁忌已够入库;否则 false,继续追问。
5. 每轮都给出目前最好的 name/content(可增量改),不要清空已有好内容。
用户样本:
${samples}
当前草稿:
${draft}`;
}
function parseResult(parsed: unknown): StyleExtractResult {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
reply: "未能解析提取结果,请再试一次。",
draft: { name: "", content: "", ready: false },
};
}
const row = parsed as Record<string, unknown>;
const reply =
typeof row.reply === "string" && row.reply.trim()
? row.reply.trim()
: "已记录。";
const name = typeof row.name === "string" ? row.name.trim() : "";
const content = typeof row.content === "string" ? row.content.trim() : "";
const ready = row.ready === true && Boolean(name && content);
return { reply, draft: { name, content, ready } };
}
export async function extractStylePackTurn(params: {
llm: LlmProvider;
messages: StyleExtractMessage[];
samples?: string;
draftName?: string;
draftContent?: string;
}): Promise<StyleExtractResult> {
const history = params.messages
.filter((m) => m.content.trim())
.slice(-16)
.map((m) => ({
role: m.role,
content: m.content.trim(),
}));
if (!history.length) {
throw new Error("请先说点对文风的要求,或贴一段样本");
}
const system = buildSystemPrompt({
samples: params.samples ?? "",
draftName: params.draftName ?? "",
draftContent: params.draftContent ?? "",
});
const result = await completeStructured(
params.llm,
[{ role: "system", content: system }, ...history],
{
schema: EXTRACT_SCHEMA as unknown as Record<string, unknown>,
name: "style_pack_extract",
schemaIsLoose: true,
caller: "style_pack_extract",
},
);
return parseResult(result.parsed);
}
+21
View File
@@ -0,0 +1,21 @@
export {
listStylePacks,
listActiveStylePacks,
getStylePack,
createStylePack,
updateStylePack,
deleteStylePack,
loadStylePackStore,
type StylePackEntry,
type StylePackStatus,
type StylePackStoreFile,
} from "./store.js";
export {
extractStylePackTurn,
type StyleExtractMessage,
type StyleExtractDraft,
type StyleExtractResult,
} from "./extract.js";
export { formatStylePackCatalogForPrompt } from "./catalog.js";
+191
View File
@@ -0,0 +1,191 @@
/**
* 全局文风库:跨卡复用的「怎么写」包(遣词 / 示范 / 禁忌 / 写法档等)。
* 与偏好库分离:偏好=硬约束全量挂载;文风=创作节点选用。
*/
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
export type StylePackStatus = "active" | "archived";
export type StylePackEntry = {
id: string;
/** 短名,便于列表与选用 */
name: string;
/** 只规定「怎么写」:遣词、示范、笔墨、禁忌、写法档、示例对话等 */
content: string;
/** 抽取时留下的样本原文(可选) */
samples?: string;
status: StylePackStatus;
createdAt: string;
updatedAt: string;
};
export type StylePackStoreFile = {
version: 1;
entries: StylePackEntry[];
};
const FILE_NAME = "style-packs.json";
function storePath(): string {
return path.join(getUserDataDir(), FILE_NAME);
}
function nowIso(): string {
return new Date().toISOString();
}
function defaultStore(): StylePackStoreFile {
return { version: 1, entries: [] };
}
function normalizeStatus(raw: unknown): StylePackStatus {
return raw === "archived" ? "archived" : "active";
}
function normalizeStore(raw: unknown): StylePackStoreFile {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return defaultStore();
}
const row = raw as Record<string, unknown>;
if (row.version !== 1 || !Array.isArray(row.entries)) {
return defaultStore();
}
const entries: StylePackEntry[] = [];
for (const item of row.entries) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const p = item as Record<string, unknown>;
const id = typeof p.id === "string" ? p.id.trim() : "";
const name = typeof p.name === "string" ? p.name.trim() : "";
const content = typeof p.content === "string" ? p.content.trim() : "";
if (!id || !name || !content) continue;
const samples =
typeof p.samples === "string" && p.samples.trim()
? p.samples.trim()
: undefined;
entries.push({
id,
name,
content,
...(samples ? { samples } : {}),
status: normalizeStatus(p.status),
createdAt: typeof p.createdAt === "string" ? p.createdAt : nowIso(),
updatedAt: typeof p.updatedAt === "string" ? p.updatedAt : nowIso(),
});
}
return { version: 1, entries };
}
export function loadStylePackStore(): StylePackStoreFile {
ensureUserDataDirs();
const file = storePath();
if (!existsSync(file)) {
const created = defaultStore();
writeFileSync(file, JSON.stringify(created, null, 2), "utf8");
return created;
}
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
return normalizeStore(parsed);
} catch {
return defaultStore();
}
}
export function saveStylePackStore(store: StylePackStoreFile): void {
ensureUserDataDirs();
writeFileSync(storePath(), JSON.stringify(store, null, 2), "utf8");
}
export function listStylePacks(opts?: {
status?: StylePackStatus | "all";
}): StylePackEntry[] {
const status = opts?.status ?? "all";
const entries = loadStylePackStore().entries;
if (status === "all") return entries;
return entries.filter((e) => e.status === status);
}
export function listActiveStylePacks(): StylePackEntry[] {
return listStylePacks({ status: "active" });
}
export function getStylePack(id: string): StylePackEntry | null {
return loadStylePackStore().entries.find((e) => e.id === id) ?? null;
}
export function createStylePack(input: {
name: string;
content: string;
samples?: string;
status?: StylePackStatus;
}): StylePackEntry {
const name = input.name.trim();
const content = input.content.trim();
if (!name) throw new Error("文风名称不能为空");
if (!content) throw new Error("文风内容不能为空");
const store = loadStylePackStore();
const t = nowIso();
const samples = input.samples?.trim() || undefined;
const entry: StylePackEntry = {
id: randomUUID(),
name,
content,
...(samples ? { samples } : {}),
status: input.status === "archived" ? "archived" : "active",
createdAt: t,
updatedAt: t,
};
store.entries.push(entry);
saveStylePackStore(store);
return entry;
}
export function updateStylePack(
id: string,
patch: {
name?: string;
content?: string;
samples?: string | null;
status?: StylePackStatus;
},
): StylePackEntry {
const store = loadStylePackStore();
const idx = store.entries.findIndex((e) => e.id === id);
if (idx < 0) throw new Error("文风包不存在");
const cur = store.entries[idx]!;
const name = patch.name !== undefined ? patch.name.trim() : cur.name;
const content =
patch.content !== undefined ? patch.content.trim() : cur.content;
if (!name) throw new Error("文风名称不能为空");
if (!content) throw new Error("文风内容不能为空");
let samples = cur.samples;
if (patch.samples !== undefined) {
samples = patch.samples?.trim() || undefined;
}
const next: StylePackEntry = {
...cur,
name,
content,
...(samples ? { samples } : {}),
status: patch.status !== undefined ? normalizeStatus(patch.status) : cur.status,
updatedAt: nowIso(),
};
if (!samples) delete (next as { samples?: string }).samples;
store.entries[idx] = next;
saveStylePackStore(store);
return next;
}
export function deleteStylePack(id: string): void {
const store = loadStylePackStore();
const next = store.entries.filter((e) => e.id !== id);
if (next.length === store.entries.length) {
throw new Error("文风包不存在");
}
store.entries = next;
saveStylePackStore(store);
}