落地偏好库与文风库存盘及注册表,便于创作节点按绑定注入。
先定无检索的节点绑定模型,替代原先未实现的检索方案。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user