把偏好库和文风库做成可提交的种子,方便数据丢了还能合并恢复。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+3
-1
@@ -13,7 +13,9 @@
|
|||||||
"phase-script": "tsx src/cli/phase-demo.ts --script --auto",
|
"phase-script": "tsx src/cli/phase-demo.ts --script --auto",
|
||||||
"web": "tsx src/server/web-server.ts",
|
"web": "tsx src/server/web-server.ts",
|
||||||
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
|
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
|
||||||
"electron": "electron ."
|
"electron": "electron .",
|
||||||
|
"libraries:restore": "tsx src/cli/restore-library-seeds.ts",
|
||||||
|
"libraries:export": "tsx src/cli/restore-library-seeds.ts --export"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 库种子(git 备份)
|
||||||
|
|
||||||
|
本目录保存偏好库、文风库的可提交种子,便于数据丢失后恢复。
|
||||||
|
|
||||||
|
- `preferences.json` / `style-packs.json`:与用户目录中同名文件同形(`version: 1` + `entries`)。
|
||||||
|
- 运行时库仍在用户数据目录;本目录只作仓库备份与恢复源。
|
||||||
|
|
||||||
|
恢复(合并写入,不覆盖已有同 id 正文):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx src/cli/restore-library-seeds.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
导出当前用户库到本目录(覆盖种子文件,便于更新 git 备份):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx src/cli/restore-library-seeds.ts --export
|
||||||
|
```
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "starter-no-user-ntr-victim",
|
||||||
|
"content": "用户代入角色不做被 NTR 的一方(恋人/伴侣与第三者发生性或背叛性感情,用户角色受害或被欺瞒)。用户角色 NTR 别人不受此限。",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-01-01T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "starter-default-male-hetero-yuri-ok",
|
||||||
|
"content": "禁止对男同性恋内容的直接描写,减少或忽略间接描写。默认用户角色为男性,情爱与性吸引朝向女性;女同性恋内容可写。",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"entries": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 从仓库 seeds/libraries 恢复偏好库/文风库,或导出当前库到种子目录。
|
||||||
|
*
|
||||||
|
* npx tsx src/cli/restore-library-seeds.ts
|
||||||
|
* npx tsx src/cli/restore-library-seeds.ts --export
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
exportLibrarySeedsToRepo,
|
||||||
|
restoreLibrarySeedsFromRepo,
|
||||||
|
} from "../libraries/seed-backup.js";
|
||||||
|
|
||||||
|
const exportMode = process.argv.includes("--export");
|
||||||
|
|
||||||
|
if (exportMode) {
|
||||||
|
const result = exportLibrarySeedsToRepo();
|
||||||
|
console.log(
|
||||||
|
`已导出种子:preferences=${result.preferences} style-packs=${result.stylePacks}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const result = restoreLibrarySeedsFromRepo();
|
||||||
|
console.log(
|
||||||
|
`已合并恢复:preferences +${result.preferencesAdded} style-packs +${result.stylePacksAdded}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 仓库内库种子 ↔ 用户数据目录库文件的导入/导出。
|
||||||
|
* 导入:缺 id 才补,不覆盖用户已改同 id 正文。
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
loadPreferenceStore,
|
||||||
|
savePreferenceStore,
|
||||||
|
type PreferenceStoreFile,
|
||||||
|
} from "../preference/store.js";
|
||||||
|
import {
|
||||||
|
loadStylePackStore,
|
||||||
|
saveStylePackStore,
|
||||||
|
type StylePackStoreFile,
|
||||||
|
} from "../style-pack/store.js";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../..",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LIBRARY_SEED_DIR = path.join(ROOT, "seeds", "libraries");
|
||||||
|
|
||||||
|
function readJsonFile(file: string): unknown {
|
||||||
|
return JSON.parse(readFileSync(file, "utf8")) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreferenceStore(raw: unknown): raw is PreferenceStoreFile {
|
||||||
|
return (
|
||||||
|
!!raw &&
|
||||||
|
typeof raw === "object" &&
|
||||||
|
!Array.isArray(raw) &&
|
||||||
|
(raw as PreferenceStoreFile).version === 1 &&
|
||||||
|
Array.isArray((raw as PreferenceStoreFile).entries)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStylePackStore(raw: unknown): raw is StylePackStoreFile {
|
||||||
|
return (
|
||||||
|
!!raw &&
|
||||||
|
typeof raw === "object" &&
|
||||||
|
!Array.isArray(raw) &&
|
||||||
|
(raw as StylePackStoreFile).version === 1 &&
|
||||||
|
Array.isArray((raw as StylePackStoreFile).entries)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreLibrarySeedsFromRepo(): {
|
||||||
|
preferencesAdded: number;
|
||||||
|
stylePacksAdded: number;
|
||||||
|
} {
|
||||||
|
const prefPath = path.join(LIBRARY_SEED_DIR, "preferences.json");
|
||||||
|
const stylePath = path.join(LIBRARY_SEED_DIR, "style-packs.json");
|
||||||
|
let preferencesAdded = 0;
|
||||||
|
let stylePacksAdded = 0;
|
||||||
|
|
||||||
|
if (existsSync(prefPath)) {
|
||||||
|
const seed = readJsonFile(prefPath);
|
||||||
|
if (isPreferenceStore(seed)) {
|
||||||
|
const store = loadPreferenceStore();
|
||||||
|
const have = new Set(store.entries.map((entry) => entry.id));
|
||||||
|
for (const entry of seed.entries) {
|
||||||
|
if (!entry.id || have.has(entry.id)) continue;
|
||||||
|
store.entries.push(entry);
|
||||||
|
have.add(entry.id);
|
||||||
|
preferencesAdded += 1;
|
||||||
|
}
|
||||||
|
if (preferencesAdded) savePreferenceStore(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(stylePath)) {
|
||||||
|
const seed = readJsonFile(stylePath);
|
||||||
|
if (isStylePackStore(seed)) {
|
||||||
|
const store = loadStylePackStore();
|
||||||
|
const have = new Set(store.entries.map((entry) => entry.id));
|
||||||
|
for (const entry of seed.entries) {
|
||||||
|
if (!entry.id || have.has(entry.id)) continue;
|
||||||
|
store.entries.push(entry);
|
||||||
|
have.add(entry.id);
|
||||||
|
stylePacksAdded += 1;
|
||||||
|
}
|
||||||
|
if (stylePacksAdded) saveStylePackStore(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preferencesAdded, stylePacksAdded };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportLibrarySeedsToRepo(): {
|
||||||
|
preferences: number;
|
||||||
|
stylePacks: number;
|
||||||
|
} {
|
||||||
|
mkdirSync(LIBRARY_SEED_DIR, { recursive: true });
|
||||||
|
const preferences = loadPreferenceStore();
|
||||||
|
const stylePacks = loadStylePackStore();
|
||||||
|
writeFileSync(
|
||||||
|
path.join(LIBRARY_SEED_DIR, "preferences.json"),
|
||||||
|
`${JSON.stringify(preferences, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(LIBRARY_SEED_DIR, "style-packs.json"),
|
||||||
|
`${JSON.stringify(stylePacks, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
preferences: preferences.entries.length,
|
||||||
|
stylePacks: stylePacks.entries.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
LIBRARY_SEED_DIR,
|
||||||
|
restoreLibrarySeedsFromRepo,
|
||||||
|
} from "../src/libraries/seed-backup.js";
|
||||||
|
import {
|
||||||
|
listActivePreferences,
|
||||||
|
loadPreferenceStore,
|
||||||
|
savePreferenceStore,
|
||||||
|
} from "../src/preference/store.js";
|
||||||
|
|
||||||
|
const originalUserData = process.env.WRITING_AGENT_DATA_DIR;
|
||||||
|
|
||||||
|
describe("library seed backup", () => {
|
||||||
|
let tempDir = "";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(path.join(os.tmpdir(), "wa-lib-seed-"));
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = tempDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalUserData === undefined) {
|
||||||
|
delete process.env.WRITING_AGENT_DATA_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = originalUserData;
|
||||||
|
}
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores missing preference ids from repo seeds without overwriting", () => {
|
||||||
|
savePreferenceStore({
|
||||||
|
version: 1,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "starter-no-user-ntr-victim",
|
||||||
|
content: "用户已改过的正文",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = restoreLibrarySeedsFromRepo();
|
||||||
|
expect(result.preferencesAdded).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const store = loadPreferenceStore();
|
||||||
|
const kept = store.entries.find((e) => e.id === "starter-no-user-ntr-victim");
|
||||||
|
expect(kept?.content).toBe("用户已改过的正文");
|
||||||
|
expect(
|
||||||
|
store.entries.some((e) => e.id === "starter-default-male-hetero-yuri-ok"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(listActivePreferences().length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps seed files in the repository libraries directory", () => {
|
||||||
|
const prefSeed = path.join(LIBRARY_SEED_DIR, "preferences.json");
|
||||||
|
const raw = JSON.parse(readFileSync(prefSeed, "utf8")) as {
|
||||||
|
version: number;
|
||||||
|
entries: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
expect(raw.version).toBe(1);
|
||||||
|
expect(raw.entries.some((e) => e.id === "starter-no-user-ntr-victim")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user