dsh-novel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1560 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1436 -0
- package/lib/index.js.map +1 -0
- package/lib/types/client/NovelWorkspaceView.d.ts +3 -0
- package/lib/types/client/entity-client.d.ts +17 -0
- package/lib/types/client/index.d.ts +3 -0
- package/lib/types/client/noveel-shapes.d.ts +1 -0
- package/lib/types/client/novel-api.d.ts +1 -0
- package/lib/types/client/section-icons.d.ts +5 -0
- package/lib/types/client/sections/ChapterSection.d.ts +2 -0
- package/lib/types/client/sections/LevelSection.d.ts +2 -0
- package/lib/types/client/sections/ListDetailSection.d.ts +13 -0
- package/lib/types/client/sections/OutlineSection.d.ts +2 -0
- package/lib/types/client/sections/OverviewSection.d.ts +2 -0
- package/lib/types/client/sections/SkillSection.d.ts +3 -0
- package/lib/types/client/sections/TimelineSection.d.ts +2 -0
- package/lib/types/client/sections/WorldSection.d.ts +2 -0
- package/lib/types/client/sections/owned-sections.d.ts +4 -0
- package/lib/types/client/sections/types.d.ts +14 -0
- package/lib/types/client/styles.d.ts +1 -0
- package/lib/types/client/ui/EntitySidebar.d.ts +10 -0
- package/lib/types/client/ui/RelationPanel.d.ts +6 -0
- package/lib/types/client/ui/TemplateRead.d.ts +13 -0
- package/lib/types/client/ui/TimelinePanel.d.ts +8 -0
- package/lib/types/client/ui/controls.d.ts +22 -0
- package/lib/types/client/ui/live-reload.d.ts +2 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/novel/contract.d.ts +2 -0
- package/lib/types/novel/defaults.d.ts +66 -0
- package/lib/types/novel/domains.d.ts +12 -0
- package/lib/types/novel/host-skills.d.ts +58 -0
- package/lib/types/novel/http.d.ts +5 -0
- package/lib/types/novel/normalize-entity.d.ts +18 -0
- package/lib/types/novel/noveel-shapes.d.ts +98 -0
- package/lib/types/novel/outline-labels.d.ts +10 -0
- package/lib/types/novel/schema.d.ts +3 -0
- package/lib/types/novel/seed.d.ts +27 -0
- package/lib/types/novel/store.d.ts +96 -0
- package/lib/types/novel/template-fields.d.ts +17 -0
- package/lib/types/novel/timeline-labels.d.ts +2 -0
- package/lib/types/novel/tools.d.ts +3 -0
- package/package.json +77 -0
- package/seed/skills/chapter//345/216/273AI/345/221/263/344/270/216/350/264/250/346/243/200.md +34 -0
- package/seed/skills/chapter//345/257/271/350/257/235/344/270/216/346/203/205/347/273/252.md +52 -0
- package/seed/skills/chapter//347/253/240/350/212/202/346/225/260/346/215/256/347/273/264/346/212/244.md +61 -0
- package/seed/skills/chapter//347/253/240/350/212/202/350/212/202/345/245/217/344/270/216/345/274/200/345/244/264.md +61 -0
- package/seed/skills/chapter//347/253/240/351/246/226/351/222/251/345/255/220.md +40 -0
- package/seed/skills/creature//345/274/202/345/205/275/344/270/216/347/201/265/346/244/215.md +11 -0
- package/seed/skills/creature//347/224/237/347/211/251/346/241/243/346/241/210/350/246/201/347/264/240.md +13 -0
- package/seed/skills/genres//344/273/231/344/276/240/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +32 -0
- package/seed/skills/genres//344/273/231/344/276/240/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/genres//345/216/206/345/217/262/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +32 -0
- package/seed/skills/genres//345/216/206/345/217/262/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/genres//346/202/254/347/226/221/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +33 -0
- package/seed/skills/genres//346/202/254/347/226/221/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/genres//346/234/253/344/270/226/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +33 -0
- package/seed/skills/genres//346/234/253/344/270/226/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/genres//347/216/204/345/271/273/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +32 -0
- package/seed/skills/genres//347/216/204/345/271/273/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +22 -0
- package/seed/skills/genres//347/247/221/345/271/273/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +33 -0
- package/seed/skills/genres//347/247/221/345/271/273/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/genres//350/250/200/346/203/205/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +31 -0
- package/seed/skills/genres//350/250/200/346/203/205/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/genres//351/203/275/345/270/202/overview//351/242/230/346/235/220/350/247/204/345/210/231.md +32 -0
- package/seed/skills/genres//351/203/275/345/270/202/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +21 -0
- package/seed/skills/item//346/265/201/350/275/254/344/270/216/345/275/222/345/261/236.md +22 -0
- package/seed/skills/item//347/211/251/345/223/201/346/241/243/346/241/210/350/246/201/347/264/240.md +13 -0
- package/seed/skills/level//346/210/220/351/225/277/344/270/216/347/210/275/347/202/271.md +44 -0
- package/seed/skills/level//350/203/275/345/212/233/344/270/216/344/273/243/344/273/267.md +42 -0
- package/seed/skills/outline//345/211/247/346/203/205/351/252/250/346/236/266/346/265/201/346/260/264/347/272/277.md +51 -0
- package/seed/skills/outline//347/273/223/346/236/204/344/270/216/345/215/267/347/253/240/346/250/241/346/235/277.md +68 -0
- package/seed/skills/outline//351/273/204/351/207/221/344/270/211/347/253/240/344/270/216/351/252/214/346/224/266.md +42 -0
- package/seed/skills/overview//345/215/226/347/202/271/344/270/216/345/256/232/344/275/215.md +52 -0
- package/seed/skills/overview//345/274/200/344/271/246/347/247/215/345/255/220.md +60 -0
- package/seed/skills/overview//346/240/207/351/242/230/345/210/233/344/275/234.md +43 -0
- package/seed/skills/review/chapter//347/253/240/350/212/202/345/256/241/346/237/245.md +72 -0
- package/seed/skills/review/outline//345/244/247/347/272/262/345/256/241/346/237/245.md +74 -0
- package/seed/skills/role//344/272/272/347/211/251/345/241/221/351/200/240.md +52 -0
- package/seed/skills/role//345/205/263/347/263/273/344/270/216/351/205/215/350/247/222.md +53 -0
- package/seed/skills/role//350/247/222/350/211/262/346/241/243/346/241/210.md +52 -0
- package/seed/skills/timeline//344/272/213/344/273/266/350/275/264/350/247/204/345/210/222.md +33 -0
- package/seed/skills/timeline//347/212/266/346/200/201/345/220/214/346/255/245.md +41 -0
- package/seed/skills/world//344/270/226/347/225/214/350/247/204/345/210/231.md +30 -0
- package/seed/skills/world//344/274/217/347/254/224/350/264/246/346/234/254.md +44 -0
- package/seed/skills/world//345/212/277/345/212/233/344/270/216/345/234/260/345/233/276.md +52 -0
- package/seed/skills/writing-style/da-feng-da-geng-ren/STYLE.md +132 -0
- package/seed/skills/writing-style/da-feng-da-geng-ren/meta.json +6 -0
- package/seed/skills/writing-style/hei-dao-da-feng/STYLE.md +144 -0
- package/seed/skills/writing-style/hei-dao-da-feng/meta.json +6 -0
- package/seed/skills/writing-style/hei-dao-xie-huang/STYLE.md +139 -0
- package/seed/skills/writing-style/hei-dao-xie-huang/meta.json +6 -0
- package/seed/templates//344/273/231/344/276/240.json +1322 -0
- package/seed/templates//345/216/206/345/217/262.json +1318 -0
- package/seed/templates//346/202/254/347/226/221.json +1318 -0
- package/seed/templates//346/234/253/344/270/226.json +1127 -0
- package/seed/templates//347/216/204/345/271/273.json +1322 -0
- package/seed/templates//347/247/221/345/271/273.json +1322 -0
- package/seed/templates//350/250/200/346/203/205.json +1318 -0
- package/seed/templates//351/203/275/345/270/202.json +1318 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
+
import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
|
|
9
|
+
//#region src/novel/contract.ts
|
|
10
|
+
/** Same-origin HTTP prefix for the novel Host API. */
|
|
11
|
+
const NOVEL_API_PREFIX = "/dsh-novel/api";
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/novel/domains.ts
|
|
14
|
+
/** Entity domains aligned with Noveel registry names. */
|
|
15
|
+
const ENTITY_DOMAINS = [
|
|
16
|
+
"role",
|
|
17
|
+
"roleRelation",
|
|
18
|
+
"roleTimeline",
|
|
19
|
+
"creature",
|
|
20
|
+
"creatureTimeline",
|
|
21
|
+
"item",
|
|
22
|
+
"itemTimeline",
|
|
23
|
+
"level",
|
|
24
|
+
"world",
|
|
25
|
+
"worldTimeline",
|
|
26
|
+
"timeline",
|
|
27
|
+
"outline",
|
|
28
|
+
"chapter"
|
|
29
|
+
];
|
|
30
|
+
function isEntityDomain(value) {
|
|
31
|
+
return ENTITY_DOMAINS.includes(value);
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/novel/schema.ts
|
|
35
|
+
/** Create the single-novel schema used inside one workspace PGlite directory. */
|
|
36
|
+
async function initNovelSchema(db) {
|
|
37
|
+
await db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS novels (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
title TEXT NOT NULL,
|
|
41
|
+
description TEXT,
|
|
42
|
+
cover TEXT,
|
|
43
|
+
category TEXT NOT NULL DEFAULT '玄幻',
|
|
44
|
+
writing_style_id TEXT,
|
|
45
|
+
chapter_count INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
word_count INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
blueprint_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
48
|
+
outline_draft_md TEXT,
|
|
49
|
+
outline_final_md TEXT,
|
|
50
|
+
outline_status TEXT NOT NULL DEFAULT 'none',
|
|
51
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
52
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS writing_styles (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
slug TEXT NOT NULL UNIQUE,
|
|
58
|
+
name TEXT NOT NULL,
|
|
59
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
60
|
+
content TEXT NOT NULL DEFAULT '',
|
|
61
|
+
origin TEXT NOT NULL DEFAULT 'user',
|
|
62
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
63
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS form_templates (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
novel_id TEXT NOT NULL UNIQUE,
|
|
69
|
+
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
70
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
71
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS novel_entities (
|
|
75
|
+
domain TEXT NOT NULL,
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
name TEXT,
|
|
78
|
+
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
79
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
80
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
81
|
+
);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_novel_entities_domain ON novel_entities (domain);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_novel_entities_name ON novel_entities (domain, name);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS novel_skills (
|
|
86
|
+
id TEXT PRIMARY KEY,
|
|
87
|
+
section TEXT NOT NULL,
|
|
88
|
+
folder TEXT NOT NULL DEFAULT '',
|
|
89
|
+
agent_kind TEXT NOT NULL DEFAULT 'write',
|
|
90
|
+
title TEXT NOT NULL,
|
|
91
|
+
body TEXT NOT NULL DEFAULT '',
|
|
92
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
93
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
94
|
+
);
|
|
95
|
+
ALTER TABLE novel_skills ADD COLUMN IF NOT EXISTS folder TEXT NOT NULL DEFAULT '';
|
|
96
|
+
UPDATE novel_skills SET folder = CASE
|
|
97
|
+
WHEN folder <> '' THEN folder
|
|
98
|
+
WHEN agent_kind = 'review' AND section IN ('outline') THEN '审查/大纲'
|
|
99
|
+
WHEN agent_kind = 'review' AND section IN ('chapters', 'chapter') THEN '审查/章节'
|
|
100
|
+
WHEN section = 'overview' THEN '概述'
|
|
101
|
+
WHEN section = 'role' THEN '人物'
|
|
102
|
+
WHEN section = 'creature' THEN '生物'
|
|
103
|
+
WHEN section = 'item' THEN '物品'
|
|
104
|
+
WHEN section = 'level' THEN '等级'
|
|
105
|
+
WHEN section = 'world' THEN '世界'
|
|
106
|
+
WHEN section = 'timeline' THEN '时间线'
|
|
107
|
+
WHEN section = 'outline' THEN '大纲'
|
|
108
|
+
WHEN section IN ('chapter', 'chapters') THEN '章节'
|
|
109
|
+
ELSE '其他'
|
|
110
|
+
END
|
|
111
|
+
WHERE folder = '';
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS novel_chapter_change_batches (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
chapter_id TEXT NOT NULL,
|
|
117
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
118
|
+
ops_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
119
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
120
|
+
rolled_back_at TIMESTAMPTZ
|
|
121
|
+
);
|
|
122
|
+
`);
|
|
123
|
+
await db.exec(`
|
|
124
|
+
DELETE FROM novel_skills WHERE folder = '题材' OR folder LIKE '题材/%'
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/novel/defaults.ts
|
|
129
|
+
/** Minimal form template so a new workspace is immediately writable. */
|
|
130
|
+
const DEFAULT_FORM_TEMPLATE = {
|
|
131
|
+
version: 1,
|
|
132
|
+
overview: { fields: [
|
|
133
|
+
{
|
|
134
|
+
key: "genre",
|
|
135
|
+
label: "小说类型",
|
|
136
|
+
component: "text"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: "tone",
|
|
140
|
+
label: "基调",
|
|
141
|
+
component: "text"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "coreConflict",
|
|
145
|
+
label: "核心冲突",
|
|
146
|
+
component: "textarea"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: "logline",
|
|
150
|
+
label: "一句话故事",
|
|
151
|
+
component: "textarea"
|
|
152
|
+
}
|
|
153
|
+
] },
|
|
154
|
+
role: { detailFields: [{
|
|
155
|
+
key: "summary",
|
|
156
|
+
label: "简介",
|
|
157
|
+
component: "textarea"
|
|
158
|
+
}] },
|
|
159
|
+
creature: { detailFields: [{
|
|
160
|
+
key: "summary",
|
|
161
|
+
label: "简介",
|
|
162
|
+
component: "textarea"
|
|
163
|
+
}] },
|
|
164
|
+
item: { detailFields: [{
|
|
165
|
+
key: "summary",
|
|
166
|
+
label: "简介",
|
|
167
|
+
component: "textarea"
|
|
168
|
+
}] },
|
|
169
|
+
level: { detailFields: [{
|
|
170
|
+
key: "summary",
|
|
171
|
+
label: "体系说明",
|
|
172
|
+
component: "textarea"
|
|
173
|
+
}] },
|
|
174
|
+
world: { detailFields: [{
|
|
175
|
+
key: "summary",
|
|
176
|
+
label: "说明",
|
|
177
|
+
component: "textarea"
|
|
178
|
+
}] },
|
|
179
|
+
timeline: { detailFields: [{
|
|
180
|
+
key: "content",
|
|
181
|
+
label: "内容",
|
|
182
|
+
component: "textarea"
|
|
183
|
+
}] },
|
|
184
|
+
outline: { detailFields: [{
|
|
185
|
+
key: "summary",
|
|
186
|
+
label: "说明",
|
|
187
|
+
component: "textarea"
|
|
188
|
+
}] }
|
|
189
|
+
};
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/novel/template-fields.ts
|
|
192
|
+
const TEMPLATE_PRESET_MAIN_CATEGORIES = [
|
|
193
|
+
"玄幻",
|
|
194
|
+
"都市",
|
|
195
|
+
"科幻",
|
|
196
|
+
"末世",
|
|
197
|
+
"仙侠",
|
|
198
|
+
"言情",
|
|
199
|
+
"悬疑",
|
|
200
|
+
"历史"
|
|
201
|
+
];
|
|
202
|
+
const CATEGORY_ALIASES = {
|
|
203
|
+
玄幻: "玄幻",
|
|
204
|
+
东方玄幻: "玄幻",
|
|
205
|
+
异世大陆: "玄幻",
|
|
206
|
+
高武世界: "玄幻",
|
|
207
|
+
都市: "都市",
|
|
208
|
+
都市异能: "都市",
|
|
209
|
+
科幻: "科幻",
|
|
210
|
+
星际: "科幻",
|
|
211
|
+
末世: "末世",
|
|
212
|
+
末日: "末世",
|
|
213
|
+
仙侠: "仙侠",
|
|
214
|
+
修真: "仙侠",
|
|
215
|
+
言情: "言情",
|
|
216
|
+
悬疑: "悬疑",
|
|
217
|
+
推理: "悬疑",
|
|
218
|
+
历史: "历史"
|
|
219
|
+
};
|
|
220
|
+
function normalizeTemplatePresetCategory(raw) {
|
|
221
|
+
const t = (raw ?? "").trim();
|
|
222
|
+
if (t.length === 0) return "玄幻";
|
|
223
|
+
const direct = CATEGORY_ALIASES[t];
|
|
224
|
+
if (direct !== void 0) return direct;
|
|
225
|
+
for (const main of TEMPLATE_PRESET_MAIN_CATEGORIES) if (t.includes(main)) return main;
|
|
226
|
+
return "玄幻";
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/novel/seed.ts
|
|
230
|
+
function packageRoot() {
|
|
231
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
232
|
+
if (basename(here) === "novel") return join(here, "..", "..");
|
|
233
|
+
return join(here, "..");
|
|
234
|
+
}
|
|
235
|
+
function seedTemplatesDir() {
|
|
236
|
+
return join(packageRoot(), "seed", "templates");
|
|
237
|
+
}
|
|
238
|
+
function seedSkillsDir() {
|
|
239
|
+
return join(packageRoot(), "seed", "skills");
|
|
240
|
+
}
|
|
241
|
+
function loadRecommendedTemplate(category) {
|
|
242
|
+
const main = normalizeTemplatePresetCategory(category);
|
|
243
|
+
const primary = join(seedTemplatesDir(), `${main}.json`);
|
|
244
|
+
const fallback = join(seedTemplatesDir(), "玄幻.json");
|
|
245
|
+
const path = existsSync(primary) ? primary : fallback;
|
|
246
|
+
if (!existsSync(path)) throw new Error(`未找到推荐模板:seed/templates/${main}.json`);
|
|
247
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
248
|
+
}
|
|
249
|
+
function listBuiltinWritingStylePresets() {
|
|
250
|
+
const root = join(seedSkillsDir(), "writing-style");
|
|
251
|
+
if (!existsSync(root)) return [];
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
254
|
+
if (!ent.isDirectory() || ent.name.startsWith("_")) continue;
|
|
255
|
+
if (!existsSync(join(root, ent.name, "STYLE.md"))) continue;
|
|
256
|
+
const metaPath = join(root, ent.name, "meta.json");
|
|
257
|
+
let name = ent.name;
|
|
258
|
+
if (existsSync(metaPath)) try {
|
|
259
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
260
|
+
if (typeof meta.name === "string" && meta.name.trim().length > 0) name = meta.name.trim();
|
|
261
|
+
} catch {}
|
|
262
|
+
const id = createHash("sha256").update(`builtin-ws:${ent.name}`).digest("hex").slice(0, 8);
|
|
263
|
+
out.push({
|
|
264
|
+
id,
|
|
265
|
+
slug: ent.name,
|
|
266
|
+
name
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
async function seedWritingStyleBuiltins(db) {
|
|
272
|
+
const root = join(seedSkillsDir(), "writing-style");
|
|
273
|
+
if (!existsSync(root)) return;
|
|
274
|
+
for (const preset of listBuiltinWritingStylePresets()) {
|
|
275
|
+
const content = readFileSync(join(root, preset.slug, "STYLE.md"), "utf8");
|
|
276
|
+
const metaPath = join(root, preset.slug, "meta.json");
|
|
277
|
+
let summary = "";
|
|
278
|
+
if (existsSync(metaPath)) try {
|
|
279
|
+
summary = JSON.parse(readFileSync(metaPath, "utf8")).summary?.trim() ?? "";
|
|
280
|
+
} catch {
|
|
281
|
+
summary = "";
|
|
282
|
+
}
|
|
283
|
+
await db.query(`INSERT INTO writing_styles (id, slug, name, summary, content, origin)
|
|
284
|
+
VALUES ($1, $2, $3, $4, $5, 'builtin')
|
|
285
|
+
ON CONFLICT (slug) DO UPDATE SET
|
|
286
|
+
name = EXCLUDED.name,
|
|
287
|
+
summary = EXCLUDED.summary,
|
|
288
|
+
content = EXCLUDED.content,
|
|
289
|
+
updated_at = NOW()`, [
|
|
290
|
+
preset.id,
|
|
291
|
+
preset.slug,
|
|
292
|
+
preset.name,
|
|
293
|
+
summary,
|
|
294
|
+
content
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const DIR_TO_SECTION = {
|
|
299
|
+
overview: "overview",
|
|
300
|
+
role: "role",
|
|
301
|
+
creature: "creature",
|
|
302
|
+
level: "level",
|
|
303
|
+
world: "world",
|
|
304
|
+
timeline: "timeline",
|
|
305
|
+
outline: "outline",
|
|
306
|
+
chapter: "chapters",
|
|
307
|
+
item: "item"
|
|
308
|
+
};
|
|
309
|
+
const SECTION_TO_FOLDER = {
|
|
310
|
+
overview: "概述",
|
|
311
|
+
role: "人物",
|
|
312
|
+
creature: "生物",
|
|
313
|
+
item: "物品",
|
|
314
|
+
level: "等级",
|
|
315
|
+
world: "世界",
|
|
316
|
+
timeline: "时间线",
|
|
317
|
+
outline: "大纲",
|
|
318
|
+
chapter: "章节",
|
|
319
|
+
chapters: "章节"
|
|
320
|
+
};
|
|
321
|
+
function readMdDir(dir, section, agentKind, folder) {
|
|
322
|
+
if (!existsSync(dir)) return [];
|
|
323
|
+
const out = [];
|
|
324
|
+
for (const file of readdirSync(dir).filter((name) => name.endsWith(".md"))) {
|
|
325
|
+
const body = readFileSync(join(dir, file), "utf8");
|
|
326
|
+
if (body.trim().length === 0) continue;
|
|
327
|
+
out.push({
|
|
328
|
+
section,
|
|
329
|
+
folder,
|
|
330
|
+
agent_kind: agentKind,
|
|
331
|
+
title: basename(file, ".md"),
|
|
332
|
+
body
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
/** Disk skill markdown used both to seed a workspace and to advertise module skills before init. */
|
|
338
|
+
function listSeedSkillRows() {
|
|
339
|
+
const ref = seedSkillsDir();
|
|
340
|
+
const rows = [];
|
|
341
|
+
for (const [dir, section] of Object.entries(DIR_TO_SECTION)) rows.push(...readMdDir(join(ref, dir), section, "write", SECTION_TO_FOLDER[dir] ?? dir));
|
|
342
|
+
rows.push(...readMdDir(join(ref, "review", "outline"), "outline", "review", "审查/大纲"));
|
|
343
|
+
rows.push(...readMdDir(join(ref, "review", "chapter"), "chapters", "review", "审查/章节"));
|
|
344
|
+
return rows;
|
|
345
|
+
}
|
|
346
|
+
/** Seed generic skill markdown (genre-specific files under seed/skills/genres are not loaded). */
|
|
347
|
+
async function seedNovelSkills(db, _category) {
|
|
348
|
+
for (const row of listSeedSkillRows()) await db.query(`INSERT INTO novel_skills (id, section, folder, agent_kind, title, body)
|
|
349
|
+
VALUES ($1, $2, $3, $4, $5, $6)`, [
|
|
350
|
+
randomUUID(),
|
|
351
|
+
row.section,
|
|
352
|
+
row.folder,
|
|
353
|
+
row.agent_kind,
|
|
354
|
+
row.title,
|
|
355
|
+
row.body
|
|
356
|
+
]);
|
|
357
|
+
}
|
|
358
|
+
async function listWritingStyles(db) {
|
|
359
|
+
const { rows } = await db.query(`SELECT id, name, slug FROM writing_styles ORDER BY origin, name`);
|
|
360
|
+
return rows;
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/novel/outline-labels.ts
|
|
364
|
+
function asOrdinal(value) {
|
|
365
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
366
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
367
|
+
const n = Number(value.trim());
|
|
368
|
+
if (Number.isFinite(n)) return n;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function canonicalizeOutlineKind(raw) {
|
|
372
|
+
if (typeof raw !== "string") return void 0;
|
|
373
|
+
const key = raw.trim();
|
|
374
|
+
if (key === "chapter_segment" || key === "chapter" || key === "章") return "chapter_segment";
|
|
375
|
+
if (key === "volume" || key === "卷") return "volume";
|
|
376
|
+
}
|
|
377
|
+
function outlineKindOf(data) {
|
|
378
|
+
const explicit = canonicalizeOutlineKind(data.kind ?? data.nodeKind);
|
|
379
|
+
if (explicit !== void 0) return explicit;
|
|
380
|
+
return typeof data.parentId === "string" && data.parentId.length > 0 || typeof data.parentName === "string" && data.parentName.length > 0 ? "chapter_segment" : "volume";
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/novel/normalize-entity.ts
|
|
384
|
+
function asRecord(value) {
|
|
385
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
386
|
+
}
|
|
387
|
+
function asNonEmptyString(value) {
|
|
388
|
+
if (typeof value !== "string") return void 0;
|
|
389
|
+
const trimmed = value.trim();
|
|
390
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
391
|
+
}
|
|
392
|
+
/** Merge nested `profile` onto the root; existing top-level keys win. */
|
|
393
|
+
function hoistProfile(data) {
|
|
394
|
+
return {
|
|
395
|
+
...asRecord(data.profile),
|
|
396
|
+
...data
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** Merge outline `extra` onto the root; existing top-level keys win. */
|
|
400
|
+
function hoistOutlineExtra(data) {
|
|
401
|
+
return {
|
|
402
|
+
...asRecord(data.extra),
|
|
403
|
+
...data
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function levelNameOf(data) {
|
|
407
|
+
return asNonEmptyString(data.name) ?? asNonEmptyString(data.title) ?? asNonEmptyString(asRecord(data.system).name);
|
|
408
|
+
}
|
|
409
|
+
const FROM_KEYS = [
|
|
410
|
+
"roleId",
|
|
411
|
+
"from",
|
|
412
|
+
"fromName",
|
|
413
|
+
"source",
|
|
414
|
+
"fromRole"
|
|
415
|
+
];
|
|
416
|
+
const TO_KEYS = [
|
|
417
|
+
"targetRoleId",
|
|
418
|
+
"to",
|
|
419
|
+
"toName",
|
|
420
|
+
"target",
|
|
421
|
+
"toRole"
|
|
422
|
+
];
|
|
423
|
+
function pickFirstString(data, keys) {
|
|
424
|
+
for (const key of keys) {
|
|
425
|
+
const value = asNonEmptyString(data[key]);
|
|
426
|
+
if (value !== void 0) return value;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function omitKeys(data, keys) {
|
|
430
|
+
const next = { ...data };
|
|
431
|
+
for (const key of keys) delete next[key];
|
|
432
|
+
return next;
|
|
433
|
+
}
|
|
434
|
+
function resolveRef(ref, map, label) {
|
|
435
|
+
const id = map.get(ref);
|
|
436
|
+
if (id === void 0) throw new Error(`${label} not found: ${ref}`);
|
|
437
|
+
return id;
|
|
438
|
+
}
|
|
439
|
+
/** Canonicalize mutate payloads so the content tab can read template keys. */
|
|
440
|
+
function normalizeEntityPayload(domain, raw, lookups) {
|
|
441
|
+
let data = hoistProfile(raw);
|
|
442
|
+
if (domain === "outline") data = hoistOutlineExtra(data);
|
|
443
|
+
if (domain === "level") {
|
|
444
|
+
const name = levelNameOf(data);
|
|
445
|
+
if (name !== void 0) data = {
|
|
446
|
+
...data,
|
|
447
|
+
name
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (domain === "world") {
|
|
451
|
+
const parentId = asNonEmptyString(data.parentId);
|
|
452
|
+
const parentName = asNonEmptyString(data.parentName);
|
|
453
|
+
if (parentId === void 0 && parentName !== void 0) data = {
|
|
454
|
+
...data,
|
|
455
|
+
parentId: resolveRef(parentName, lookups.worldIdByName, "world parent")
|
|
456
|
+
};
|
|
457
|
+
data = omitKeys(data, ["parentName"]);
|
|
458
|
+
}
|
|
459
|
+
if (domain === "outline") {
|
|
460
|
+
const parentId = asNonEmptyString(data.parentId);
|
|
461
|
+
const parentName = asNonEmptyString(data.parentName);
|
|
462
|
+
if (parentId === void 0 && parentName !== void 0) data = {
|
|
463
|
+
...data,
|
|
464
|
+
parentId: resolveRef(parentName, lookups.outlineIdByName, "outline parent")
|
|
465
|
+
};
|
|
466
|
+
const ordinal = asOrdinal(data.ordinal) ?? asOrdinal(data.chapterNo);
|
|
467
|
+
data = {
|
|
468
|
+
...data,
|
|
469
|
+
kind: outlineKindOf(data),
|
|
470
|
+
...ordinal !== void 0 ? { ordinal } : {}
|
|
471
|
+
};
|
|
472
|
+
data = omitKeys(data, [
|
|
473
|
+
"parentName",
|
|
474
|
+
"nodeKind",
|
|
475
|
+
"volumeNo",
|
|
476
|
+
"chapterNo"
|
|
477
|
+
]);
|
|
478
|
+
}
|
|
479
|
+
if (domain === "chapter") {
|
|
480
|
+
const relatedId = asNonEmptyString(data.relatedOutlineId);
|
|
481
|
+
const relatedName = asNonEmptyString(data.relatedOutlineName);
|
|
482
|
+
if (relatedId === void 0 && relatedName !== void 0) {
|
|
483
|
+
const id = lookups.outlineChapterIdByName.get(relatedName) ?? lookups.outlineIdByName.get(relatedName);
|
|
484
|
+
if (id === void 0) throw new Error(`outline chapter not found: ${relatedName}`);
|
|
485
|
+
data = {
|
|
486
|
+
...data,
|
|
487
|
+
relatedOutlineId: id
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const ordinal = asOrdinal(data.ordinal) ?? asOrdinal(data.chapterNo);
|
|
491
|
+
if (ordinal !== void 0) data = {
|
|
492
|
+
...data,
|
|
493
|
+
ordinal
|
|
494
|
+
};
|
|
495
|
+
data = omitKeys(data, [
|
|
496
|
+
"relatedOutlineName",
|
|
497
|
+
"chapterNo",
|
|
498
|
+
"volumeNo"
|
|
499
|
+
]);
|
|
500
|
+
}
|
|
501
|
+
if (domain === "roleRelation") {
|
|
502
|
+
const fromRef = pickFirstString(data, FROM_KEYS);
|
|
503
|
+
const toRef = pickFirstString(data, TO_KEYS);
|
|
504
|
+
if (fromRef === void 0 || toRef === void 0) throw new Error("roleRelation requires both ends (roleId/from and targetRoleId/to)");
|
|
505
|
+
data = {
|
|
506
|
+
...data,
|
|
507
|
+
roleId: resolveRef(fromRef, lookups.roleIdByRef, "role"),
|
|
508
|
+
targetRoleId: resolveRef(toRef, lookups.roleIdByRef, "role")
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
return data;
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/novel/store.ts
|
|
515
|
+
const NOVEL_DIR = ".dsh-novel";
|
|
516
|
+
const LEGACY_NOVEL_DIR = ".noveel";
|
|
517
|
+
const META_FILE = "meta.json";
|
|
518
|
+
function novelRoot(workspacePath) {
|
|
519
|
+
return join(workspacePath, NOVEL_DIR);
|
|
520
|
+
}
|
|
521
|
+
async function migrateLegacyNovelDir(workspacePath) {
|
|
522
|
+
const next = join(workspacePath, NOVEL_DIR);
|
|
523
|
+
const legacy = join(workspacePath, LEGACY_NOVEL_DIR);
|
|
524
|
+
try {
|
|
525
|
+
await access(next);
|
|
526
|
+
return;
|
|
527
|
+
} catch {}
|
|
528
|
+
try {
|
|
529
|
+
await access(legacy);
|
|
530
|
+
} catch {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
await rename(legacy, next);
|
|
534
|
+
}
|
|
535
|
+
function pgliteDir(workspacePath) {
|
|
536
|
+
return join(novelRoot(workspacePath), "pglite");
|
|
537
|
+
}
|
|
538
|
+
function metaPath(workspacePath) {
|
|
539
|
+
return join(novelRoot(workspacePath), META_FILE);
|
|
540
|
+
}
|
|
541
|
+
/** Return whether a workspace directory has been provisioned as a novel. */
|
|
542
|
+
async function isNovelWorkspace(workspacePath) {
|
|
543
|
+
await migrateLegacyNovelDir(workspacePath);
|
|
544
|
+
try {
|
|
545
|
+
const raw = await readFile(metaPath(workspacePath), "utf8");
|
|
546
|
+
return JSON.parse(raw).version === 1;
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const connections = /* @__PURE__ */ new Map();
|
|
552
|
+
async function openDb(workspacePath) {
|
|
553
|
+
await migrateLegacyNovelDir(workspacePath);
|
|
554
|
+
const existing = connections.get(workspacePath);
|
|
555
|
+
if (existing !== void 0) return existing;
|
|
556
|
+
const pending = (async () => {
|
|
557
|
+
const db = await PGlite.create(pgliteDir(workspacePath));
|
|
558
|
+
await initNovelSchema(db);
|
|
559
|
+
return db;
|
|
560
|
+
})();
|
|
561
|
+
connections.set(workspacePath, pending);
|
|
562
|
+
try {
|
|
563
|
+
return await pending;
|
|
564
|
+
} catch (cause) {
|
|
565
|
+
connections.delete(workspacePath);
|
|
566
|
+
throw cause;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** Initialize `.dsh-novel` + PGlite + default novel row for one workspace directory. */
|
|
570
|
+
async function provisionNovelWorkspace(workspacePath, input = {}) {
|
|
571
|
+
await migrateLegacyNovelDir(workspacePath);
|
|
572
|
+
if (await isNovelWorkspace(workspacePath)) {
|
|
573
|
+
const novel = await getNovel(workspacePath);
|
|
574
|
+
return {
|
|
575
|
+
meta: JSON.parse(await readFile(metaPath(workspacePath), "utf8")),
|
|
576
|
+
novel,
|
|
577
|
+
alreadyInitialized: true
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const novelId = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
581
|
+
const title = input.title?.trim() || basename(workspacePath);
|
|
582
|
+
const category = normalizeTemplatePresetCategory(input.category);
|
|
583
|
+
const meta = {
|
|
584
|
+
version: 1,
|
|
585
|
+
novelId,
|
|
586
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
587
|
+
};
|
|
588
|
+
await mkdir(pgliteDir(workspacePath), { recursive: true });
|
|
589
|
+
await writeFile(metaPath(workspacePath), `${JSON.stringify(meta, null, 2)}\n`);
|
|
590
|
+
const db = await openDb(workspacePath);
|
|
591
|
+
await seedWritingStyleBuiltins(db);
|
|
592
|
+
const writingStyleId = input.writingStyleId?.trim() || null;
|
|
593
|
+
const template = (() => {
|
|
594
|
+
try {
|
|
595
|
+
return loadRecommendedTemplate(category);
|
|
596
|
+
} catch {
|
|
597
|
+
return DEFAULT_FORM_TEMPLATE;
|
|
598
|
+
}
|
|
599
|
+
})();
|
|
600
|
+
await db.query(`INSERT INTO novels (id, title, category, writing_style_id) VALUES ($1, $2, $3, $4)`, [
|
|
601
|
+
novelId,
|
|
602
|
+
title,
|
|
603
|
+
category,
|
|
604
|
+
writingStyleId
|
|
605
|
+
]);
|
|
606
|
+
await db.query(`INSERT INTO form_templates (id, novel_id, config) VALUES ($1, $2, $3::jsonb)`, [
|
|
607
|
+
randomUUID(),
|
|
608
|
+
novelId,
|
|
609
|
+
JSON.stringify(template)
|
|
610
|
+
]);
|
|
611
|
+
await seedNovelSkills(db, category);
|
|
612
|
+
return {
|
|
613
|
+
meta,
|
|
614
|
+
novel: await getNovel(workspacePath),
|
|
615
|
+
alreadyInitialized: false
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** Load the singleton novel row. */
|
|
619
|
+
async function getNovel(workspacePath) {
|
|
620
|
+
const { rows } = await (await openDb(workspacePath)).query(`SELECT * FROM novels LIMIT 1`);
|
|
621
|
+
const row = rows[0];
|
|
622
|
+
if (row === void 0) throw new Error("novel row missing; re-provision this workspace");
|
|
623
|
+
return row;
|
|
624
|
+
}
|
|
625
|
+
/** Patch novel metadata / blueprint. */
|
|
626
|
+
async function updateNovel(workspacePath, patch) {
|
|
627
|
+
const current = await getNovel(workspacePath);
|
|
628
|
+
const next = {
|
|
629
|
+
...current,
|
|
630
|
+
...patch,
|
|
631
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
632
|
+
};
|
|
633
|
+
await (await openDb(workspacePath)).query(`UPDATE novels SET
|
|
634
|
+
title = $1, description = $2, cover = $3, category = $4, writing_style_id = $5,
|
|
635
|
+
blueprint_json = $6::jsonb, outline_draft_md = $7, outline_final_md = $8,
|
|
636
|
+
outline_status = $9, updated_at = NOW()
|
|
637
|
+
WHERE id = $10`, [
|
|
638
|
+
next.title,
|
|
639
|
+
next.description,
|
|
640
|
+
next.cover,
|
|
641
|
+
next.category,
|
|
642
|
+
next.writing_style_id,
|
|
643
|
+
JSON.stringify(next.blueprint_json ?? {}),
|
|
644
|
+
next.outline_draft_md,
|
|
645
|
+
next.outline_final_md,
|
|
646
|
+
next.outline_status,
|
|
647
|
+
current.id
|
|
648
|
+
]);
|
|
649
|
+
return getNovel(workspacePath);
|
|
650
|
+
}
|
|
651
|
+
/** List entities in one domain. */
|
|
652
|
+
async function listEntities(workspacePath, domain) {
|
|
653
|
+
const { rows } = await (await openDb(workspacePath)).query(`SELECT * FROM novel_entities WHERE domain = $1 ORDER BY name NULLS LAST, updated_at DESC`, [domain]);
|
|
654
|
+
return rows;
|
|
655
|
+
}
|
|
656
|
+
/** Load one entity. */
|
|
657
|
+
async function getEntity(workspacePath, id) {
|
|
658
|
+
const { rows } = await (await openDb(workspacePath)).query(`SELECT * FROM novel_entities WHERE id = $1`, [id]);
|
|
659
|
+
return rows[0];
|
|
660
|
+
}
|
|
661
|
+
async function lookupsFor(workspacePath, domain, entityId) {
|
|
662
|
+
const worldIdByName = /* @__PURE__ */ new Map();
|
|
663
|
+
const roleIdByRef = /* @__PURE__ */ new Map();
|
|
664
|
+
const outlineIdByName = /* @__PURE__ */ new Map();
|
|
665
|
+
const outlineChapterIdByName = /* @__PURE__ */ new Map();
|
|
666
|
+
if (domain === "world") for (const row of await listEntities(workspacePath, "world")) {
|
|
667
|
+
if (row.id === entityId) continue;
|
|
668
|
+
if (typeof row.name === "string" && row.name.length > 0) worldIdByName.set(row.name, row.id);
|
|
669
|
+
}
|
|
670
|
+
if (domain === "roleRelation") for (const row of await listEntities(workspacePath, "role")) {
|
|
671
|
+
roleIdByRef.set(row.id, row.id);
|
|
672
|
+
if (typeof row.name === "string" && row.name.length > 0) roleIdByRef.set(row.name, row.id);
|
|
673
|
+
}
|
|
674
|
+
if (domain === "outline" || domain === "chapter") {
|
|
675
|
+
const rows = await listEntities(workspacePath, "outline");
|
|
676
|
+
for (const row of rows) {
|
|
677
|
+
if (row.id === entityId) continue;
|
|
678
|
+
if (typeof row.name !== "string" || row.name.length === 0) continue;
|
|
679
|
+
outlineIdByName.set(row.name, row.id);
|
|
680
|
+
if (outlineKindOf(asRecord(row.data)) === "chapter_segment") outlineChapterIdByName.set(row.name, row.id);
|
|
681
|
+
}
|
|
682
|
+
for (const row of rows) {
|
|
683
|
+
if (row.id === entityId) continue;
|
|
684
|
+
if (typeof row.name !== "string" || row.name.length === 0) continue;
|
|
685
|
+
if (outlineKindOf(asRecord(row.data)) === "volume") outlineIdByName.set(row.name, row.id);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
worldIdByName,
|
|
690
|
+
roleIdByRef,
|
|
691
|
+
outlineIdByName,
|
|
692
|
+
outlineChapterIdByName
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
/** Insert or replace an entity. */
|
|
696
|
+
async function upsertEntity(workspacePath, domain, data, id) {
|
|
697
|
+
if (!isEntityDomain(domain)) throw new Error(`unknown domain ${domain}`);
|
|
698
|
+
const entityId = id ?? (typeof data.id === "string" && data.id.length > 0 ? data.id : randomUUID());
|
|
699
|
+
const normalized = normalizeEntityPayload(domain, data, await lookupsFor(workspacePath, domain, entityId));
|
|
700
|
+
const name = typeof normalized.name === "string" ? normalized.name : typeof normalized.title === "string" ? normalized.title : levelNameOf(normalized) ?? null;
|
|
701
|
+
await (await openDb(workspacePath)).query(`INSERT INTO novel_entities (domain, id, name, data)
|
|
702
|
+
VALUES ($1, $2, $3, $4::jsonb)
|
|
703
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
704
|
+
domain = EXCLUDED.domain,
|
|
705
|
+
name = EXCLUDED.name,
|
|
706
|
+
data = EXCLUDED.data,
|
|
707
|
+
updated_at = NOW()`, [
|
|
708
|
+
domain,
|
|
709
|
+
entityId,
|
|
710
|
+
name,
|
|
711
|
+
JSON.stringify({
|
|
712
|
+
...normalized,
|
|
713
|
+
id: entityId
|
|
714
|
+
})
|
|
715
|
+
]);
|
|
716
|
+
const row = await getEntity(workspacePath, entityId);
|
|
717
|
+
if (row === void 0) throw new Error("entity upsert failed");
|
|
718
|
+
return row;
|
|
719
|
+
}
|
|
720
|
+
/** Delete one entity. */
|
|
721
|
+
async function deleteEntity(workspacePath, id) {
|
|
722
|
+
const { rows } = await (await openDb(workspacePath)).query(`DELETE FROM novel_entities WHERE id = $1 RETURNING id`, [id]);
|
|
723
|
+
return rows.length > 0;
|
|
724
|
+
}
|
|
725
|
+
/** Form template JSON for the workspace novel. */
|
|
726
|
+
async function getTemplate(workspacePath) {
|
|
727
|
+
const { rows } = await (await openDb(workspacePath)).query(`SELECT config FROM form_templates LIMIT 1`);
|
|
728
|
+
return rows[0]?.config ?? DEFAULT_FORM_TEMPLATE;
|
|
729
|
+
}
|
|
730
|
+
/** Replace the form template. */
|
|
731
|
+
async function saveTemplate(workspacePath, config) {
|
|
732
|
+
const novel = await getNovel(workspacePath);
|
|
733
|
+
await (await openDb(workspacePath)).query(`INSERT INTO form_templates (id, novel_id, config) VALUES ($1, $2, $3::jsonb)
|
|
734
|
+
ON CONFLICT (novel_id) DO UPDATE SET config = EXCLUDED.config, updated_at = NOW()`, [
|
|
735
|
+
randomUUID(),
|
|
736
|
+
novel.id,
|
|
737
|
+
JSON.stringify(config)
|
|
738
|
+
]);
|
|
739
|
+
return getTemplate(workspacePath);
|
|
740
|
+
}
|
|
741
|
+
/** List writing skills. */
|
|
742
|
+
async function listSkills(workspacePath) {
|
|
743
|
+
const { rows } = await (await openDb(workspacePath)).query(`SELECT * FROM novel_skills
|
|
744
|
+
WHERE folder <> '题材' AND folder NOT LIKE '题材/%'
|
|
745
|
+
ORDER BY folder, title`);
|
|
746
|
+
return rows;
|
|
747
|
+
}
|
|
748
|
+
/** Create or update a skill. */
|
|
749
|
+
async function upsertSkill(workspacePath, skill) {
|
|
750
|
+
const id = skill.id ?? randomUUID();
|
|
751
|
+
const folder = skill.folder ?? SECTION_TO_FOLDER[skill.section] ?? "其他";
|
|
752
|
+
const db = await openDb(workspacePath);
|
|
753
|
+
await db.query(`INSERT INTO novel_skills (id, section, folder, agent_kind, title, body)
|
|
754
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
755
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
756
|
+
section = EXCLUDED.section,
|
|
757
|
+
folder = EXCLUDED.folder,
|
|
758
|
+
agent_kind = EXCLUDED.agent_kind,
|
|
759
|
+
title = EXCLUDED.title,
|
|
760
|
+
body = EXCLUDED.body,
|
|
761
|
+
updated_at = NOW()`, [
|
|
762
|
+
id,
|
|
763
|
+
skill.section,
|
|
764
|
+
folder,
|
|
765
|
+
skill.agentKind ?? "write",
|
|
766
|
+
skill.title,
|
|
767
|
+
skill.body
|
|
768
|
+
]);
|
|
769
|
+
const { rows } = await db.query(`SELECT * FROM novel_skills WHERE id = $1`, [id]);
|
|
770
|
+
const row = rows[0];
|
|
771
|
+
if (row === void 0) throw new Error("skill upsert failed");
|
|
772
|
+
return row;
|
|
773
|
+
}
|
|
774
|
+
/** Delete a skill. */
|
|
775
|
+
async function deleteSkill(workspacePath, id) {
|
|
776
|
+
const { rows } = await (await openDb(workspacePath)).query(`DELETE FROM novel_skills WHERE id = $1 RETURNING id`, [id]);
|
|
777
|
+
return rows.length > 0;
|
|
778
|
+
}
|
|
779
|
+
/** Static write contract for tools. Book-specific field keys still come from get_template. */
|
|
780
|
+
function describeSchema() {
|
|
781
|
+
return {
|
|
782
|
+
domains: ENTITY_DOMAINS,
|
|
783
|
+
notes: "One DSH workspace is one novel. overview lives on novels.blueprint_json. Call get_template for this book's field keys, then write those keys at the top level (not nested under profile).",
|
|
784
|
+
write: {
|
|
785
|
+
overview: "update_novel blueprint_json; keys from get_template overview.fields (genre, tone, coreConflict, …).",
|
|
786
|
+
world: "name, kind, parentId or parentName (resolved to parentId), geography/culture/history/factions at top level.",
|
|
787
|
+
role: "name plus get_template role.detailFields keys at top level; faction must match an existing world name.",
|
|
788
|
+
roleRelation: "one directed edge: roleId/targetRoleId or from/to names, relationType, content. UI shows the edge on both people; do not duplicate a reverse row.",
|
|
789
|
+
level: "system.name and system.levels[] (name, promotionCondition, abilities, subLevels). Entity name = system.name.",
|
|
790
|
+
item: "name plus get_template item.detailFields keys at top level.",
|
|
791
|
+
creature: "name plus get_template creature.detailFields keys at top level.",
|
|
792
|
+
timeline: "title, content, timeLabel, timePoint, eventType; optional isResolved / relatedChapterNo.",
|
|
793
|
+
outline: "identity: ordinal + name (UI shows 第N卷/章 + name). Volume = no parent; chapter = parentId or parentName. Optional theme/conflict/majorEvents/content at top level (or extra, which is hoisted). Do not put 第N卷/章 into name. No volumeNo/chapterNo.",
|
|
794
|
+
chapter: "identity: ordinal + name (UI shows 第N章 + name; chapterNo is an alias for ordinal). Optional content (body) and relatedOutlineId or relatedOutlineName."
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
async function listWorkspaceWritingStyles(workspacePath) {
|
|
799
|
+
const db = await openDb(workspacePath);
|
|
800
|
+
await seedWritingStyleBuiltins(db);
|
|
801
|
+
return listWritingStyles(db);
|
|
802
|
+
}
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/novel/http.ts
|
|
805
|
+
function finish(res, status, body) {
|
|
806
|
+
res.statusCode = status;
|
|
807
|
+
if (body === void 0) {
|
|
808
|
+
res.end();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
812
|
+
res.end(JSON.stringify(body));
|
|
813
|
+
}
|
|
814
|
+
async function readJson(req) {
|
|
815
|
+
const chunks = [];
|
|
816
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
817
|
+
if (chunks.length === 0) return {};
|
|
818
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
819
|
+
}
|
|
820
|
+
function pathOf(req) {
|
|
821
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
822
|
+
return {
|
|
823
|
+
pathname: url.pathname,
|
|
824
|
+
search: url.searchParams
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function requirePath(value) {
|
|
828
|
+
if (typeof value !== "string" || value.length === 0) throw new Error("path is required");
|
|
829
|
+
return value;
|
|
830
|
+
}
|
|
831
|
+
/** Same-origin JSON API for the novel client and tools. */
|
|
832
|
+
async function handleNovelApi(req, res, expectedOrigin) {
|
|
833
|
+
if (req.headers.origin !== void 0 && req.headers.origin !== expectedOrigin) {
|
|
834
|
+
finish(res, 403);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
const { pathname, search } = pathOf(req);
|
|
839
|
+
const method = req.method ?? "GET";
|
|
840
|
+
const rel = pathname.slice(14) || "/";
|
|
841
|
+
if (method === "GET" && rel === "/status") {
|
|
842
|
+
finish(res, 200, { novel: await isNovelWorkspace(requirePath(search.get("path"))) });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (method === "POST" && rel === "/provision") {
|
|
846
|
+
const body = await readJson(req);
|
|
847
|
+
finish(res, 200, await provisionNovelWorkspace(requirePath(body.path), {
|
|
848
|
+
title: typeof body.title === "string" ? body.title : void 0,
|
|
849
|
+
category: typeof body.category === "string" ? body.category : void 0,
|
|
850
|
+
writingStyleId: typeof body.writingStyleId === "string" ? body.writingStyleId : void 0
|
|
851
|
+
}));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (method === "GET" && rel === "/schema") {
|
|
855
|
+
finish(res, 200, describeSchema());
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (method === "GET" && rel === "/novel") {
|
|
859
|
+
finish(res, 200, await getNovel(requirePath(search.get("path"))));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (method === "PATCH" && rel === "/novel") {
|
|
863
|
+
const body = await readJson(req);
|
|
864
|
+
finish(res, 200, await updateNovel(requirePath(body.path), {
|
|
865
|
+
...typeof body.title === "string" ? { title: body.title } : {},
|
|
866
|
+
...typeof body.description === "string" ? { description: body.description } : {},
|
|
867
|
+
...typeof body.cover === "string" ? { cover: body.cover } : {},
|
|
868
|
+
...typeof body.category === "string" ? { category: body.category } : {},
|
|
869
|
+
...body.writing_style_id === null || typeof body.writing_style_id === "string" ? { writing_style_id: body.writing_style_id } : {},
|
|
870
|
+
...body.blueprint_json !== void 0 ? { blueprint_json: body.blueprint_json } : {},
|
|
871
|
+
...typeof body.outline_draft_md === "string" ? { outline_draft_md: body.outline_draft_md } : {},
|
|
872
|
+
...typeof body.outline_final_md === "string" ? { outline_final_md: body.outline_final_md } : {},
|
|
873
|
+
...typeof body.outline_status === "string" ? { outline_status: body.outline_status } : {}
|
|
874
|
+
}));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (method === "GET" && rel === "/entities") {
|
|
878
|
+
const workspacePath = requirePath(search.get("path"));
|
|
879
|
+
const domain = search.get("domain") ?? "";
|
|
880
|
+
const id = search.get("id");
|
|
881
|
+
if (id !== null) {
|
|
882
|
+
finish(res, 200, await getEntity(workspacePath, id) ?? null);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (!isEntityDomain(domain)) {
|
|
886
|
+
finish(res, 400, { error: `unknown domain; expected one of ${ENTITY_DOMAINS.join(", ")}` });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
finish(res, 200, await listEntities(workspacePath, domain));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if ((method === "POST" || method === "PATCH") && rel === "/entities") {
|
|
893
|
+
const body = await readJson(req);
|
|
894
|
+
const workspacePath = requirePath(body.path);
|
|
895
|
+
const domain = String(body.domain ?? "");
|
|
896
|
+
if (!isEntityDomain(domain)) throw new Error("unknown domain");
|
|
897
|
+
finish(res, 200, await upsertEntity(workspacePath, domain, body.data ?? {}, typeof body.id === "string" ? body.id : void 0));
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (method === "DELETE" && rel === "/entities") {
|
|
901
|
+
const body = await readJson(req);
|
|
902
|
+
finish(res, 200, { deleted: await deleteEntity(requirePath(body.path), String(body.id ?? "")) });
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (method === "GET" && rel === "/template") {
|
|
906
|
+
finish(res, 200, await getTemplate(requirePath(search.get("path"))));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (method === "PUT" && rel === "/template") {
|
|
910
|
+
const body = await readJson(req);
|
|
911
|
+
finish(res, 200, await saveTemplate(requirePath(body.path), body.config));
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (method === "GET" && rel === "/style-presets") {
|
|
915
|
+
finish(res, 200, listBuiltinWritingStylePresets());
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (method === "GET" && rel === "/styles") {
|
|
919
|
+
finish(res, 200, await listWorkspaceWritingStyles(requirePath(search.get("path"))));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (method === "GET" && rel === "/skills") {
|
|
923
|
+
finish(res, 200, await listSkills(requirePath(search.get("path"))));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if ((method === "POST" || method === "PATCH") && rel === "/skills") {
|
|
927
|
+
const body = await readJson(req);
|
|
928
|
+
finish(res, 200, await upsertSkill(requirePath(body.path), {
|
|
929
|
+
id: typeof body.id === "string" ? body.id : void 0,
|
|
930
|
+
section: String(body.section ?? "overview"),
|
|
931
|
+
folder: typeof body.folder === "string" ? body.folder : void 0,
|
|
932
|
+
agentKind: typeof body.agentKind === "string" ? body.agentKind : void 0,
|
|
933
|
+
title: String(body.title ?? ""),
|
|
934
|
+
body: String(body.body ?? "")
|
|
935
|
+
}));
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (method === "DELETE" && rel === "/skills") {
|
|
939
|
+
const body = await readJson(req);
|
|
940
|
+
finish(res, 200, { deleted: await deleteSkill(requirePath(body.path), String(body.id ?? "")) });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
finish(res, 404, { error: "not found" });
|
|
944
|
+
} catch (cause) {
|
|
945
|
+
finish(res, 400, { error: cause instanceof Error ? cause.message : String(cause) });
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/novel/host-skills.ts
|
|
950
|
+
const NOVEL_CREATE_SKILL_NAME = "novel-create";
|
|
951
|
+
const MODULE_SKILL_SPECS = [
|
|
952
|
+
{
|
|
953
|
+
name: "novel-overview",
|
|
954
|
+
root: "概述",
|
|
955
|
+
description: "概述与开书:标题、卖点、种子。"
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: "novel-world",
|
|
959
|
+
root: "世界",
|
|
960
|
+
description: "世界规则、势力与伏笔。"
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
name: "novel-role",
|
|
964
|
+
root: "人物",
|
|
965
|
+
description: "人物档案、塑造与关系。"
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
name: "novel-creature",
|
|
969
|
+
root: "生物",
|
|
970
|
+
description: "生物与灵植档案。"
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
name: "novel-item",
|
|
974
|
+
root: "物品",
|
|
975
|
+
description: "物品档案与归属。"
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
name: "novel-level",
|
|
979
|
+
root: "等级",
|
|
980
|
+
description: "等级、能力与代价。"
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
name: "novel-timeline",
|
|
984
|
+
root: "时间线",
|
|
985
|
+
description: "事件轴与状态同步。"
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
name: "novel-outline",
|
|
989
|
+
root: "大纲",
|
|
990
|
+
description: "大纲结构与剧情骨架。"
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
name: "novel-chapter",
|
|
994
|
+
root: "章节",
|
|
995
|
+
description: "章节节奏、对话与质检。"
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
name: "novel-review",
|
|
999
|
+
root: "审查",
|
|
1000
|
+
description: "大纲与章节审查。"
|
|
1001
|
+
}
|
|
1002
|
+
];
|
|
1003
|
+
const PROVIDER = "dsh-novel";
|
|
1004
|
+
const NOVEL_CREATE_CONTENT = [
|
|
1005
|
+
"# 创建小说工作区",
|
|
1006
|
+
"",
|
|
1007
|
+
"对当前会话工作区目录调用工具 `novel_create`(可选 `title`、`category`)。",
|
|
1008
|
+
"该工具会写入 `.dsh-novel`、建 PGlite、播种模板与技能。",
|
|
1009
|
+
"若已经是小说工作区,工具返回 `alreadyInitialized: true`:告知用户已忽略,**不要**改书名或题材。",
|
|
1010
|
+
"",
|
|
1011
|
+
"然后只做开书设定(不要写大纲或章节):",
|
|
1012
|
+
"1. 先 `get_template`,按返回的字段 **顶层 key** 写入(不要包在 `profile` 里)。",
|
|
1013
|
+
"2. 加载 `novel-overview`,以及按用户素材选用 `novel-world` / `novel-role` / `novel-level` / `novel-item` / `novel-timeline`。",
|
|
1014
|
+
"3. 写入概述 blueprint、世界树(先父后子,子节点用 `parentName`)、人物顶层字段、`roleRelation`、等级 `system`。",
|
|
1015
|
+
"4. 按素材写入全书 `timeline` 关键事件:`title` + `content` + `timeLabel` + `timePoint` + `eventType`。伏笔/冲突/悬念必须带 `eventType`(`foreshadow` / `conflict` / `suspense`)和 `isResolved`(开书多为 `false`)。",
|
|
1016
|
+
"5. **禁止**加载 `novel-outline` / `novel-chapter`,禁止 mutate `outline` 或 `chapter`。",
|
|
1017
|
+
"6. 设定写完即停,告诉用户:大纲用 `/novel-outline`,章节用 `/novel-chapter`。"
|
|
1018
|
+
].join("\n");
|
|
1019
|
+
function moduleSkillInvocation(name) {
|
|
1020
|
+
return {
|
|
1021
|
+
modelInvocable: true,
|
|
1022
|
+
userInvocable: name === "novel-outline" || name === "novel-chapter"
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/** Filter DB skill rows that belong to one DSH module skill. */
|
|
1026
|
+
function rowsForModule(rows, name) {
|
|
1027
|
+
return rows.filter((row) => folderMatchesModule(row.folder, name));
|
|
1028
|
+
}
|
|
1029
|
+
/** Module skills that currently have at least one row. */
|
|
1030
|
+
function moduleSkillsFromRows(rows) {
|
|
1031
|
+
return MODULE_SKILL_SPECS.filter((spec) => rowsForModule(rows, spec.name).length > 0).map((spec) => spec.name);
|
|
1032
|
+
}
|
|
1033
|
+
function composeModuleSkillBody(rows, name) {
|
|
1034
|
+
return rowsForModule(rows, name).map((row) => `## ${row.title}\n\n${row.body}`).join("\n\n");
|
|
1035
|
+
}
|
|
1036
|
+
function folderMatchesModule(folder, name) {
|
|
1037
|
+
if (name === "novel-review") return isReviewFolder(folder);
|
|
1038
|
+
const spec = MODULE_SKILL_SPECS.find((item) => item.name === name);
|
|
1039
|
+
if (spec === void 0) return false;
|
|
1040
|
+
if (isReviewFolder(folder)) return false;
|
|
1041
|
+
return folder === spec.root || folder.startsWith(`${spec.root}/`);
|
|
1042
|
+
}
|
|
1043
|
+
function isReviewFolder(folder) {
|
|
1044
|
+
return folder === "审查" || folder.startsWith("审查/") || /\/审查(?:\/|$)/u.test(folder);
|
|
1045
|
+
}
|
|
1046
|
+
/** Register /novel-create and cwd-scoped module skills from the novel database. */
|
|
1047
|
+
function registerNovelSkills(ctx) {
|
|
1048
|
+
ctx.effect(() => ctx.skills.register({
|
|
1049
|
+
name: NOVEL_CREATE_SKILL_NAME,
|
|
1050
|
+
description: "把当前工作区初始化为小说工作区(已初始化则忽略)。",
|
|
1051
|
+
whenToUse: "用户要开新书、初始化小说目录,或输入 /novel-create。",
|
|
1052
|
+
source: "bundled",
|
|
1053
|
+
content: NOVEL_CREATE_CONTENT,
|
|
1054
|
+
invocation: {
|
|
1055
|
+
modelInvocable: true,
|
|
1056
|
+
userInvocable: true
|
|
1057
|
+
}
|
|
1058
|
+
}), "dsh-plugin-novel: novel-create skill");
|
|
1059
|
+
ctx.effect(() => ctx.skills.registerProvider(() => ({
|
|
1060
|
+
name: PROVIDER,
|
|
1061
|
+
async list(options) {
|
|
1062
|
+
return moduleSkillsFromRows(await loadRows(options.cwd)).map((name) => candidateOf(name));
|
|
1063
|
+
},
|
|
1064
|
+
async get(candidate, options) {
|
|
1065
|
+
const name = asModuleName(candidate.locator);
|
|
1066
|
+
if (name === void 0) return void 0;
|
|
1067
|
+
const content = composeModuleSkillBody(await loadRows(options.cwd), name);
|
|
1068
|
+
if (content.length === 0) return void 0;
|
|
1069
|
+
return definitionOf(name, content);
|
|
1070
|
+
}
|
|
1071
|
+
})), "dsh-plugin-novel: module skill provider");
|
|
1072
|
+
}
|
|
1073
|
+
async function loadRows(cwd) {
|
|
1074
|
+
if (typeof cwd === "string" && cwd.length > 0 && await isNovelWorkspace(cwd)) return listSkills(cwd);
|
|
1075
|
+
return listSeedSkillRows().map((row) => ({
|
|
1076
|
+
id: `seed:${row.folder}/${row.title}`,
|
|
1077
|
+
section: row.section,
|
|
1078
|
+
folder: row.folder,
|
|
1079
|
+
agent_kind: row.agent_kind,
|
|
1080
|
+
title: row.title,
|
|
1081
|
+
body: row.body,
|
|
1082
|
+
created_at: "",
|
|
1083
|
+
updated_at: ""
|
|
1084
|
+
}));
|
|
1085
|
+
}
|
|
1086
|
+
function asModuleName(locator) {
|
|
1087
|
+
if (typeof locator !== "string") return void 0;
|
|
1088
|
+
return MODULE_SKILL_SPECS.some((spec) => spec.name === locator) ? locator : void 0;
|
|
1089
|
+
}
|
|
1090
|
+
function candidateOf(name) {
|
|
1091
|
+
const spec = MODULE_SKILL_SPECS.find((item) => item.name === name);
|
|
1092
|
+
return {
|
|
1093
|
+
name: spec.name,
|
|
1094
|
+
description: spec.description,
|
|
1095
|
+
invocation: moduleSkillInvocation(name),
|
|
1096
|
+
source: "bundled",
|
|
1097
|
+
provider: PROVIDER,
|
|
1098
|
+
rank: BUNDLED_SKILL_RANK,
|
|
1099
|
+
locator: spec.name
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function definitionOf(name, content) {
|
|
1103
|
+
const spec = MODULE_SKILL_SPECS.find((item) => item.name === name);
|
|
1104
|
+
return {
|
|
1105
|
+
name: spec.name,
|
|
1106
|
+
description: spec.description,
|
|
1107
|
+
invocation: moduleSkillInvocation(name),
|
|
1108
|
+
source: "bundled",
|
|
1109
|
+
provider: PROVIDER,
|
|
1110
|
+
content
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
//#endregion
|
|
1114
|
+
//#region src/novel/tools.ts
|
|
1115
|
+
function jsonText(value) {
|
|
1116
|
+
return JSON.stringify(value, null, 2);
|
|
1117
|
+
}
|
|
1118
|
+
function asJson(value) {
|
|
1119
|
+
return JSON.parse(JSON.stringify(value));
|
|
1120
|
+
}
|
|
1121
|
+
function workspaceFrom(args, exec) {
|
|
1122
|
+
const path = args.workspace_path ?? exec.agent?.cwd ?? exec.agent?.session?.header?.cwd;
|
|
1123
|
+
if (typeof path !== "string" || path.length === 0) throw new Error("workspace_path is required (or the session must have a cwd)");
|
|
1124
|
+
return path;
|
|
1125
|
+
}
|
|
1126
|
+
function jsonOutput() {
|
|
1127
|
+
return {
|
|
1128
|
+
schema: { type: "json" },
|
|
1129
|
+
render: (_args, value) => [{
|
|
1130
|
+
type: "text",
|
|
1131
|
+
text: jsonText(value)
|
|
1132
|
+
}]
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
/** Register novel CRUD tools, DSH skills, and a system-prompt context dump. */
|
|
1136
|
+
function registerNovelHost(ctx) {
|
|
1137
|
+
registerNovelSkills(ctx);
|
|
1138
|
+
ctx.tools.register(defineTool({
|
|
1139
|
+
name: "novel_status",
|
|
1140
|
+
description: "Check whether the current workspace is a novel workspace (.dsh-novel).",
|
|
1141
|
+
parameters: { workspace_path: {
|
|
1142
|
+
type: "string",
|
|
1143
|
+
description: "Workspace directory; defaults to session cwd"
|
|
1144
|
+
} },
|
|
1145
|
+
output: jsonOutput(),
|
|
1146
|
+
async execute(args, exec) {
|
|
1147
|
+
const path = workspaceFrom(args, exec);
|
|
1148
|
+
return asJson({
|
|
1149
|
+
path,
|
|
1150
|
+
novel: await isNovelWorkspace(path)
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
}));
|
|
1154
|
+
ctx.tools.register(defineTool({
|
|
1155
|
+
name: "novel_create",
|
|
1156
|
+
description: "Initialize the current directory as a novel workspace. If it already is one, returns alreadyInitialized and does not change title or category.",
|
|
1157
|
+
parameters: {
|
|
1158
|
+
workspace_path: {
|
|
1159
|
+
type: "string",
|
|
1160
|
+
description: "Workspace directory; defaults to session cwd"
|
|
1161
|
+
},
|
|
1162
|
+
title: {
|
|
1163
|
+
type: "string",
|
|
1164
|
+
description: "Novel title"
|
|
1165
|
+
},
|
|
1166
|
+
category: {
|
|
1167
|
+
type: "string",
|
|
1168
|
+
description: "Genre such as 玄幻"
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
output: jsonOutput(),
|
|
1172
|
+
async execute(args, exec) {
|
|
1173
|
+
const path = workspaceFrom(args, exec);
|
|
1174
|
+
const result = await provisionNovelWorkspace(path, {
|
|
1175
|
+
title: args.title,
|
|
1176
|
+
category: args.category
|
|
1177
|
+
});
|
|
1178
|
+
if (!result.alreadyInitialized) {
|
|
1179
|
+
const registry = ctx.get("workspaceRegistry");
|
|
1180
|
+
try {
|
|
1181
|
+
await registry?.create?.(path, result.novel.title);
|
|
1182
|
+
} catch {}
|
|
1183
|
+
}
|
|
1184
|
+
return asJson(result);
|
|
1185
|
+
}
|
|
1186
|
+
}));
|
|
1187
|
+
ctx.tools.register(defineTool({
|
|
1188
|
+
name: "get_novel",
|
|
1189
|
+
description: "Read the current novel metadata and blueprint (overview).",
|
|
1190
|
+
parameters: { workspace_path: { type: "string" } },
|
|
1191
|
+
output: jsonOutput(),
|
|
1192
|
+
async execute(args, exec) {
|
|
1193
|
+
return asJson(await getNovel(workspaceFrom(args, exec)));
|
|
1194
|
+
}
|
|
1195
|
+
}));
|
|
1196
|
+
ctx.tools.register(defineTool({
|
|
1197
|
+
name: "update_novel",
|
|
1198
|
+
description: "Update novel title, category, description, or blueprint_json overview.",
|
|
1199
|
+
parameters: {
|
|
1200
|
+
workspace_path: { type: "string" },
|
|
1201
|
+
title: { type: "string" },
|
|
1202
|
+
category: { type: "string" },
|
|
1203
|
+
description: { type: "string" },
|
|
1204
|
+
blueprint_json: {
|
|
1205
|
+
type: "string",
|
|
1206
|
+
description: "JSON object string for the overview blueprint"
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
output: jsonOutput(),
|
|
1210
|
+
async execute(args, exec) {
|
|
1211
|
+
const path = workspaceFrom(args, exec);
|
|
1212
|
+
const patch = {};
|
|
1213
|
+
if (args.title !== void 0) patch.title = args.title;
|
|
1214
|
+
if (args.category !== void 0) patch.category = args.category;
|
|
1215
|
+
if (args.description !== void 0) patch.description = args.description;
|
|
1216
|
+
if (args.blueprint_json !== void 0) patch.blueprint_json = JSON.parse(args.blueprint_json);
|
|
1217
|
+
return asJson(await updateNovel(path, patch));
|
|
1218
|
+
}
|
|
1219
|
+
}));
|
|
1220
|
+
ctx.tools.register(defineTool({
|
|
1221
|
+
name: "describe_schema",
|
|
1222
|
+
description: "Describe novel entity domains available in this workspace.",
|
|
1223
|
+
parameters: {},
|
|
1224
|
+
output: jsonOutput(),
|
|
1225
|
+
async execute() {
|
|
1226
|
+
return asJson(describeSchema());
|
|
1227
|
+
}
|
|
1228
|
+
}));
|
|
1229
|
+
ctx.tools.register(defineTool({
|
|
1230
|
+
name: "query_entities",
|
|
1231
|
+
description: `List entities in a domain (${ENTITY_DOMAINS.join(", ")}).`,
|
|
1232
|
+
parameters: {
|
|
1233
|
+
workspace_path: { type: "string" },
|
|
1234
|
+
domain: {
|
|
1235
|
+
type: "string",
|
|
1236
|
+
required: true
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
output: jsonOutput(),
|
|
1240
|
+
async execute(args, exec) {
|
|
1241
|
+
const domain = args.domain;
|
|
1242
|
+
if (!isEntityDomain(domain)) throw new Error(`unknown domain ${domain}`);
|
|
1243
|
+
return asJson({ items: await listEntities(workspaceFrom(args, exec), domain) });
|
|
1244
|
+
}
|
|
1245
|
+
}));
|
|
1246
|
+
ctx.tools.register(defineTool({
|
|
1247
|
+
name: "get_entities",
|
|
1248
|
+
description: "Load one entity by id.",
|
|
1249
|
+
parameters: {
|
|
1250
|
+
workspace_path: { type: "string" },
|
|
1251
|
+
id: {
|
|
1252
|
+
type: "string",
|
|
1253
|
+
required: true
|
|
1254
|
+
}
|
|
1255
|
+
},
|
|
1256
|
+
output: jsonOutput(),
|
|
1257
|
+
async execute(args, exec) {
|
|
1258
|
+
return asJson(await getEntity(workspaceFrom(args, exec), args.id) ?? { missing: true });
|
|
1259
|
+
}
|
|
1260
|
+
}));
|
|
1261
|
+
ctx.tools.register(defineTool({
|
|
1262
|
+
name: "mutate_entities",
|
|
1263
|
+
description: "Create or update an entity. Pass domain and a JSON object string as data.",
|
|
1264
|
+
parameters: {
|
|
1265
|
+
workspace_path: { type: "string" },
|
|
1266
|
+
domain: {
|
|
1267
|
+
type: "string",
|
|
1268
|
+
required: true
|
|
1269
|
+
},
|
|
1270
|
+
data: {
|
|
1271
|
+
type: "string",
|
|
1272
|
+
required: true,
|
|
1273
|
+
description: "JSON object, typically including name"
|
|
1274
|
+
},
|
|
1275
|
+
id: { type: "string" }
|
|
1276
|
+
},
|
|
1277
|
+
output: jsonOutput(),
|
|
1278
|
+
async execute(args, exec) {
|
|
1279
|
+
if (!isEntityDomain(args.domain)) throw new Error(`unknown domain ${args.domain}`);
|
|
1280
|
+
const data = JSON.parse(args.data);
|
|
1281
|
+
return asJson(await upsertEntity(workspaceFrom(args, exec), args.domain, data, args.id));
|
|
1282
|
+
}
|
|
1283
|
+
}));
|
|
1284
|
+
ctx.tools.register(defineTool({
|
|
1285
|
+
name: "delete_entity",
|
|
1286
|
+
description: "Delete one entity by id.",
|
|
1287
|
+
parameters: {
|
|
1288
|
+
workspace_path: { type: "string" },
|
|
1289
|
+
id: {
|
|
1290
|
+
type: "string",
|
|
1291
|
+
required: true
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
output: jsonOutput(),
|
|
1295
|
+
async execute(args, exec) {
|
|
1296
|
+
return asJson({ deleted: await deleteEntity(workspaceFrom(args, exec), args.id) });
|
|
1297
|
+
}
|
|
1298
|
+
}));
|
|
1299
|
+
ctx.tools.register(defineTool({
|
|
1300
|
+
name: "skill_list",
|
|
1301
|
+
description: "List writing/review skills for the current novel (per-file rows in the database).",
|
|
1302
|
+
parameters: { workspace_path: { type: "string" } },
|
|
1303
|
+
output: jsonOutput(),
|
|
1304
|
+
async execute(args, exec) {
|
|
1305
|
+
return asJson({ items: await listSkills(workspaceFrom(args, exec)) });
|
|
1306
|
+
}
|
|
1307
|
+
}));
|
|
1308
|
+
ctx.tools.register(defineTool({
|
|
1309
|
+
name: "upsert_skill",
|
|
1310
|
+
description: "Create or update one skill row in this novel. Changes the book skill table; do not overwrite the whole catalog without asking.",
|
|
1311
|
+
parameters: {
|
|
1312
|
+
workspace_path: { type: "string" },
|
|
1313
|
+
id: { type: "string" },
|
|
1314
|
+
section: {
|
|
1315
|
+
type: "string",
|
|
1316
|
+
required: true
|
|
1317
|
+
},
|
|
1318
|
+
folder: {
|
|
1319
|
+
type: "string",
|
|
1320
|
+
description: "Chinese folder path such as 概述"
|
|
1321
|
+
},
|
|
1322
|
+
agentKind: { type: "string" },
|
|
1323
|
+
title: {
|
|
1324
|
+
type: "string",
|
|
1325
|
+
required: true
|
|
1326
|
+
},
|
|
1327
|
+
body: {
|
|
1328
|
+
type: "string",
|
|
1329
|
+
required: true
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
output: jsonOutput(),
|
|
1333
|
+
async execute(args, exec) {
|
|
1334
|
+
return asJson(await upsertSkill(workspaceFrom(args, exec), {
|
|
1335
|
+
id: args.id,
|
|
1336
|
+
section: args.section,
|
|
1337
|
+
folder: args.folder,
|
|
1338
|
+
agentKind: args.agentKind,
|
|
1339
|
+
title: args.title,
|
|
1340
|
+
body: args.body
|
|
1341
|
+
}));
|
|
1342
|
+
}
|
|
1343
|
+
}));
|
|
1344
|
+
ctx.tools.register(defineTool({
|
|
1345
|
+
name: "delete_skill",
|
|
1346
|
+
description: "Delete one skill row by id. Destructive; confirm with the user first.",
|
|
1347
|
+
parameters: {
|
|
1348
|
+
workspace_path: { type: "string" },
|
|
1349
|
+
id: {
|
|
1350
|
+
type: "string",
|
|
1351
|
+
required: true
|
|
1352
|
+
}
|
|
1353
|
+
},
|
|
1354
|
+
output: jsonOutput(),
|
|
1355
|
+
async execute(args, exec) {
|
|
1356
|
+
return asJson({ deleted: await deleteSkill(workspaceFrom(args, exec), args.id) });
|
|
1357
|
+
}
|
|
1358
|
+
}));
|
|
1359
|
+
ctx.tools.register(defineTool({
|
|
1360
|
+
name: "get_template",
|
|
1361
|
+
description: "Read the form template JSON used to render field labels for this novel.",
|
|
1362
|
+
parameters: { workspace_path: { type: "string" } },
|
|
1363
|
+
output: jsonOutput(),
|
|
1364
|
+
async execute(args, exec) {
|
|
1365
|
+
return asJson(await getTemplate(workspaceFrom(args, exec)));
|
|
1366
|
+
}
|
|
1367
|
+
}));
|
|
1368
|
+
ctx.tools.register(defineTool({
|
|
1369
|
+
name: "save_template",
|
|
1370
|
+
description: "Replace the entire form template JSON for this novel. Destructive; do not overwrite the whole template without asking.",
|
|
1371
|
+
parameters: {
|
|
1372
|
+
workspace_path: { type: "string" },
|
|
1373
|
+
config: {
|
|
1374
|
+
type: "string",
|
|
1375
|
+
required: true,
|
|
1376
|
+
description: "JSON object string for form_templates.config"
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
output: jsonOutput(),
|
|
1380
|
+
async execute(args, exec) {
|
|
1381
|
+
return asJson(await saveTemplate(workspaceFrom(args, exec), JSON.parse(args.config)));
|
|
1382
|
+
}
|
|
1383
|
+
}));
|
|
1384
|
+
ctx.tools.register(defineTool({
|
|
1385
|
+
name: "list_writing_styles",
|
|
1386
|
+
description: "List writing styles available in this novel workspace.",
|
|
1387
|
+
parameters: { workspace_path: { type: "string" } },
|
|
1388
|
+
output: jsonOutput(),
|
|
1389
|
+
async execute(args, exec) {
|
|
1390
|
+
return asJson({ items: await listWorkspaceWritingStyles(workspaceFrom(args, exec)) });
|
|
1391
|
+
}
|
|
1392
|
+
}));
|
|
1393
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
1394
|
+
name: "novel-authoring",
|
|
1395
|
+
order: 80,
|
|
1396
|
+
text: [
|
|
1397
|
+
"You are helping write a novel bound to the current DSH workspace.",
|
|
1398
|
+
"One workspace directory is one novel, stored in `.dsh-novel/pglite`.",
|
|
1399
|
+
"To create a novel workspace and write setting (overview/world/roles/levels), use /novel-create. Do not write outline or chapters in that command.",
|
|
1400
|
+
"To write the outline, the user must invoke /novel-outline. To write chapters, /novel-chapter.",
|
|
1401
|
+
"Before writing a section, load the matching skill novel-overview, novel-world, novel-role, novel-creature, novel-item, novel-level, novel-timeline, novel-outline, novel-chapter, or novel-review; or call skill_list for a single file.",
|
|
1402
|
+
"Call get_template and write template keys at the top level of mutate_entities data (not nested under profile). World children use parentName; relations use from/to names or roleId/targetRoleId.",
|
|
1403
|
+
"Use get_novel, query_entities, mutate_entities, and update_novel to read and write canon.",
|
|
1404
|
+
"Prefer existing names; do not invent duplicate characters."
|
|
1405
|
+
].join("\n")
|
|
1406
|
+
}), "dsh-plugin-novel: system prompt section");
|
|
1407
|
+
ctx.effect(() => ctx.systemPrompt.context({
|
|
1408
|
+
name: "novel-snapshot",
|
|
1409
|
+
order: 80,
|
|
1410
|
+
text: () => "Novel data is in the workspace `.dsh-novel` database. Call get_novel and query_entities before writing."
|
|
1411
|
+
}), "dsh-plugin-novel: system prompt context");
|
|
1412
|
+
}
|
|
1413
|
+
//#endregion
|
|
1414
|
+
//#region src/index.ts
|
|
1415
|
+
const name = "novel";
|
|
1416
|
+
const inject = [
|
|
1417
|
+
"webServer",
|
|
1418
|
+
"tools",
|
|
1419
|
+
"systemPrompt",
|
|
1420
|
+
"skills"
|
|
1421
|
+
];
|
|
1422
|
+
function apply(ctx) {
|
|
1423
|
+
const rendererOrigin = `http://${ctx.webServer.host}:${String(ctx.webServer.port)}`;
|
|
1424
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1425
|
+
kind: "prefix",
|
|
1426
|
+
path: NOVEL_API_PREFIX,
|
|
1427
|
+
handler: (req, res) => {
|
|
1428
|
+
handleNovelApi(req, res, rendererOrigin);
|
|
1429
|
+
}
|
|
1430
|
+
}), "dsh-novel: JSON API");
|
|
1431
|
+
registerNovelHost(ctx);
|
|
1432
|
+
}
|
|
1433
|
+
//#endregion
|
|
1434
|
+
export { apply, inject, name };
|
|
1435
|
+
|
|
1436
|
+
//# sourceMappingURL=index.js.map
|