落地偏好库与文风库存盘及注册表,便于创作节点按绑定注入。
先定无检索的节点绑定模型,替代原先未实现的检索方案。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,63 +1,97 @@
|
|||||||
# 用户偏好 RAG 库(待定)
|
# 用户偏好库 / 文风库
|
||||||
|
|
||||||
> 状态:**未实现**。先记设计共识与未决项,尤其 RAG 插入点。
|
|
||||||
|
|
||||||
## 已对齐
|
|
||||||
|
|
||||||
### 写库(学习,不改游玩上下文)
|
> 状态:**节点绑定(无 RAG)**。偏好与文风分库;创作节点声明 `libraries` 后注入,库空跳过。
|
||||||
|
|
||||||
- 累计对话轮次 ≥ **10**(可在设置中改)后,尝试从最近对话**总结**用户偏好。
|
|
||||||
- 弹出**审核卡**:通过 / 编辑后通过 / 合并到已有 / 跳过。
|
|
||||||
- 通过后才写入全局偏好库;支持**去重**与**更新已有条目**。
|
|
||||||
- 「随机插一条」= 往库里记候选,**不**修改当前轮 prompt。
|
|
||||||
|
|
||||||
### 读库(仅创建期)
|
|
||||||
|
|
||||||
- 根据用户输入 **Boss 直聘式**召回匹配条目(卡片点选)。
|
## 两库
|
||||||
- 选中结果收成**本局固定上下文**(`resident_context` / 叙事指南合并),不在游玩期动态插入。
|
|
||||||
|
|
||||||
### 条目形状(草案)
|
|
||||||
|
|
||||||
- `id`, `title`, `kind`(style|taboo|tone|format|other)
|
|
||||||
- `keys[]`(检索用), `content`, `mount[]`, `always_on?`
|
|
||||||
- `source`(manual|learned), `status`(active|archived)
|
|
||||||
|
|
||||||
## 未决:RAG 怎么插
|
| 库 | 存盘 | 绑定节点 | 注入 |
|
||||||
|
|
||||||
当前仓库**无 embedding / 向量检索**。需先定插入架构,再写代码。
|
|----|------|----------|------|
|
||||||
|
|
||||||
| 选项 | 做法 | 优点 | 风险 |
|
| **文风库** `style-packs` | `~/.writing-agent/style-packs.json` | 叙事指南、叙事指南与故事推进 | 【文风库 · 可选用】 |
|
||||||
|------|------|------|------|
|
|
||||||
| A. 关键词召回 | `keys` + 标题匹配用户输入 | 零依赖、快落地 | 召回弱 |
|
|
||||||
| B. 创建期 LLM 选条 | 把库摘要交给 design-flow 一步选 | 与现有编排一致 | token、不稳定 |
|
|
||||||
| C. 本地 embedding | 条目向量化 + 相似度 Top-K | 真 RAG | 新依赖、索引维护 |
|
|
||||||
| D. 混合 | always_on + 关键词粗筛 +(可选)embedding 精排 | 可渐进 | 实现稍复杂 |
|
|
||||||
|
|
||||||
**读库插入落点(创建期)候选:**
|
| **偏好库** `preferences` | `~/.writing-agent/preferences.json` | 用户需求 | 【偏好库 · 可选用】 |
|
||||||
|
|
||||||
1. `设计.用户偏好.选用集` fragment → `refine` 合并进 `resident_context` / 叙事 `情境备用` / `绝对禁忌`
|
|
||||||
2. 仅 `resident_context[]`,不经过叙事指南
|
|
||||||
3. 新设计步「用户偏好」专产选用集
|
|
||||||
|
|
||||||
**写库触发落点候选:**
|
|
||||||
|
|
||||||
1. `session-manager` 回合末计数 + 调 summarize API
|
## 读写
|
||||||
2. 独立 `PreferenceService` + `/api/preferences/*`
|
|
||||||
3. 审核 UI 挂在 `SessionView.preferenceReview`
|
|
||||||
|
|
||||||
## 建议实现顺序(定好 RAG 后再做)
|
|
||||||
|
|
||||||
1. 库 store + 手写样例条目
|
|
||||||
2. 设置项 `preferenceReviewEveryTurns`(默认 10)
|
|
||||||
3. 读库:关键词召回 + Boss 卡片 + 选用集 tag
|
|
||||||
4. `refine` 合并选用集 → 固定上下文
|
|
||||||
5. 写库:轮次触发 + 总结 + 审核 + 去重写回
|
|
||||||
6. (可选)embedding RAG
|
|
||||||
|
|
||||||
## 相关现有基建
|
| 阶段 | 行为 |
|
||||||
|
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| **库页** | 顶栏「库」:手写 CRUD + LLM 提取入库 |
|
||||||
|
|
||||||
|
| **创作节点** | design-step / 对话落盘按 `libraries` 注入目录;节点选用写入本局产物 |
|
||||||
|
|
||||||
|
| **开玩** | **不再**全量自动摊偏好;靠产物 `设计.用户需求` / `设计.叙事指南*` 挂载 |
|
||||||
|
|
||||||
|
| **游玩** | 每隔 N 回合采集偏好候选(设置项);收下写入全局偏好库 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 偏好条目形状
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- `id`, `content`, `status`(active|archived), `createdAt`, `updatedAt`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 文风包形状
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- `id`, `name`, `content`, `samples?`, `status`, `createdAt`, `updatedAt`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- `preferenceCollectEveryTurns`:采集间隔(默认 10;0=关)
|
||||||
|
|
||||||
|
- 设置页「用户偏好」:只留间隔;CRUD 在库页
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- `GET/POST /api/preferences`;`PUT/DELETE /api/preferences/:id`
|
||||||
|
|
||||||
|
- `POST /api/preferences/extract`
|
||||||
|
|
||||||
|
- `GET/POST /api/style-packs`;`PUT/DELETE /api/style-packs/:id`
|
||||||
|
|
||||||
|
- `POST /api/style-packs/extract`
|
||||||
|
|
||||||
|
- `POST /api/sessions/:id/preferences/review`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- `src/libraries/registry.ts` — 库注册 + `formatBoundLibrariesForPrompt`
|
||||||
|
|
||||||
|
- `src/style-pack/` — 文风库
|
||||||
|
|
||||||
|
- `src/preference/` — 偏好库 + extract
|
||||||
|
|
||||||
|
- `skills/.../modules/user-requirements/` — 用户需求节点
|
||||||
|
|
||||||
|
- `web/libraries.html` — 库页
|
||||||
|
|
||||||
|
|
||||||
- 常驻:`src/skills/resident-context.ts`,`设计.worker集.resident_context`
|
|
||||||
- 投影排序:`context-order` inserts
|
|
||||||
- 叙事写法档:`设计.叙事指南` → `情境备用`
|
|
||||||
- 设置:`src/config/settings.ts`
|
|
||||||
- 无游玩期上下文注入需求(与 `context-assembly.md` 一致)
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
registerLibrary,
|
||||||
|
getLibrary,
|
||||||
|
listRegisteredLibraries,
|
||||||
|
parseLibraryIds,
|
||||||
|
formatBoundLibrariesForPrompt,
|
||||||
|
collectLibraryIdsFromModules,
|
||||||
|
type LibraryProvider,
|
||||||
|
} from "./registry.js";
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 节点可绑定的全局库:某能力声明 libraries 后,design-step 注入对应块。
|
||||||
|
* 新库:实现 formatForPrompt,在本文件 register。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatPreferenceCatalogForPrompt } from "../preference/catalog.js";
|
||||||
|
import { formatStylePackCatalogForPrompt } from "../style-pack/catalog.js";
|
||||||
|
|
||||||
|
export type LibraryProvider = {
|
||||||
|
id: string;
|
||||||
|
/** 给人看的短名 */
|
||||||
|
label: string;
|
||||||
|
/** 空串 = 库空或无可注入,跳过 */
|
||||||
|
formatForPrompt: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const providers = new Map<string, LibraryProvider>();
|
||||||
|
|
||||||
|
export function registerLibrary(provider: LibraryProvider): void {
|
||||||
|
const id = provider.id.trim();
|
||||||
|
if (!id) return;
|
||||||
|
providers.set(id, { ...provider, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLibrary(id: string): LibraryProvider | undefined {
|
||||||
|
return providers.get(id.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRegisteredLibraries(): LibraryProvider[] {
|
||||||
|
return [...providers.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 catalog / meta 原始值收成 id 列表 */
|
||||||
|
export function parseLibraryIds(raw: unknown): string[] | undefined {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
const id = typeof item === "string" ? item.trim() : "";
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
return ids.length ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按节点绑定的库 id 拼注入块;未知 id 忽略;库空跳过。
|
||||||
|
*/
|
||||||
|
export function formatBoundLibrariesForPrompt(
|
||||||
|
libraryIds: readonly string[] | undefined,
|
||||||
|
): string {
|
||||||
|
if (!libraryIds?.length) return "";
|
||||||
|
const blocks: string[] = [];
|
||||||
|
for (const id of libraryIds) {
|
||||||
|
const provider = providers.get(id.trim());
|
||||||
|
if (!provider) continue;
|
||||||
|
const text = provider.formatForPrompt().trim();
|
||||||
|
if (text) blocks.push(text);
|
||||||
|
}
|
||||||
|
return blocks.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 内置库 ——
|
||||||
|
registerLibrary({
|
||||||
|
id: "style-packs",
|
||||||
|
label: "文风库",
|
||||||
|
formatForPrompt: formatStylePackCatalogForPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerLibrary({
|
||||||
|
id: "preferences",
|
||||||
|
label: "偏好库",
|
||||||
|
formatForPrompt: formatPreferenceCatalogForPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从模块列表收集绑定的库 id(去重、保序)。
|
||||||
|
* 对话落盘:通常传入 intake≠recipe 的模块。
|
||||||
|
*/
|
||||||
|
export function collectLibraryIdsFromModules(
|
||||||
|
modules: ReadonlyArray<{ libraries?: readonly string[] }>,
|
||||||
|
): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const m of modules) {
|
||||||
|
for (const raw of m.libraries ?? []) {
|
||||||
|
const id = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
|
||||||
|
* 偏好库 → 提示词块(由 libraries 注册表按节点绑定调用)。
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
ensureStarterPreferences,
|
||||||
|
|
||||||
|
listActivePreferences,
|
||||||
|
|
||||||
|
type PreferenceEntry,
|
||||||
|
|
||||||
|
} from "./store.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function formatPreferenceCatalogForPrompt(
|
||||||
|
|
||||||
|
entries?: PreferenceEntry[],
|
||||||
|
|
||||||
|
): string {
|
||||||
|
|
||||||
|
ensureStarterPreferences();
|
||||||
|
|
||||||
|
const active = (entries ?? listActivePreferences()).filter((e) =>
|
||||||
|
|
||||||
|
e.content.trim(),
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!active.length) return "";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const lines = active.map((e, i) => `${i + 1}. ${e.content.trim()}`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `## 【偏好库 · 可选用】
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
跨局硬约束条目(禁区 / 纠偏 / 默认取向等)。用户点名、对齐某条、或明确「本局要进库里某某」时:写入本步产物;可改写为可执行句子,勿整库无脑粘贴。
|
||||||
|
|
||||||
|
与文风库分工:本库不管遣词示范,只管「必须遵守 / 绝对不要」。无匹配则照常追问,勿强行套用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
${lines.join("\n")}`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 游玩期偏好采集:隔 N 个用户回合,从近窗对话抽出可复用跨局偏好候选。
|
||||||
|
* 不改本局「设计.用户约束」;候选须经审核卡才写入全局库。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { LlmProvider } from "../llm/client.js";
|
||||||
|
import { completeStructured } from "../llm/structured-complete.js";
|
||||||
|
import { listActivePreferences } from "./store.js";
|
||||||
|
|
||||||
|
export type PreferenceCandidate = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLLECT_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["candidates"],
|
||||||
|
properties: {
|
||||||
|
candidates: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["content"],
|
||||||
|
properties: {
|
||||||
|
content: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function buildCollectPrompt(params: {
|
||||||
|
recentDialogue: string;
|
||||||
|
existing: string[];
|
||||||
|
}): string {
|
||||||
|
const existingBlock = params.existing.length
|
||||||
|
? params.existing.map((c, i) => `${i + 1}. ${c}`).join("\n")
|
||||||
|
: "(库空)";
|
||||||
|
return `你正在从游玩对话中采集用户的**跨局偏好**候选。
|
||||||
|
|
||||||
|
任务:读近窗对话,判断用户是否表达了可复用的喜好/禁区/节奏/写法纠偏。有则写入 candidates;没有则 candidates 为空数组。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 只收跨局可复用约束,不收本局剧情指令(如「这章让她出门」)。
|
||||||
|
2. 每条写成可执行做法,带替代行为;禁止只写「不要 xxx」。
|
||||||
|
3. 与已有条目近义则不要再提。
|
||||||
|
4. 一次最多 2 条;拿不准就 0 条。
|
||||||
|
5. 力度偏「默认避开」;只有用户说死了才写成绝对禁止。
|
||||||
|
|
||||||
|
已有偏好:
|
||||||
|
${existingBlock}
|
||||||
|
|
||||||
|
近窗对话:
|
||||||
|
${params.recentDialogue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCandidates(parsed: unknown): PreferenceCandidate[] {
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
||||||
|
const raw = (parsed as { candidates?: unknown }).candidates;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: PreferenceCandidate[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||||
|
const content = String(
|
||||||
|
(item as { content?: unknown }).content ?? "",
|
||||||
|
).trim();
|
||||||
|
if (!content) continue;
|
||||||
|
out.push({ id: randomUUID(), content });
|
||||||
|
if (out.length >= 2) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 LLM 产出候选;失败或空则返回 []。
|
||||||
|
*/
|
||||||
|
export async function collectPreferenceCandidates(params: {
|
||||||
|
llm: LlmProvider;
|
||||||
|
recentDialogue: string;
|
||||||
|
}): Promise<PreferenceCandidate[]> {
|
||||||
|
const dialogue = params.recentDialogue.trim();
|
||||||
|
if (!dialogue) return [];
|
||||||
|
const existing = listActivePreferences().map((e) => e.content);
|
||||||
|
try {
|
||||||
|
const result = await completeStructured(
|
||||||
|
params.llm,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: buildCollectPrompt({
|
||||||
|
recentDialogue: dialogue,
|
||||||
|
existing,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
schema: COLLECT_SCHEMA as unknown as Record<string, unknown>,
|
||||||
|
name: "preference_candidates",
|
||||||
|
schemaIsLoose: true,
|
||||||
|
caller: "preference_collect",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return parseCandidates(result.parsed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从会话消息拼近窗(用户 + 助手可见文),供采集 */
|
||||||
|
export function formatRecentDialogueForCollect(
|
||||||
|
messages: Array<{ role: string; text?: string; body?: string }>,
|
||||||
|
limit = 12,
|
||||||
|
): string {
|
||||||
|
const slice = messages.slice(-limit);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const m of slice) {
|
||||||
|
const role = m.role === "user" ? "用户" : m.role === "assistant" ? "助手" : m.role;
|
||||||
|
const text = (m.body ?? m.text ?? "").trim();
|
||||||
|
if (!text) continue;
|
||||||
|
// 跳过系统调度句
|
||||||
|
if (/^\[(总管|阶段机|游玩|创作|对话落盘)/.test(text)) continue;
|
||||||
|
lines.push(`${role}:${text.slice(0, 800)}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 创作期:把全局偏好库全量摊成「设计.用户约束」,挂进游玩固定上下文。
|
||||||
|
* 开玩后本局只读;游玩采集只写回全局库,不改本 tag。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
|
import {
|
||||||
|
CONTEXT_ORDER_SCHEMA,
|
||||||
|
CONTEXT_ORDER_TAG,
|
||||||
|
mergeContextOrderIntoWorkerSetJson,
|
||||||
|
parseContextOrder,
|
||||||
|
renumberContextOrder,
|
||||||
|
type ContextOrderDoc,
|
||||||
|
type ContextOrderInsert,
|
||||||
|
} from "../skills/context-order.js";
|
||||||
|
import {
|
||||||
|
WORKER_SET_DRAFT_TAG,
|
||||||
|
WORKER_SET_FINAL_TAG,
|
||||||
|
} from "../skills/creation-units.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_PLAY_SLOT_REFS,
|
||||||
|
parsePlaySlots,
|
||||||
|
} from "../skills/play-slots.js";
|
||||||
|
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||||
|
import {
|
||||||
|
ensureStarterPreferences,
|
||||||
|
listActivePreferences,
|
||||||
|
type PreferenceEntry,
|
||||||
|
} from "./store.js";
|
||||||
|
|
||||||
|
export const USER_CONSTRAINTS_TAG = "设计.用户约束";
|
||||||
|
|
||||||
|
const IDENTITY =
|
||||||
|
"【用户约束】跨局偏好。约束展示与写法,不改舞台真值;与本局世界观许可冲突时以本段为准。";
|
||||||
|
|
||||||
|
function tryParseOrder(raw: string): ContextOrderDoc | undefined {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (!t) return undefined;
|
||||||
|
try {
|
||||||
|
return parseContextOrder(JSON.parse(t));
|
||||||
|
} catch {
|
||||||
|
return parseContextOrder(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由偏好条目拼成本局产物正文;空库返回空串 */
|
||||||
|
export function buildUserConstraintsContent(
|
||||||
|
entries?: PreferenceEntry[],
|
||||||
|
): string {
|
||||||
|
const source = entries ?? listActivePreferences();
|
||||||
|
const active = source.filter((e) => e.status === "active" && e.content.trim());
|
||||||
|
if (!active.length) return "";
|
||||||
|
const lines = active.map((e, i) => `${i + 1}. ${e.content.trim()}`);
|
||||||
|
return `${IDENTITY}\n\n${lines.join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入黑板;库空则清空 tag 正文(避免陈旧约束残留) */
|
||||||
|
export function writeUserConstraintsTag(board: Blackboard): {
|
||||||
|
content: string;
|
||||||
|
count: number;
|
||||||
|
} {
|
||||||
|
ensureStarterPreferences();
|
||||||
|
const entries = listActivePreferences();
|
||||||
|
const content = buildUserConstraintsContent(entries);
|
||||||
|
board.write({
|
||||||
|
tag: USER_CONSTRAINTS_TAG,
|
||||||
|
content,
|
||||||
|
source: "system:user-constraints",
|
||||||
|
});
|
||||||
|
return { content, count: entries.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alreadyHasRef(inserts: ContextOrderInsert[], ref: string): boolean {
|
||||||
|
return inserts.some((ins) => ins.ref === ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 persona 之后插入用户约束(靠上,高于世界观长文)。
|
||||||
|
* 目标槽:主世界层 + 叙事转述(有则挂)。
|
||||||
|
*/
|
||||||
|
export function ensureUserConstraintsInContextOrder(
|
||||||
|
doc: ContextOrderDoc,
|
||||||
|
): ContextOrderDoc {
|
||||||
|
const gmRef = DEFAULT_PLAY_SLOT_REFS.gm;
|
||||||
|
const narratorRef = DEFAULT_PLAY_SLOT_REFS.narrator;
|
||||||
|
const slots = doc.slots.map((slot) => {
|
||||||
|
const isGm = slot.ref === gmRef || /world-simulator|主世界/.test(slot.ref);
|
||||||
|
const isNarrator = slot.ref === narratorRef || /narrator|转述/.test(slot.ref);
|
||||||
|
if (!isGm && !isNarrator) return slot;
|
||||||
|
if (alreadyHasRef(slot.inserts, USER_CONSTRAINTS_TAG)) return slot;
|
||||||
|
|
||||||
|
const personaIdx = slot.inserts.findIndex(
|
||||||
|
(ins) => ins.ref === "worker.persona",
|
||||||
|
);
|
||||||
|
const insertAt = personaIdx >= 0 ? personaIdx + 1 : 0;
|
||||||
|
const nextInserts = [...slot.inserts];
|
||||||
|
nextInserts.splice(insertAt, 0, {
|
||||||
|
order: 0,
|
||||||
|
ref: USER_CONSTRAINTS_TAG,
|
||||||
|
projection: "full",
|
||||||
|
note: "用户跨局约束(全量)",
|
||||||
|
});
|
||||||
|
return { ...slot, inserts: nextInserts };
|
||||||
|
});
|
||||||
|
|
||||||
|
return renumberContextOrder({
|
||||||
|
...doc,
|
||||||
|
schema: CONTEXT_ORDER_SCHEMA,
|
||||||
|
slots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量落盘 + 挂入运行规格 / 投影排序。
|
||||||
|
* 库空:清空 tag,并从 inserts 摘掉该 ref。
|
||||||
|
*/
|
||||||
|
export function applyUserConstraintsToPlaySpec(board: Blackboard): {
|
||||||
|
content: string;
|
||||||
|
count: number;
|
||||||
|
mounted: boolean;
|
||||||
|
} {
|
||||||
|
const { content, count } = writeUserConstraintsTag(board);
|
||||||
|
|
||||||
|
const finalRaw =
|
||||||
|
board.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() ||
|
||||||
|
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
|
||||||
|
"";
|
||||||
|
const orderRaw = board.getContentByTag(CONTEXT_ORDER_TAG)?.trim() || "";
|
||||||
|
|
||||||
|
let orderDoc =
|
||||||
|
tryParseOrder(orderRaw) ??
|
||||||
|
(() => {
|
||||||
|
if (!finalRaw) return undefined;
|
||||||
|
try {
|
||||||
|
const row = JSON.parse(finalRaw) as Record<string, unknown>;
|
||||||
|
if (row.context_order && typeof row.context_order === "object") {
|
||||||
|
return parseContextOrder(row.context_order);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!orderDoc && finalRaw) {
|
||||||
|
const parsed = parseWorkerSetYaml(finalRaw);
|
||||||
|
const playSlots = parsed?.play_slots ?? parsePlaySlots(undefined);
|
||||||
|
orderDoc = {
|
||||||
|
schema: CONTEXT_ORDER_SCHEMA,
|
||||||
|
brief: "用户约束挂载",
|
||||||
|
play_slots: playSlots,
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
ref: DEFAULT_PLAY_SLOT_REFS.gm,
|
||||||
|
label: "主世界层",
|
||||||
|
inserts: [
|
||||||
|
{ order: 0, ref: "worker.persona", projection: "fixed" },
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
ref: USER_CONSTRAINTS_TAG,
|
||||||
|
projection: "full",
|
||||||
|
note: "用户跨局约束(全量)",
|
||||||
|
},
|
||||||
|
{ order: 2, ref: "用户.最新输入", projection: "full" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderDoc) {
|
||||||
|
return { content, count, mounted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
// 摘掉空约束 ref
|
||||||
|
orderDoc = renumberContextOrder({
|
||||||
|
...orderDoc,
|
||||||
|
slots: orderDoc.slots.map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
inserts: slot.inserts.filter((ins) => ins.ref !== USER_CONSTRAINTS_TAG),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
orderDoc = ensureUserConstraintsInContextOrder(orderDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
board.write({
|
||||||
|
tag: CONTEXT_ORDER_TAG,
|
||||||
|
content: JSON.stringify(orderDoc, null, 2),
|
||||||
|
source: "system:user-constraints",
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseJson =
|
||||||
|
finalRaw.trim() ||
|
||||||
|
JSON.stringify({ play_slots: orderDoc.play_slots ?? {}, brief: "用户约束挂载" }, null, 2);
|
||||||
|
const workerSetJson = mergeContextOrderIntoWorkerSetJson(baseJson, orderDoc);
|
||||||
|
board.write({
|
||||||
|
tag: WORKER_SET_FINAL_TAG,
|
||||||
|
content: workerSetJson,
|
||||||
|
source: "system:user-constraints",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { content, count, mounted: Boolean(content.trim()) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
|
||||||
|
* 偏好提取:对话 → 可入库的短约束条目候选。
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import type { LlmProvider } from "../llm/client.js";
|
||||||
|
|
||||||
|
import { completeStructured } from "../llm/structured-complete.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type PreferenceExtractMessage = {
|
||||||
|
|
||||||
|
role: "user" | "assistant";
|
||||||
|
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type PreferenceExtractCandidate = {
|
||||||
|
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
ready: boolean;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type PreferenceExtractResult = {
|
||||||
|
|
||||||
|
reply: string;
|
||||||
|
|
||||||
|
candidates: PreferenceExtractCandidate[];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const EXTRACT_SCHEMA = {
|
||||||
|
|
||||||
|
type: "object",
|
||||||
|
|
||||||
|
additionalProperties: false,
|
||||||
|
|
||||||
|
required: ["reply", "candidates"],
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
|
||||||
|
reply: { type: "string" },
|
||||||
|
|
||||||
|
candidates: {
|
||||||
|
|
||||||
|
type: "array",
|
||||||
|
|
||||||
|
items: {
|
||||||
|
|
||||||
|
type: "object",
|
||||||
|
|
||||||
|
additionalProperties: false,
|
||||||
|
|
||||||
|
required: ["content", "ready"],
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
|
||||||
|
content: { type: "string" },
|
||||||
|
|
||||||
|
ready: { type: "boolean" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function buildSystemPrompt(params: {
|
||||||
|
|
||||||
|
draftCandidates: string;
|
||||||
|
|
||||||
|
}): string {
|
||||||
|
|
||||||
|
const draft = params.draftCandidates.trim() || "(尚无候选)";
|
||||||
|
|
||||||
|
return `你正在帮用户从对话中提取**跨卡可复用偏好条目**(硬约束)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
任务:只写短、可执行的禁区/纠偏/默认取向;不写文风遣词、不写世界观与剧情。产物供「用户需求」节点选用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
每条 content:
|
||||||
|
|
||||||
|
- 一句到三句;写「不要 X,改做 Y」优于空泛「注意分寸」
|
||||||
|
|
||||||
|
- 一条只钉一件事;多件事拆成多条
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. reply:用人话简短回应;缺料时问 1~2 个具体点。
|
||||||
|
|
||||||
|
2. candidates:当前最好的条目列表(可增量改);未成形可空数组。
|
||||||
|
|
||||||
|
3. ready=true 仅当该条已够入库;否则 false,继续追问。
|
||||||
|
|
||||||
|
4. 不要清空用户已认可的好条目。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
当前候选草稿:
|
||||||
|
|
||||||
|
${draft}`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseResult(parsed: unknown): PreferenceExtractResult {
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
|
||||||
|
return {
|
||||||
|
|
||||||
|
reply: "未能解析提取结果,请再试一次。",
|
||||||
|
|
||||||
|
candidates: [],
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
const reply =
|
||||||
|
|
||||||
|
typeof row.reply === "string" && row.reply.trim()
|
||||||
|
|
||||||
|
? row.reply.trim()
|
||||||
|
|
||||||
|
: "已记录。";
|
||||||
|
|
||||||
|
const candidates: PreferenceExtractCandidate[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(row.candidates)) {
|
||||||
|
|
||||||
|
for (const item of row.candidates) {
|
||||||
|
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||||
|
|
||||||
|
const c = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
const content = typeof c.content === "string" ? c.content.trim() : "";
|
||||||
|
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
|
||||||
|
content,
|
||||||
|
|
||||||
|
ready: c.ready === true,
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reply, candidates };
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export async function extractPreferenceTurn(params: {
|
||||||
|
|
||||||
|
llm: LlmProvider;
|
||||||
|
|
||||||
|
messages: PreferenceExtractMessage[];
|
||||||
|
|
||||||
|
draftCandidates?: PreferenceExtractCandidate[];
|
||||||
|
|
||||||
|
}): Promise<PreferenceExtractResult> {
|
||||||
|
|
||||||
|
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 draftText = (params.draftCandidates ?? [])
|
||||||
|
|
||||||
|
.filter((c) => c.content.trim())
|
||||||
|
|
||||||
|
.map((c, i) => `${i + 1}. ${c.content.trim()}${c.ready ? " 〔可入库〕" : ""}`)
|
||||||
|
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const system = buildSystemPrompt({ draftCandidates: draftText });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const result = await completeStructured(
|
||||||
|
|
||||||
|
params.llm,
|
||||||
|
|
||||||
|
[{ role: "system", content: system }, ...history],
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
schema: EXTRACT_SCHEMA as unknown as Record<string, unknown>,
|
||||||
|
|
||||||
|
name: "preference_extract",
|
||||||
|
|
||||||
|
schemaIsLoose: true,
|
||||||
|
|
||||||
|
caller: "preference_extract",
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseResult(result.parsed);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export {
|
||||||
|
listPreferences,
|
||||||
|
listActivePreferences,
|
||||||
|
getPreference,
|
||||||
|
createPreference,
|
||||||
|
updatePreference,
|
||||||
|
deletePreference,
|
||||||
|
acceptPreferenceCandidate,
|
||||||
|
loadPreferenceStore,
|
||||||
|
ensureStarterPreferences,
|
||||||
|
STARTER_PREFERENCES,
|
||||||
|
type PreferenceEntry,
|
||||||
|
type PreferenceStatus,
|
||||||
|
type PreferenceStoreFile,
|
||||||
|
} from "./store.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
USER_CONSTRAINTS_TAG,
|
||||||
|
buildUserConstraintsContent,
|
||||||
|
writeUserConstraintsTag,
|
||||||
|
applyUserConstraintsToPlaySpec,
|
||||||
|
ensureUserConstraintsInContextOrder,
|
||||||
|
} from "./constraints.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
collectPreferenceCandidates,
|
||||||
|
formatRecentDialogueForCollect,
|
||||||
|
type PreferenceCandidate,
|
||||||
|
} from "./collect.js";
|
||||||
|
|
||||||
|
export { formatPreferenceCatalogForPrompt } from "./catalog.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
extractPreferenceTurn,
|
||||||
|
type PreferenceExtractMessage,
|
||||||
|
type PreferenceExtractCandidate,
|
||||||
|
type PreferenceExtractResult,
|
||||||
|
} from "./extract.js";
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* 全局用户偏好库:跨局约束条目(禁区 / 节奏 / 写法纠偏等)。
|
||||||
|
* 手写与游玩采集入库后同形;采集入库须经审核卡。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 PreferenceStatus = "active" | "archived";
|
||||||
|
|
||||||
|
export type PreferenceEntry = {
|
||||||
|
id: string;
|
||||||
|
/** 可执行约束正文(含替代做法) */
|
||||||
|
content: string;
|
||||||
|
status: PreferenceStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreferenceStoreFile = {
|
||||||
|
version: 1;
|
||||||
|
entries: PreferenceEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILE_NAME = "preferences.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置起步条目(稳定 id)。库空或缺少对应 id 时写入;用户可改/归档/删。
|
||||||
|
* 写法:短、具体、可执行;不写「何时静音 / 本局例外」——交给选用/筛选层。
|
||||||
|
*/
|
||||||
|
export const STARTER_PREFERENCES: ReadonlyArray<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "starter-no-user-ntr-victim",
|
||||||
|
content:
|
||||||
|
"用户代入角色不做被 NTR 的一方(恋人/伴侣与第三者发生性或背叛性感情,用户角色受害或被欺瞒)。用户角色 NTR 别人不受此限。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starter-default-male-hetero-yuri-ok",
|
||||||
|
content:
|
||||||
|
"禁止对男同性恋内容的直接描写,减少或忽略间接描写。默认用户角色为男性,情爱与性吸引朝向女性;女同性恋内容可写。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function storePath(): string {
|
||||||
|
return path.join(getUserDataDir(), FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStarterEntries(at = nowIso()): PreferenceEntry[] {
|
||||||
|
return STARTER_PREFERENCES.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
content: s.content,
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStore(): PreferenceStoreFile {
|
||||||
|
return { version: 1, entries: buildStarterEntries() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺哪些起步 id 就补哪些;不覆盖用户已改过的同 id 正文 */
|
||||||
|
export function ensureStarterPreferences(): PreferenceEntry[] {
|
||||||
|
const store = loadPreferenceStore();
|
||||||
|
const have = new Set(store.entries.map((e) => e.id));
|
||||||
|
const at = nowIso();
|
||||||
|
let added = 0;
|
||||||
|
for (const s of STARTER_PREFERENCES) {
|
||||||
|
if (have.has(s.id)) continue;
|
||||||
|
store.entries.push({
|
||||||
|
id: s.id,
|
||||||
|
content: s.content,
|
||||||
|
status: "active",
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
});
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
if (added) savePreferenceStore(store);
|
||||||
|
return store.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(raw: unknown): PreferenceStatus {
|
||||||
|
return raw === "archived" ? "archived" : "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStore(raw: unknown): PreferenceStoreFile {
|
||||||
|
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: PreferenceEntry[] = [];
|
||||||
|
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 content = typeof p.content === "string" ? p.content.trim() : "";
|
||||||
|
if (!id || !content) continue;
|
||||||
|
entries.push({
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
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 loadPreferenceStore(): PreferenceStoreFile {
|
||||||
|
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;
|
||||||
|
const normalized = normalizeStore(parsed);
|
||||||
|
// 旧空库:补起步条目
|
||||||
|
if (normalized.entries.length === 0) {
|
||||||
|
const seeded = defaultStore();
|
||||||
|
writeFileSync(file, JSON.stringify(seeded, null, 2), "utf8");
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
} catch {
|
||||||
|
return defaultStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePreferenceStore(store: PreferenceStoreFile): void {
|
||||||
|
ensureUserDataDirs();
|
||||||
|
writeFileSync(storePath(), JSON.stringify(store, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPreferences(opts?: {
|
||||||
|
status?: PreferenceStatus | "all";
|
||||||
|
}): PreferenceEntry[] {
|
||||||
|
const status = opts?.status ?? "all";
|
||||||
|
const entries = loadPreferenceStore().entries;
|
||||||
|
if (status === "all") return entries;
|
||||||
|
return entries.filter((e) => e.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActivePreferences(): PreferenceEntry[] {
|
||||||
|
return listPreferences({ status: "active" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreference(id: string): PreferenceEntry | null {
|
||||||
|
return loadPreferenceStore().entries.find((e) => e.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPreference(input: {
|
||||||
|
content: string;
|
||||||
|
status?: PreferenceStatus;
|
||||||
|
}): PreferenceEntry {
|
||||||
|
const content = input.content.trim();
|
||||||
|
if (!content) throw new Error("偏好内容不能为空");
|
||||||
|
const store = loadPreferenceStore();
|
||||||
|
const t = nowIso();
|
||||||
|
const entry: PreferenceEntry = {
|
||||||
|
id: randomUUID(),
|
||||||
|
content,
|
||||||
|
status: input.status === "archived" ? "archived" : "active",
|
||||||
|
createdAt: t,
|
||||||
|
updatedAt: t,
|
||||||
|
};
|
||||||
|
store.entries.push(entry);
|
||||||
|
savePreferenceStore(store);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePreference(
|
||||||
|
id: string,
|
||||||
|
patch: { content?: string; status?: PreferenceStatus },
|
||||||
|
): PreferenceEntry {
|
||||||
|
const store = loadPreferenceStore();
|
||||||
|
const idx = store.entries.findIndex((e) => e.id === id);
|
||||||
|
if (idx < 0) throw new Error("偏好条目不存在");
|
||||||
|
const cur = store.entries[idx]!;
|
||||||
|
const content =
|
||||||
|
patch.content !== undefined ? patch.content.trim() : cur.content;
|
||||||
|
if (!content) throw new Error("偏好内容不能为空");
|
||||||
|
const next: PreferenceEntry = {
|
||||||
|
...cur,
|
||||||
|
content,
|
||||||
|
status: patch.status !== undefined ? normalizeStatus(patch.status) : cur.status,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
store.entries[idx] = next;
|
||||||
|
savePreferenceStore(store);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePreference(id: string): void {
|
||||||
|
const store = loadPreferenceStore();
|
||||||
|
const next = store.entries.filter((e) => e.id !== id);
|
||||||
|
if (next.length === store.entries.length) {
|
||||||
|
throw new Error("偏好条目不存在");
|
||||||
|
}
|
||||||
|
store.entries = next;
|
||||||
|
savePreferenceStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审核通过后写入;content 以用户编辑后的正文为准 */
|
||||||
|
export function acceptPreferenceCandidate(content: string): PreferenceEntry {
|
||||||
|
return createPreference({ content, status: "active" });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
describe("libraries registry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||||
|
path.join(tmpdir(), "wa-lib-"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats style-packs and preferences when non-empty", async () => {
|
||||||
|
const { createStylePack } = await import("../src/style-pack/store.js");
|
||||||
|
const { ensureStarterPreferences } = await import(
|
||||||
|
"../src/preference/store.js"
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
formatBoundLibrariesForPrompt,
|
||||||
|
collectLibraryIdsFromModules,
|
||||||
|
} = await import("../src/libraries/index.js");
|
||||||
|
|
||||||
|
ensureStarterPreferences();
|
||||||
|
createStylePack({
|
||||||
|
name: "锋利爽文",
|
||||||
|
content: "短句推进;示范:他抬眼,门开了。",
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = formatBoundLibrariesForPrompt(["style-packs"]);
|
||||||
|
expect(style).toContain("【文风库 · 可选用】");
|
||||||
|
expect(style).toContain("锋利爽文");
|
||||||
|
|
||||||
|
const pref = formatBoundLibrariesForPrompt(["preferences"]);
|
||||||
|
expect(pref).toContain("【偏好库 · 可选用】");
|
||||||
|
expect(pref).toContain("被 NTR");
|
||||||
|
|
||||||
|
expect(formatBoundLibrariesForPrompt(["unknown-lib"])).toBe("");
|
||||||
|
expect(
|
||||||
|
collectLibraryIdsFromModules([
|
||||||
|
{ libraries: ["style-packs"] },
|
||||||
|
{ libraries: ["preferences", "style-packs"] },
|
||||||
|
]),
|
||||||
|
).toEqual(["style-packs", "preferences"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty style-packs library", async () => {
|
||||||
|
const { formatBoundLibrariesForPrompt } = await import(
|
||||||
|
"../src/libraries/index.js"
|
||||||
|
);
|
||||||
|
// 空文风库 → 空串;偏好库有起步条目仍会有内容
|
||||||
|
expect(formatBoundLibrariesForPrompt(["style-packs"])).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||||
|
|
||||||
|
describe("preference store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||||
|
path.join(tmpdir(), "wa-pref-"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds starter preferences on empty store", async () => {
|
||||||
|
const {
|
||||||
|
STARTER_PREFERENCES,
|
||||||
|
ensureStarterPreferences,
|
||||||
|
listActivePreferences,
|
||||||
|
loadPreferenceStore,
|
||||||
|
} = await import("../src/preference/store.js");
|
||||||
|
|
||||||
|
const seeded = loadPreferenceStore();
|
||||||
|
expect(seeded.entries.length).toBe(STARTER_PREFERENCES.length);
|
||||||
|
expect(listActivePreferences().map((e) => e.id).sort()).toEqual(
|
||||||
|
STARTER_PREFERENCES.map((s) => s.id).sort(),
|
||||||
|
);
|
||||||
|
ensureStarterPreferences();
|
||||||
|
expect(listActivePreferences()).toHaveLength(STARTER_PREFERENCES.length);
|
||||||
|
expect(
|
||||||
|
listActivePreferences().some((e) => e.content.includes("被 NTR")),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
listActivePreferences().some((e) => e.content.includes("男同性恋")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, lists, archives, and deletes custom entries", async () => {
|
||||||
|
const {
|
||||||
|
STARTER_PREFERENCES,
|
||||||
|
createPreference,
|
||||||
|
listActivePreferences,
|
||||||
|
listPreferences,
|
||||||
|
updatePreference,
|
||||||
|
deletePreference,
|
||||||
|
loadPreferenceStore,
|
||||||
|
} = await import("../src/preference/store.js");
|
||||||
|
|
||||||
|
loadPreferenceStore();
|
||||||
|
const starterCount = STARTER_PREFERENCES.length;
|
||||||
|
const a = createPreference({
|
||||||
|
content: "关系线保持专一;不写出轨已成。",
|
||||||
|
});
|
||||||
|
expect(listActivePreferences()).toHaveLength(starterCount + 1);
|
||||||
|
updatePreference(a.id, { status: "archived" });
|
||||||
|
expect(listActivePreferences()).toHaveLength(starterCount);
|
||||||
|
expect(listPreferences()).toHaveLength(starterCount + 1);
|
||||||
|
deletePreference(a.id);
|
||||||
|
expect(listPreferences()).toHaveLength(starterCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("user constraints", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||||
|
path.join(tmpdir(), "wa-pref-c-"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds full dump and mounts into play spec", async () => {
|
||||||
|
const {
|
||||||
|
STARTER_PREFERENCES,
|
||||||
|
createPreference,
|
||||||
|
loadPreferenceStore,
|
||||||
|
} = await import("../src/preference/store.js");
|
||||||
|
const {
|
||||||
|
USER_CONSTRAINTS_TAG,
|
||||||
|
buildUserConstraintsContent,
|
||||||
|
applyUserConstraintsToPlaySpec,
|
||||||
|
} = await import("../src/preference/constraints.js");
|
||||||
|
|
||||||
|
loadPreferenceStore();
|
||||||
|
createPreference({ content: "不要反复报精确数字;用量感与约数。" });
|
||||||
|
createPreference({ content: "推进偏慢;每轮只推进一个小决定点。" });
|
||||||
|
|
||||||
|
const body = buildUserConstraintsContent();
|
||||||
|
expect(body).toContain("【用户约束】");
|
||||||
|
expect(body).toContain("精确数字");
|
||||||
|
expect(body).toContain("推进偏慢");
|
||||||
|
expect(body).toContain("被 NTR");
|
||||||
|
|
||||||
|
const board = new Blackboard();
|
||||||
|
board.write({
|
||||||
|
tag: "设计.worker集",
|
||||||
|
content: JSON.stringify({
|
||||||
|
play_slots: { gm: true, narrator: true, auditor: false, perspective: false },
|
||||||
|
brief: "test",
|
||||||
|
}),
|
||||||
|
source: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const applied = applyUserConstraintsToPlaySpec(board);
|
||||||
|
expect(applied.mounted).toBe(true);
|
||||||
|
expect(applied.count).toBe(STARTER_PREFERENCES.length + 2);
|
||||||
|
expect(board.getContentByTag(USER_CONSTRAINTS_TAG)).toContain("精确数字");
|
||||||
|
const order = board.getContentByTag("设计.上下文投影排序") ?? "";
|
||||||
|
expect(order).toContain(USER_CONSTRAINTS_TAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears mount when all preferences archived", async () => {
|
||||||
|
const {
|
||||||
|
listPreferences,
|
||||||
|
updatePreference,
|
||||||
|
loadPreferenceStore,
|
||||||
|
} = await import("../src/preference/store.js");
|
||||||
|
const {
|
||||||
|
USER_CONSTRAINTS_TAG,
|
||||||
|
applyUserConstraintsToPlaySpec,
|
||||||
|
} = await import("../src/preference/constraints.js");
|
||||||
|
|
||||||
|
loadPreferenceStore();
|
||||||
|
for (const e of listPreferences()) {
|
||||||
|
updatePreference(e.id, { status: "archived" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const board = new Blackboard();
|
||||||
|
board.write({
|
||||||
|
tag: USER_CONSTRAINTS_TAG,
|
||||||
|
content: "旧约束",
|
||||||
|
source: "test",
|
||||||
|
});
|
||||||
|
board.write({
|
||||||
|
tag: "设计.worker集",
|
||||||
|
content: JSON.stringify({
|
||||||
|
play_slots: { gm: true, narrator: false },
|
||||||
|
context_order: {
|
||||||
|
schema: "context-order.v1",
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
ref: "world-simulator",
|
||||||
|
inserts: [
|
||||||
|
{ order: 0, ref: "worker.persona", projection: "fixed" },
|
||||||
|
{ order: 1, ref: USER_CONSTRAINTS_TAG, projection: "full" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
source: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const applied = applyUserConstraintsToPlaySpec(board);
|
||||||
|
expect(applied.mounted).toBe(false);
|
||||||
|
expect(board.getContentByTag(USER_CONSTRAINTS_TAG)).toBe("");
|
||||||
|
const order = board.getContentByTag("设计.上下文投影排序") ?? "";
|
||||||
|
expect(order).not.toContain(`"ref": "${USER_CONSTRAINTS_TAG}"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user