@musnows/scriverse 0.2.0 → 0.3.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.
Files changed (70) hide show
  1. package/README.en.md +13 -4
  2. package/README.md +13 -4
  3. package/dist/ai.js +3374 -0
  4. package/dist/ai.js.map +1 -0
  5. package/dist/app.js +1046 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli-core.js +147 -20
  8. package/dist/cli-core.js.map +1 -1
  9. package/dist/credential-vault.js +40 -0
  10. package/dist/credential-vault.js.map +1 -0
  11. package/dist/database.js +1122 -0
  12. package/dist/database.js.map +1 -0
  13. package/dist/docx-security.js +184 -0
  14. package/dist/docx-security.js.map +1 -0
  15. package/dist/domain.js +21 -0
  16. package/dist/domain.js.map +1 -0
  17. package/dist/errors.js +16 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/image-captcha.js +173 -0
  20. package/dist/image-captcha.js.map +1 -0
  21. package/dist/image-metadata.js +128 -0
  22. package/dist/image-metadata.js.map +1 -0
  23. package/dist/import-security.js +46 -0
  24. package/dist/import-security.js.map +1 -0
  25. package/dist/parser.js +223 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/public/ai-context-meter.js +10 -0
  28. package/dist/public/ai-conversation.js +3 -0
  29. package/dist/public/ai-mentions.js +55 -0
  30. package/dist/public/ai-message-actions.js +27 -0
  31. package/dist/public/ai-message-meta.js +15 -0
  32. package/dist/public/ai-message-time.js +23 -0
  33. package/dist/public/ai-prompt-keyboard.js +3 -0
  34. package/dist/public/app.js +4255 -0
  35. package/dist/public/character-profile.d.ts +9 -0
  36. package/dist/public/character-profile.js +65 -0
  37. package/dist/public/character-version.d.ts +2 -0
  38. package/dist/public/character-version.js +31 -0
  39. package/dist/public/entity-version.js +34 -0
  40. package/dist/public/icon.svg +10 -0
  41. package/dist/public/index.html +547 -0
  42. package/dist/public/line-number-layout.js +15 -0
  43. package/dist/public/markdown.js +199 -0
  44. package/dist/public/model-config.d.ts +17 -0
  45. package/dist/public/model-config.js +57 -0
  46. package/dist/public/page-route.d.ts +11 -0
  47. package/dist/public/page-route.js +81 -0
  48. package/dist/public/relationship-graph.js +2017 -0
  49. package/dist/public/site.webmanifest +17 -0
  50. package/dist/public/styles.css +1399 -0
  51. package/dist/public/text-formatting.d.ts +2 -0
  52. package/dist/public/text-formatting.js +20 -0
  53. package/dist/public/theme-init.js +8 -0
  54. package/dist/public/theme.js +13 -0
  55. package/dist/public/whitespace-visualization.js +20 -0
  56. package/dist/request-context.js +9 -0
  57. package/dist/request-context.js.map +1 -0
  58. package/dist/security.js +235 -0
  59. package/dist/security.js.map +1 -0
  60. package/dist/server-runtime.js +77 -0
  61. package/dist/server-runtime.js.map +1 -0
  62. package/dist/server.js +15 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/store.js +2492 -0
  65. package/dist/store.js.map +1 -0
  66. package/dist/user-auth.js +489 -0
  67. package/dist/user-auth.js.map +1 -0
  68. package/dist/utils.js +50 -0
  69. package/dist/utils.js.map +1 -0
  70. package/package.json +4 -9
@@ -0,0 +1,1122 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ export const PLATFORM_AI_WORK_ID = "__scriverse_platform_ai__";
5
+ export class Database {
6
+ raw;
7
+ constructor(filename) {
8
+ if (filename !== ":memory:")
9
+ mkdirSync(dirname(filename), { recursive: true });
10
+ this.raw = new DatabaseSync(filename);
11
+ this.raw.exec("PRAGMA foreign_keys = ON");
12
+ this.raw.exec("PRAGMA busy_timeout = 5000");
13
+ if (filename !== ":memory:")
14
+ this.raw.exec("PRAGMA journal_mode = WAL");
15
+ this.migrate();
16
+ this.recoverInterruptedOperations();
17
+ }
18
+ close() {
19
+ this.raw.close();
20
+ }
21
+ run(sql, ...params) {
22
+ const result = this.raw.prepare(sql).run(...params);
23
+ return { changes: Number(result.changes), lastInsertRowid: result.lastInsertRowid };
24
+ }
25
+ get(sql, ...params) {
26
+ return this.raw.prepare(sql).get(...params);
27
+ }
28
+ all(sql, ...params) {
29
+ return this.raw.prepare(sql).all(...params);
30
+ }
31
+ transaction(operation) {
32
+ if (this.raw.isTransaction)
33
+ return operation();
34
+ this.raw.exec("BEGIN IMMEDIATE");
35
+ try {
36
+ const result = operation();
37
+ this.raw.exec("COMMIT");
38
+ return result;
39
+ }
40
+ catch (error) {
41
+ this.raw.exec("ROLLBACK");
42
+ throw error;
43
+ }
44
+ }
45
+ migrate() {
46
+ this.raw.exec(`
47
+ CREATE TABLE IF NOT EXISTS schema_migrations (
48
+ version INTEGER PRIMARY KEY,
49
+ applied_at TEXT NOT NULL
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS works (
53
+ id TEXT PRIMARY KEY,
54
+ title TEXT NOT NULL,
55
+ author TEXT NOT NULL DEFAULT '',
56
+ description TEXT NOT NULL DEFAULT '',
57
+ language TEXT NOT NULL DEFAULT 'zh-CN',
58
+ cover_url TEXT,
59
+ tags_json TEXT NOT NULL DEFAULT '[]',
60
+ is_internal INTEGER NOT NULL DEFAULT 0,
61
+ created_at TEXT NOT NULL,
62
+ updated_at TEXT NOT NULL
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS file_versions (
66
+ id TEXT PRIMARY KEY,
67
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
68
+ file_name TEXT NOT NULL,
69
+ file_type TEXT NOT NULL,
70
+ word_count INTEGER NOT NULL,
71
+ paragraph_count INTEGER NOT NULL,
72
+ warnings_json TEXT NOT NULL DEFAULT '[]',
73
+ snapshot_json TEXT NOT NULL,
74
+ created_at TEXT NOT NULL
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS volumes (
78
+ id TEXT PRIMARY KEY,
79
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
80
+ title TEXT NOT NULL,
81
+ kind TEXT NOT NULL DEFAULT 'main',
82
+ source TEXT NOT NULL DEFAULT 'manual',
83
+ description TEXT NOT NULL DEFAULT '',
84
+ keywords_json TEXT NOT NULL DEFAULT '[]',
85
+ sort_order INTEGER NOT NULL,
86
+ created_at TEXT NOT NULL,
87
+ updated_at TEXT NOT NULL
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS chapters (
91
+ id TEXT PRIMARY KEY,
92
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
93
+ volume_id TEXT NOT NULL REFERENCES volumes(id) ON DELETE CASCADE,
94
+ title TEXT NOT NULL,
95
+ content TEXT NOT NULL DEFAULT '',
96
+ chapter_type TEXT NOT NULL DEFAULT '正文' CHECK(chapter_type IN ('正文', '设定', '作者的话', '其他')),
97
+ sort_order INTEGER NOT NULL,
98
+ word_count INTEGER NOT NULL DEFAULT 0,
99
+ version_no INTEGER NOT NULL DEFAULT 1,
100
+ analysis_status TEXT NOT NULL DEFAULT 'pending',
101
+ excluded_from_analysis INTEGER NOT NULL DEFAULT 0,
102
+ created_at TEXT NOT NULL,
103
+ updated_at TEXT NOT NULL
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS chapter_versions (
107
+ id TEXT PRIMARY KEY,
108
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
109
+ chapter_id TEXT NOT NULL,
110
+ version_no INTEGER NOT NULL,
111
+ title TEXT NOT NULL,
112
+ content TEXT NOT NULL,
113
+ volume_id TEXT,
114
+ sort_order INTEGER,
115
+ chapter_type TEXT,
116
+ source TEXT NOT NULL DEFAULT 'manual',
117
+ source_ref TEXT,
118
+ change_note TEXT NOT NULL DEFAULT '',
119
+ created_at TEXT NOT NULL,
120
+ created_by_user_id TEXT,
121
+ UNIQUE(chapter_id, version_no)
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS chapter_insights (
125
+ id TEXT PRIMARY KEY,
126
+ chapter_id TEXT NOT NULL REFERENCES chapters(id) ON DELETE CASCADE,
127
+ chapter_version INTEGER NOT NULL,
128
+ summary TEXT NOT NULL,
129
+ events_json TEXT NOT NULL DEFAULT '[]',
130
+ characters_json TEXT NOT NULL DEFAULT '[]',
131
+ settings_json TEXT NOT NULL DEFAULT '[]',
132
+ evidence_json TEXT NOT NULL DEFAULT '[]',
133
+ uncertainties_json TEXT NOT NULL DEFAULT '[]',
134
+ status TEXT NOT NULL DEFAULT 'review',
135
+ created_at TEXT NOT NULL
136
+ );
137
+
138
+ CREATE TABLE IF NOT EXISTS settings (
139
+ id TEXT PRIMARY KEY,
140
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
141
+ title TEXT NOT NULL,
142
+ category TEXT NOT NULL,
143
+ content TEXT NOT NULL,
144
+ tags_json TEXT NOT NULL DEFAULT '[]',
145
+ status TEXT NOT NULL DEFAULT 'draft',
146
+ locked INTEGER NOT NULL DEFAULT 0,
147
+ evidence_json TEXT NOT NULL DEFAULT '[]',
148
+ scope_json TEXT NOT NULL DEFAULT '{}',
149
+ author_note TEXT NOT NULL DEFAULT '',
150
+ created_at TEXT NOT NULL,
151
+ updated_at TEXT NOT NULL
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS races (
155
+ id TEXT PRIMARY KEY,
156
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
157
+ name TEXT NOT NULL,
158
+ normalized_name TEXT NOT NULL,
159
+ description TEXT NOT NULL DEFAULT '',
160
+ settings_json TEXT NOT NULL DEFAULT '[]',
161
+ created_at TEXT NOT NULL,
162
+ updated_at TEXT NOT NULL,
163
+ UNIQUE(work_id, normalized_name)
164
+ );
165
+
166
+ CREATE TABLE IF NOT EXISTS characters (
167
+ id TEXT PRIMARY KEY,
168
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
169
+ name TEXT NOT NULL,
170
+ aliases_json TEXT NOT NULL DEFAULT '[]',
171
+ species TEXT NOT NULL DEFAULT '',
172
+ race_id TEXT REFERENCES races(id) ON DELETE SET NULL,
173
+ attributes_json TEXT NOT NULL DEFAULT '{}',
174
+ profile_json TEXT NOT NULL DEFAULT '{}',
175
+ current_state_json TEXT NOT NULL DEFAULT '{}',
176
+ locked_fields_json TEXT NOT NULL DEFAULT '[]',
177
+ visibility TEXT NOT NULL DEFAULT 'author',
178
+ first_chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL,
179
+ version_no INTEGER NOT NULL DEFAULT 1,
180
+ created_at TEXT NOT NULL,
181
+ updated_at TEXT NOT NULL
182
+ );
183
+
184
+ CREATE TABLE IF NOT EXISTS character_versions (
185
+ id TEXT PRIMARY KEY,
186
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
187
+ character_id TEXT NOT NULL,
188
+ version_no INTEGER NOT NULL,
189
+ snapshot_json TEXT NOT NULL,
190
+ source TEXT NOT NULL DEFAULT 'manual',
191
+ source_ref TEXT,
192
+ change_note TEXT NOT NULL DEFAULT '',
193
+ created_at TEXT NOT NULL,
194
+ created_by_user_id TEXT,
195
+ UNIQUE(character_id, version_no)
196
+ );
197
+
198
+ CREATE TABLE IF NOT EXISTS entity_versions (
199
+ id TEXT PRIMARY KEY,
200
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
201
+ entity_type TEXT NOT NULL,
202
+ entity_id TEXT NOT NULL,
203
+ version_no INTEGER NOT NULL,
204
+ snapshot_json TEXT NOT NULL,
205
+ source TEXT NOT NULL DEFAULT 'manual',
206
+ source_ref TEXT,
207
+ change_note TEXT NOT NULL DEFAULT '',
208
+ created_at TEXT NOT NULL,
209
+ UNIQUE(entity_type, entity_id, version_no)
210
+ );
211
+
212
+ CREATE TABLE IF NOT EXISTS timeline_tracks (
213
+ id TEXT PRIMARY KEY,
214
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
215
+ name TEXT NOT NULL,
216
+ description TEXT NOT NULL DEFAULT '',
217
+ sort_order INTEGER NOT NULL DEFAULT 0,
218
+ created_at TEXT NOT NULL,
219
+ updated_at TEXT NOT NULL,
220
+ UNIQUE(work_id, name)
221
+ );
222
+
223
+ CREATE TABLE IF NOT EXISTS timeline_events (
224
+ id TEXT PRIMARY KEY,
225
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
226
+ track_id TEXT REFERENCES timeline_tracks(id) ON DELETE SET NULL,
227
+ name TEXT NOT NULL,
228
+ description TEXT NOT NULL DEFAULT '',
229
+ event_type TEXT NOT NULL DEFAULT 'other',
230
+ time_label TEXT NOT NULL DEFAULT '时间待定',
231
+ time_sort REAL,
232
+ chapter_ids_json TEXT NOT NULL DEFAULT '[]',
233
+ participant_ids_json TEXT NOT NULL DEFAULT '[]',
234
+ location TEXT NOT NULL DEFAULT '',
235
+ causes_json TEXT NOT NULL DEFAULT '[]',
236
+ impact_scope TEXT NOT NULL DEFAULT 'personal',
237
+ evidence_json TEXT NOT NULL DEFAULT '[]',
238
+ status TEXT NOT NULL DEFAULT 'candidate',
239
+ created_at TEXT NOT NULL,
240
+ updated_at TEXT NOT NULL
241
+ );
242
+
243
+ CREATE TABLE IF NOT EXISTS relationships (
244
+ id TEXT PRIMARY KEY,
245
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
246
+ from_character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
247
+ to_character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
248
+ category TEXT NOT NULL,
249
+ subtype TEXT NOT NULL DEFAULT '',
250
+ keywords_json TEXT NOT NULL DEFAULT '[]',
251
+ directed INTEGER NOT NULL DEFAULT 0,
252
+ current_status TEXT NOT NULL DEFAULT 'active',
253
+ time_range_json TEXT NOT NULL DEFAULT '{}',
254
+ confidence REAL NOT NULL DEFAULT 0.5,
255
+ evidence_json TEXT NOT NULL DEFAULT '[]',
256
+ confirmation_status TEXT NOT NULL DEFAULT 'pending',
257
+ locked INTEGER NOT NULL DEFAULT 0,
258
+ created_at TEXT NOT NULL,
259
+ updated_at TEXT NOT NULL,
260
+ CHECK(from_character_id <> to_character_id)
261
+ );
262
+
263
+ CREATE TABLE IF NOT EXISTS review_items (
264
+ id TEXT PRIMARY KEY,
265
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
266
+ item_type TEXT NOT NULL,
267
+ severity TEXT NOT NULL DEFAULT 'medium',
268
+ title TEXT NOT NULL,
269
+ description TEXT NOT NULL DEFAULT '',
270
+ entity_refs_json TEXT NOT NULL DEFAULT '[]',
271
+ evidence_json TEXT NOT NULL DEFAULT '[]',
272
+ suggestion TEXT NOT NULL DEFAULT '',
273
+ status TEXT NOT NULL DEFAULT 'pending',
274
+ resolution_note TEXT NOT NULL DEFAULT '',
275
+ created_at TEXT NOT NULL,
276
+ updated_at TEXT NOT NULL
277
+ );
278
+
279
+ CREATE TABLE IF NOT EXISTS providers (
280
+ id TEXT PRIMARY KEY,
281
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
282
+ name TEXT NOT NULL,
283
+ base_url TEXT NOT NULL,
284
+ encrypted_key TEXT NOT NULL,
285
+ key_iv TEXT NOT NULL,
286
+ key_tag TEXT NOT NULL,
287
+ key_hint TEXT NOT NULL,
288
+ status TEXT NOT NULL DEFAULT 'disabled',
289
+ connection_status TEXT NOT NULL DEFAULT 'unchecked',
290
+ concurrency_limit INTEGER NOT NULL DEFAULT 10 CHECK(concurrency_limit BETWEEN 1 AND 100),
291
+ rpm_limit INTEGER NOT NULL DEFAULT 10 CHECK(rpm_limit BETWEEN 1 AND 10000),
292
+ max_tokens INTEGER NOT NULL DEFAULT 32000 CHECK(max_tokens BETWEEN 1 AND 32768),
293
+ default_model_id TEXT,
294
+ note TEXT NOT NULL DEFAULT '',
295
+ last_error TEXT,
296
+ last_success_at TEXT,
297
+ created_at TEXT NOT NULL,
298
+ updated_at TEXT NOT NULL
299
+ );
300
+
301
+ CREATE TABLE IF NOT EXISTS models (
302
+ id TEXT PRIMARY KEY,
303
+ provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
304
+ display_name TEXT NOT NULL,
305
+ model_id TEXT NOT NULL,
306
+ purposes_json TEXT NOT NULL DEFAULT '[]',
307
+ context_note TEXT NOT NULL DEFAULT '',
308
+ context_window INTEGER NOT NULL DEFAULT 128000 CHECK(context_window BETWEEN 1024 AND 2000000),
309
+ output_note TEXT NOT NULL DEFAULT '',
310
+ preset_json TEXT NOT NULL DEFAULT '{}',
311
+ thinking_enabled INTEGER NOT NULL DEFAULT 1,
312
+ enabled INTEGER NOT NULL DEFAULT 1,
313
+ note TEXT NOT NULL DEFAULT '',
314
+ created_at TEXT NOT NULL,
315
+ updated_at TEXT NOT NULL,
316
+ UNIQUE(provider_id, model_id)
317
+ );
318
+
319
+ CREATE TABLE IF NOT EXISTS task_defaults (
320
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
321
+ task_type TEXT NOT NULL,
322
+ model_id TEXT NOT NULL REFERENCES models(id) ON DELETE CASCADE,
323
+ PRIMARY KEY(work_id, task_type)
324
+ );
325
+
326
+ CREATE TABLE IF NOT EXISTS platform_ai_settings (
327
+ id INTEGER PRIMARY KEY CHECK(id = 1),
328
+ system_prompt TEXT NOT NULL DEFAULT '',
329
+ updated_at TEXT NOT NULL
330
+ );
331
+
332
+ CREATE TABLE IF NOT EXISTS platform_ui_settings (
333
+ id INTEGER PRIMARY KEY CHECK(id = 1),
334
+ toast_position TEXT NOT NULL DEFAULT 'bottom-right' CHECK(toast_position IN ('bottom-right', 'top-right')),
335
+ updated_at TEXT NOT NULL
336
+ );
337
+
338
+ CREATE TABLE IF NOT EXISTS work_ai_settings (
339
+ work_id TEXT PRIMARY KEY REFERENCES works(id) ON DELETE CASCADE,
340
+ system_prompt TEXT NOT NULL DEFAULT '',
341
+ auto_run_enabled INTEGER NOT NULL DEFAULT 0,
342
+ auto_run_concurrency INTEGER NOT NULL DEFAULT 2,
343
+ auto_run_batch_limit INTEGER NOT NULL DEFAULT 20,
344
+ book_summary_context_percent INTEGER NOT NULL DEFAULT 50 CHECK(book_summary_context_percent BETWEEN 1 AND 90),
345
+ context_compact_threshold INTEGER NOT NULL DEFAULT 85 CHECK(context_compact_threshold BETWEEN 50 AND 90),
346
+ agent_tools_json TEXT NOT NULL DEFAULT '["story_index","read_chapters","query_story_knowledge"]',
347
+ updated_at TEXT NOT NULL
348
+ );
349
+
350
+ CREATE TABLE IF NOT EXISTS ai_calls (
351
+ id TEXT PRIMARY KEY,
352
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
353
+ task_type TEXT NOT NULL,
354
+ provider_id TEXT NOT NULL,
355
+ model_id TEXT NOT NULL,
356
+ context_scope_json TEXT NOT NULL,
357
+ parameters_json TEXT NOT NULL DEFAULT '{}',
358
+ status TEXT NOT NULL,
359
+ failure TEXT,
360
+ input_chars INTEGER NOT NULL DEFAULT 0,
361
+ output_chars INTEGER NOT NULL DEFAULT 0,
362
+ created_at TEXT NOT NULL,
363
+ completed_at TEXT
364
+ );
365
+
366
+ CREATE TABLE IF NOT EXISTS ai_suggestions (
367
+ id TEXT PRIMARY KEY,
368
+ call_id TEXT NOT NULL REFERENCES ai_calls(id) ON DELETE CASCADE,
369
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
370
+ chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL,
371
+ chapter_version INTEGER,
372
+ task_type TEXT NOT NULL,
373
+ instruction TEXT NOT NULL,
374
+ source_text TEXT NOT NULL DEFAULT '',
375
+ content TEXT NOT NULL,
376
+ action TEXT NOT NULL DEFAULT 'note',
377
+ status TEXT NOT NULL DEFAULT 'pending',
378
+ created_at TEXT NOT NULL,
379
+ decided_at TEXT
380
+ );
381
+
382
+ CREATE TABLE IF NOT EXISTS ai_conversations (
383
+ id TEXT PRIMARY KEY,
384
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
385
+ title TEXT NOT NULL DEFAULT '新对话',
386
+ compacted_summary TEXT NOT NULL DEFAULT '',
387
+ compacted_message_count INTEGER NOT NULL DEFAULT 0,
388
+ context_warning_at TEXT,
389
+ created_at TEXT NOT NULL,
390
+ updated_at TEXT NOT NULL
391
+ );
392
+
393
+ CREATE TABLE IF NOT EXISTS ai_conversation_messages (
394
+ id TEXT PRIMARY KEY,
395
+ conversation_id TEXT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
396
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
397
+ content TEXT NOT NULL,
398
+ citations_json TEXT NOT NULL DEFAULT '[]',
399
+ metadata_json TEXT NOT NULL DEFAULT '{}',
400
+ created_at TEXT NOT NULL
401
+ );
402
+
403
+ CREATE TABLE IF NOT EXISTS analysis_tasks (
404
+ id TEXT PRIMARY KEY,
405
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
406
+ task_type TEXT NOT NULL,
407
+ scope_json TEXT NOT NULL DEFAULT '{}',
408
+ status TEXT NOT NULL DEFAULT 'pending',
409
+ progress INTEGER NOT NULL DEFAULT 0,
410
+ result_json TEXT NOT NULL DEFAULT '{}',
411
+ failure_json TEXT NOT NULL DEFAULT '[]',
412
+ source_versions_json TEXT NOT NULL DEFAULT '{}',
413
+ created_at TEXT NOT NULL,
414
+ updated_at TEXT NOT NULL
415
+ );
416
+
417
+ CREATE TABLE IF NOT EXISTS audit_logs (
418
+ id TEXT PRIMARY KEY,
419
+ work_id TEXT REFERENCES works(id) ON DELETE CASCADE,
420
+ action TEXT NOT NULL,
421
+ entity_type TEXT NOT NULL,
422
+ entity_id TEXT,
423
+ actor TEXT NOT NULL DEFAULT 'owner',
424
+ detail_json TEXT NOT NULL DEFAULT '{}',
425
+ created_at TEXT NOT NULL
426
+ );
427
+
428
+ CREATE TABLE IF NOT EXISTS work_covers (
429
+ work_id TEXT PRIMARY KEY REFERENCES works(id) ON DELETE CASCADE,
430
+ mime_type TEXT NOT NULL CHECK(mime_type IN ('image/jpeg', 'image/png', 'image/webp')),
431
+ content BLOB NOT NULL,
432
+ byte_length INTEGER NOT NULL,
433
+ sha256 TEXT NOT NULL,
434
+ updated_at TEXT NOT NULL
435
+ );
436
+
437
+ CREATE TABLE IF NOT EXISTS character_names (
438
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
439
+ normalized_name TEXT NOT NULL,
440
+ character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
441
+ display_name TEXT NOT NULL,
442
+ kind TEXT NOT NULL CHECK(kind IN ('primary', 'alias')),
443
+ sort_order INTEGER NOT NULL DEFAULT 0,
444
+ PRIMARY KEY(work_id, normalized_name)
445
+ );
446
+
447
+ CREATE TABLE IF NOT EXISTS organizations (
448
+ id TEXT PRIMARY KEY,
449
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
450
+ name TEXT NOT NULL,
451
+ normalized_name TEXT NOT NULL,
452
+ description TEXT NOT NULL DEFAULT '',
453
+ settings_json TEXT NOT NULL DEFAULT '[]',
454
+ created_at TEXT NOT NULL,
455
+ updated_at TEXT NOT NULL,
456
+ UNIQUE(work_id, normalized_name)
457
+ );
458
+
459
+ CREATE TABLE IF NOT EXISTS character_organization_memberships (
460
+ character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
461
+ organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
462
+ role TEXT NOT NULL DEFAULT '',
463
+ note TEXT NOT NULL DEFAULT '',
464
+ created_at TEXT NOT NULL,
465
+ updated_at TEXT NOT NULL,
466
+ PRIMARY KEY(character_id, organization_id)
467
+ );
468
+
469
+ CREATE TABLE IF NOT EXISTS chapter_outlines (
470
+ chapter_id TEXT PRIMARY KEY REFERENCES chapters(id) ON DELETE CASCADE,
471
+ goal TEXT NOT NULL DEFAULT '',
472
+ conflict TEXT NOT NULL DEFAULT '',
473
+ turning_point TEXT NOT NULL DEFAULT '',
474
+ notes TEXT NOT NULL DEFAULT '',
475
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'ready', 'completed')),
476
+ created_at TEXT NOT NULL,
477
+ updated_at TEXT NOT NULL
478
+ );
479
+
480
+ CREATE TABLE IF NOT EXISTS foreshadows (
481
+ id TEXT PRIMARY KEY,
482
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
483
+ title TEXT NOT NULL,
484
+ description TEXT NOT NULL DEFAULT '',
485
+ status TEXT NOT NULL DEFAULT 'planned' CHECK(status IN ('planned', 'planted', 'resolved', 'abandoned')),
486
+ importance TEXT NOT NULL DEFAULT 'medium' CHECK(importance IN ('low', 'medium', 'high')),
487
+ planned_payoff_chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL,
488
+ resolution_note TEXT NOT NULL DEFAULT '',
489
+ created_at TEXT NOT NULL,
490
+ updated_at TEXT NOT NULL
491
+ );
492
+
493
+ CREATE TABLE IF NOT EXISTS foreshadow_occurrences (
494
+ id TEXT PRIMARY KEY,
495
+ foreshadow_id TEXT NOT NULL REFERENCES foreshadows(id) ON DELETE CASCADE,
496
+ chapter_id TEXT NOT NULL REFERENCES chapters(id) ON DELETE CASCADE,
497
+ role TEXT NOT NULL CHECK(role IN ('setup', 'reminder', 'payoff')),
498
+ note TEXT NOT NULL DEFAULT '',
499
+ evidence_json TEXT NOT NULL DEFAULT '[]',
500
+ created_at TEXT NOT NULL,
501
+ updated_at TEXT NOT NULL,
502
+ UNIQUE(foreshadow_id, chapter_id, role)
503
+ );
504
+
505
+ CREATE TABLE IF NOT EXISTS continuation_guard_runs (
506
+ id TEXT PRIMARY KEY,
507
+ suggestion_id TEXT NOT NULL REFERENCES ai_suggestions(id) ON DELETE CASCADE,
508
+ call_id TEXT REFERENCES ai_calls(id) ON DELETE SET NULL,
509
+ chapter_version INTEGER NOT NULL,
510
+ content_hash TEXT NOT NULL,
511
+ status TEXT NOT NULL CHECK(status IN ('clear', 'warning', 'failed')),
512
+ issues_json TEXT NOT NULL DEFAULT '[]',
513
+ context_refs_json TEXT NOT NULL DEFAULT '{}',
514
+ failure TEXT,
515
+ created_at TEXT NOT NULL
516
+ );
517
+
518
+ CREATE INDEX IF NOT EXISTS idx_volumes_work ON volumes(work_id, sort_order);
519
+ CREATE INDEX IF NOT EXISTS idx_chapters_work ON chapters(work_id, volume_id, sort_order);
520
+ CREATE INDEX IF NOT EXISTS idx_versions_chapter ON chapter_versions(chapter_id, version_no DESC);
521
+ CREATE INDEX IF NOT EXISTS idx_settings_work ON settings(work_id, category);
522
+ CREATE INDEX IF NOT EXISTS idx_characters_work ON characters(work_id, name);
523
+ CREATE INDEX IF NOT EXISTS idx_events_work ON timeline_events(work_id, time_sort);
524
+ CREATE INDEX IF NOT EXISTS idx_timeline_tracks_work ON timeline_tracks(work_id, sort_order);
525
+ CREATE INDEX IF NOT EXISTS idx_relationships_work ON relationships(work_id);
526
+ CREATE INDEX IF NOT EXISTS idx_reviews_work ON review_items(work_id, status);
527
+ CREATE INDEX IF NOT EXISTS idx_tasks_work ON analysis_tasks(work_id, status);
528
+ CREATE INDEX IF NOT EXISTS idx_calls_work ON ai_calls(work_id, created_at DESC);
529
+ CREATE INDEX IF NOT EXISTS idx_ai_conversations_work ON ai_conversations(work_id, updated_at DESC);
530
+ CREATE INDEX IF NOT EXISTS idx_ai_conversation_messages ON ai_conversation_messages(conversation_id, created_at);
531
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_character_names_primary ON character_names(character_id) WHERE kind = 'primary';
532
+ CREATE INDEX IF NOT EXISTS idx_character_names_character ON character_names(character_id, sort_order);
533
+ CREATE INDEX IF NOT EXISTS idx_character_versions_character ON character_versions(character_id, version_no DESC);
534
+ CREATE INDEX IF NOT EXISTS idx_entity_versions_entity ON entity_versions(entity_type, entity_id, version_no DESC);
535
+ CREATE INDEX IF NOT EXISTS idx_entity_versions_work ON entity_versions(work_id, created_at DESC);
536
+ CREATE INDEX IF NOT EXISTS idx_races_work ON races(work_id, name);
537
+ CREATE INDEX IF NOT EXISTS idx_organizations_work ON organizations(work_id, name);
538
+ CREATE INDEX IF NOT EXISTS idx_memberships_organization ON character_organization_memberships(organization_id, character_id);
539
+ CREATE INDEX IF NOT EXISTS idx_foreshadows_work ON foreshadows(work_id, status, importance);
540
+ CREATE INDEX IF NOT EXISTS idx_foreshadow_occurrences_chapter ON foreshadow_occurrences(chapter_id, role);
541
+ CREATE INDEX IF NOT EXISTS idx_continuation_guards_suggestion ON continuation_guard_runs(suggestion_id, created_at DESC);
542
+ `);
543
+ this.applyDataMigrations();
544
+ }
545
+ applyDataMigrations() {
546
+ const applied = new Set(this.all("SELECT version FROM schema_migrations").map((row) => Number(row.version)));
547
+ if (!applied.has(1)) {
548
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (1, ?)", new Date().toISOString());
549
+ }
550
+ if (!applied.has(2)) {
551
+ this.transaction(() => {
552
+ this.run("DELETE FROM character_names");
553
+ const characters = this.all("SELECT id, work_id, name, aliases_json FROM characters ORDER BY created_at, id");
554
+ for (const character of characters) {
555
+ const aliases = this.parseAliases(character.aliases_json);
556
+ const names = [
557
+ { displayName: character.name, kind: "primary", sortOrder: 0 },
558
+ ...aliases.map((displayName, index) => ({ displayName, kind: "alias", sortOrder: index + 1 }))
559
+ ];
560
+ const localNames = new Set();
561
+ for (const name of names) {
562
+ const normalizedName = this.normalizeCharacterName(name.displayName);
563
+ if (!normalizedName)
564
+ throw new Error(`角色 ${character.id} 存在空名称,无法完成名称索引迁移`);
565
+ if (localNames.has(normalizedName))
566
+ throw new Error(`角色 ${character.id} 的名称或别名重复:${name.displayName}`);
567
+ localNames.add(normalizedName);
568
+ try {
569
+ this.run(`INSERT INTO character_names (work_id, normalized_name, character_id, display_name, kind, sort_order)
570
+ VALUES (?, ?, ?, ?, ?, ?)`, character.work_id, normalizedName, character.id, name.displayName.trim(), name.kind, name.sortOrder);
571
+ }
572
+ catch {
573
+ throw new Error(`作品 ${character.work_id} 存在重复角色名或别名:${name.displayName}`);
574
+ }
575
+ }
576
+ }
577
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (2, ?)", new Date().toISOString());
578
+ });
579
+ }
580
+ if (!applied.has(3)) {
581
+ this.transaction(() => {
582
+ const relationshipColumns = new Set(this.all("PRAGMA table_info(relationships)").map((row) => String(row.name)));
583
+ if (!relationshipColumns.has("keywords_json")) {
584
+ this.run("ALTER TABLE relationships ADD COLUMN keywords_json TEXT NOT NULL DEFAULT '[]'");
585
+ }
586
+ const providerColumns = new Set(this.all("PRAGMA table_info(providers)").map((row) => String(row.name)));
587
+ if (!providerColumns.has("concurrency_limit")) {
588
+ this.run("ALTER TABLE providers ADD COLUMN concurrency_limit INTEGER NOT NULL DEFAULT 10 CHECK(concurrency_limit BETWEEN 1 AND 100)");
589
+ }
590
+ if (!providerColumns.has("rpm_limit")) {
591
+ this.run("ALTER TABLE providers ADD COLUMN rpm_limit INTEGER NOT NULL DEFAULT 10 CHECK(rpm_limit BETWEEN 1 AND 10000)");
592
+ }
593
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (3, ?)", new Date().toISOString());
594
+ });
595
+ }
596
+ if (!applied.has(4)) {
597
+ this.transaction(() => {
598
+ const chapterColumns = new Set(this.all("PRAGMA table_info(chapters)").map((row) => String(row.name)));
599
+ if (!chapterColumns.has("chapter_type")) {
600
+ this.run("ALTER TABLE chapters ADD COLUMN chapter_type TEXT NOT NULL DEFAULT '正文' CHECK(chapter_type IN ('正文', '设定', '作者的话', '其他'))");
601
+ }
602
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (4, ?)", new Date().toISOString());
603
+ });
604
+ }
605
+ if (!applied.has(5)) {
606
+ this.transaction(() => {
607
+ const providerColumns = new Set(this.all("PRAGMA table_info(providers)").map((row) => String(row.name)));
608
+ if (!providerColumns.has("max_tokens")) {
609
+ this.run("ALTER TABLE providers ADD COLUMN max_tokens INTEGER NOT NULL DEFAULT 32000 CHECK(max_tokens BETWEEN 1 AND 32768)");
610
+ }
611
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (5, ?)", new Date().toISOString());
612
+ });
613
+ }
614
+ if (!applied.has(6)) {
615
+ this.transaction(() => {
616
+ this.run(`CREATE TABLE IF NOT EXISTS timeline_tracks (
617
+ id TEXT PRIMARY KEY,
618
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
619
+ name TEXT NOT NULL,
620
+ description TEXT NOT NULL DEFAULT '',
621
+ sort_order INTEGER NOT NULL DEFAULT 0,
622
+ created_at TEXT NOT NULL,
623
+ updated_at TEXT NOT NULL,
624
+ UNIQUE(work_id, name)
625
+ )`);
626
+ const eventColumns = new Set(this.all("PRAGMA table_info(timeline_events)").map((row) => String(row.name)));
627
+ if (!eventColumns.has("track_id")) {
628
+ this.run("ALTER TABLE timeline_events ADD COLUMN track_id TEXT REFERENCES timeline_tracks(id) ON DELETE SET NULL");
629
+ }
630
+ this.run("CREATE INDEX IF NOT EXISTS idx_timeline_tracks_work ON timeline_tracks(work_id, sort_order)");
631
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (6, ?)", new Date().toISOString());
632
+ });
633
+ }
634
+ if (!applied.has(7)) {
635
+ this.transaction(() => {
636
+ const volumeColumns = new Set(this.all("PRAGMA table_info(volumes)").map((row) => String(row.name)));
637
+ if (!volumeColumns.has("description")) {
638
+ this.run("ALTER TABLE volumes ADD COLUMN description TEXT NOT NULL DEFAULT ''");
639
+ }
640
+ if (!volumeColumns.has("keywords_json")) {
641
+ this.run("ALTER TABLE volumes ADD COLUMN keywords_json TEXT NOT NULL DEFAULT '[]'");
642
+ }
643
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (7, ?)", new Date().toISOString());
644
+ });
645
+ }
646
+ if (!applied.has(8)) {
647
+ this.transaction(() => {
648
+ const timestamp = new Date().toISOString();
649
+ const workColumns = new Set(this.all("PRAGMA table_info(works)").map((row) => String(row.name)));
650
+ if (!workColumns.has("is_internal")) {
651
+ this.run("ALTER TABLE works ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0");
652
+ }
653
+ const modelColumns = new Set(this.all("PRAGMA table_info(models)").map((row) => String(row.name)));
654
+ if (!modelColumns.has("context_window")) {
655
+ this.run("ALTER TABLE models ADD COLUMN context_window INTEGER NOT NULL DEFAULT 128000 CHECK(context_window BETWEEN 1024 AND 2000000)");
656
+ }
657
+ this.run(`CREATE TABLE IF NOT EXISTS platform_ai_settings (
658
+ id INTEGER PRIMARY KEY CHECK(id = 1),
659
+ system_prompt TEXT NOT NULL DEFAULT '',
660
+ updated_at TEXT NOT NULL
661
+ )`);
662
+ this.run(`CREATE TABLE IF NOT EXISTS work_ai_settings (
663
+ work_id TEXT PRIMARY KEY REFERENCES works(id) ON DELETE CASCADE,
664
+ system_prompt TEXT NOT NULL DEFAULT '',
665
+ updated_at TEXT NOT NULL
666
+ )`);
667
+ this.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, is_internal, created_at, updated_at)
668
+ VALUES (?, '平台 AI 配置', '', '', 'zh-CN', NULL, '[]', 1, ?, ?)
669
+ ON CONFLICT(id) DO UPDATE SET is_internal = 1`, PLATFORM_AI_WORK_ID, timestamp, timestamp);
670
+ this.run("INSERT INTO platform_ai_settings (id, system_prompt, updated_at) VALUES (1, '', ?) ON CONFLICT(id) DO NOTHING", timestamp);
671
+ this.run("UPDATE providers SET work_id = ? WHERE work_id <> ?", PLATFORM_AI_WORK_ID, PLATFORM_AI_WORK_ID);
672
+ this.run("CREATE INDEX IF NOT EXISTS idx_work_ai_settings_work ON work_ai_settings(work_id)");
673
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (8, ?)", timestamp);
674
+ });
675
+ }
676
+ if (!applied.has(9)) {
677
+ this.transaction(() => {
678
+ const messageColumns = new Set(this.all("PRAGMA table_info(ai_conversation_messages)").map((row) => String(row.name)));
679
+ if (!messageColumns.has("metadata_json")) {
680
+ this.run("ALTER TABLE ai_conversation_messages ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
681
+ }
682
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (9, ?)", new Date().toISOString());
683
+ });
684
+ }
685
+ if (!applied.has(10)) {
686
+ this.transaction(() => {
687
+ const characterColumns = new Set(this.all("PRAGMA table_info(characters)").map((row) => String(row.name)));
688
+ if (!characterColumns.has("species")) {
689
+ this.run("ALTER TABLE characters ADD COLUMN species TEXT NOT NULL DEFAULT ''");
690
+ }
691
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (10, ?)", new Date().toISOString());
692
+ });
693
+ }
694
+ if (!applied.has(11)) {
695
+ this.transaction(() => {
696
+ const characters = this.all("SELECT id, species, attributes_json FROM characters WHERE species = ''");
697
+ for (const character of characters) {
698
+ try {
699
+ const attributes = JSON.parse(character.attributes_json);
700
+ if (typeof attributes.species === "string" && attributes.species.trim()) {
701
+ this.run("UPDATE characters SET species = ? WHERE id = ?", attributes.species.trim(), character.id);
702
+ }
703
+ }
704
+ catch {
705
+ // 无效的旧扩展属性保持原样,避免迁移阻断数据库启动。
706
+ }
707
+ }
708
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (11, ?)", new Date().toISOString());
709
+ });
710
+ }
711
+ if (!applied.has(12)) {
712
+ this.transaction(() => {
713
+ const characterColumns = new Set(this.all("PRAGMA table_info(characters)").map((row) => String(row.name)));
714
+ if (!characterColumns.has("version_no")) {
715
+ this.run("ALTER TABLE characters ADD COLUMN version_no INTEGER NOT NULL DEFAULT 1");
716
+ }
717
+ this.run(`CREATE TABLE IF NOT EXISTS character_versions (
718
+ id TEXT PRIMARY KEY,
719
+ character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
720
+ version_no INTEGER NOT NULL,
721
+ snapshot_json TEXT NOT NULL,
722
+ source TEXT NOT NULL DEFAULT 'manual',
723
+ source_ref TEXT,
724
+ change_note TEXT NOT NULL DEFAULT '',
725
+ created_at TEXT NOT NULL,
726
+ UNIQUE(character_id, version_no)
727
+ )`);
728
+ this.run("CREATE INDEX IF NOT EXISTS idx_character_versions_character ON character_versions(character_id, version_no DESC)");
729
+ const characterVersionColumns = new Set(this.all("PRAGMA table_info(character_versions)").map((row) => String(row.name)));
730
+ const characters = this.all("SELECT * FROM characters ORDER BY created_at, id");
731
+ for (const character of characters) {
732
+ const characterId = String(character.id);
733
+ const organizationIds = this.all("SELECT organization_id FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", characterId).map((membership) => membership.organization_id);
734
+ const parseJson = (value, fallback) => {
735
+ try {
736
+ return JSON.parse(String(value));
737
+ }
738
+ catch {
739
+ return fallback;
740
+ }
741
+ };
742
+ const snapshot = {
743
+ name: String(character.name),
744
+ aliases: parseJson(character.aliases_json, []),
745
+ species: String(character.species ?? ""),
746
+ organizationIds,
747
+ attributes: parseJson(character.attributes_json, {}),
748
+ profile: parseJson(character.profile_json, {}),
749
+ currentState: parseJson(character.current_state_json, {}),
750
+ lockedFields: parseJson(character.locked_fields_json, []),
751
+ visibility: String(character.visibility),
752
+ firstChapterId: character.first_chapter_id === null ? null : String(character.first_chapter_id)
753
+ };
754
+ this.run(characterVersionColumns.has("work_id")
755
+ ? `INSERT INTO character_versions (id, work_id, character_id, version_no, snapshot_json, source, change_note, created_at)
756
+ VALUES (?, ?, ?, 1, ?, 'migration', '建立人物版本基线', ?)
757
+ ON CONFLICT(character_id, version_no) DO NOTHING`
758
+ : `INSERT INTO character_versions (id, character_id, version_no, snapshot_json, source, change_note, created_at)
759
+ VALUES (?, ?, 1, ?, 'migration', '建立人物版本基线', ?)
760
+ ON CONFLICT(character_id, version_no) DO NOTHING`, ...(characterVersionColumns.has("work_id")
761
+ ? [`characterVersion_migration_${characterId}`, String(character.work_id), characterId, JSON.stringify(snapshot), String(character.updated_at)]
762
+ : [`characterVersion_migration_${characterId}`, characterId, JSON.stringify(snapshot), String(character.updated_at)]));
763
+ }
764
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (12, ?)", new Date().toISOString());
765
+ });
766
+ }
767
+ if (!applied.has(13)) {
768
+ this.transaction(() => {
769
+ this.run(`CREATE TABLE IF NOT EXISTS races (
770
+ id TEXT PRIMARY KEY,
771
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
772
+ name TEXT NOT NULL,
773
+ normalized_name TEXT NOT NULL,
774
+ description TEXT NOT NULL DEFAULT '',
775
+ settings_json TEXT NOT NULL DEFAULT '[]',
776
+ created_at TEXT NOT NULL,
777
+ updated_at TEXT NOT NULL,
778
+ UNIQUE(work_id, normalized_name)
779
+ )`);
780
+ this.run("CREATE INDEX IF NOT EXISTS idx_races_work ON races(work_id, name)");
781
+ const characterColumns = new Set(this.all("PRAGMA table_info(characters)").map((row) => String(row.name)));
782
+ if (!characterColumns.has("race_id")) {
783
+ this.run("ALTER TABLE characters ADD COLUMN race_id TEXT REFERENCES races(id) ON DELETE SET NULL");
784
+ }
785
+ const legacySpecies = this.all(`SELECT work_id, species, MIN(created_at) AS created_at, MAX(updated_at) AS updated_at
786
+ FROM characters WHERE TRIM(species) <> '' GROUP BY work_id, species ORDER BY work_id, species`);
787
+ const raceByWorkAndName = new Map();
788
+ let migrationIndex = 0;
789
+ for (const legacy of legacySpecies) {
790
+ const name = legacy.species.normalize("NFKC").trim().replace(/\s+/gu, " ");
791
+ const normalizedName = name.toLocaleLowerCase("zh-CN");
792
+ const key = `${legacy.work_id}\u0000${normalizedName}`;
793
+ let raceId = raceByWorkAndName.get(key);
794
+ if (!raceId) {
795
+ const existing = this.get("SELECT id FROM races WHERE work_id = ? AND normalized_name = ?", legacy.work_id, normalizedName);
796
+ raceId = existing?.id ?? `race_migration_${++migrationIndex}`;
797
+ if (!existing) {
798
+ this.run(`INSERT INTO races (id, work_id, name, normalized_name, description, settings_json, created_at, updated_at)
799
+ VALUES (?, ?, ?, ?, '由旧人物种族字段迁移生成', '[]', ?, ?)`, raceId, legacy.work_id, name, normalizedName, legacy.created_at, legacy.updated_at);
800
+ }
801
+ raceByWorkAndName.set(key, raceId);
802
+ }
803
+ this.run("UPDATE characters SET race_id = ?, species = ? WHERE work_id = ? AND species = ?", raceId, name, legacy.work_id, legacy.species);
804
+ }
805
+ const versions = this.all(`SELECT cv.id, c.work_id, cv.snapshot_json FROM character_versions cv
806
+ JOIN characters c ON c.id = cv.character_id`);
807
+ for (const version of versions) {
808
+ try {
809
+ const snapshot = JSON.parse(version.snapshot_json);
810
+ const species = typeof snapshot.species === "string" ? snapshot.species.normalize("NFKC").trim().replace(/\s+/gu, " ") : "";
811
+ if (!species) {
812
+ snapshot.raceId = null;
813
+ }
814
+ else {
815
+ const normalizedName = species.toLocaleLowerCase("zh-CN");
816
+ const race = this.get("SELECT id FROM races WHERE work_id = ? AND normalized_name = ?", version.work_id, normalizedName);
817
+ snapshot.raceId = race?.id ?? null;
818
+ }
819
+ this.run("UPDATE character_versions SET snapshot_json = ? WHERE id = ?", JSON.stringify(snapshot), version.id);
820
+ }
821
+ catch {
822
+ // 无效历史快照保持原样,避免阻断数据库迁移。
823
+ }
824
+ }
825
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (13, ?)", new Date().toISOString());
826
+ });
827
+ }
828
+ if (!applied.has(14)) {
829
+ this.transaction(() => {
830
+ this.run(`CREATE TABLE IF NOT EXISTS entity_versions (
831
+ id TEXT PRIMARY KEY,
832
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
833
+ entity_type TEXT NOT NULL,
834
+ entity_id TEXT NOT NULL,
835
+ version_no INTEGER NOT NULL,
836
+ snapshot_json TEXT NOT NULL,
837
+ source TEXT NOT NULL DEFAULT 'manual',
838
+ source_ref TEXT,
839
+ change_note TEXT NOT NULL DEFAULT '',
840
+ created_at TEXT NOT NULL,
841
+ UNIQUE(entity_type, entity_id, version_no)
842
+ )`);
843
+ this.run("CREATE INDEX IF NOT EXISTS idx_entity_versions_entity ON entity_versions(entity_type, entity_id, version_no DESC)");
844
+ this.run("CREATE INDEX IF NOT EXISTS idx_entity_versions_work ON entity_versions(work_id, created_at DESC)");
845
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (14, ?)", new Date().toISOString());
846
+ });
847
+ }
848
+ if (!applied.has(15)) {
849
+ this.transaction(() => {
850
+ this.run(`CREATE TABLE IF NOT EXISTS users (
851
+ id TEXT PRIMARY KEY,
852
+ username TEXT NOT NULL,
853
+ normalized_username TEXT NOT NULL UNIQUE,
854
+ display_name TEXT NOT NULL,
855
+ password_hash TEXT NOT NULL,
856
+ password_salt TEXT NOT NULL,
857
+ role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
858
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
859
+ created_at TEXT NOT NULL,
860
+ updated_at TEXT NOT NULL,
861
+ last_login_at TEXT
862
+ )`);
863
+ this.run(`CREATE TABLE IF NOT EXISTS user_sessions (
864
+ id TEXT PRIMARY KEY,
865
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
866
+ token_hash TEXT NOT NULL UNIQUE,
867
+ csrf_token TEXT NOT NULL,
868
+ created_at TEXT NOT NULL,
869
+ expires_at TEXT NOT NULL,
870
+ last_seen_at TEXT NOT NULL,
871
+ revoked_at TEXT
872
+ )`);
873
+ const workColumns = new Set(this.all("PRAGMA table_info(works)").map((row) => String(row.name)));
874
+ if (!workColumns.has("owner_user_id")) {
875
+ this.run("ALTER TABLE works ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL");
876
+ }
877
+ this.run(`CREATE TABLE IF NOT EXISTS work_memberships (
878
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
879
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
880
+ role TEXT NOT NULL CHECK(role IN ('owner', 'editor')),
881
+ invited_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
882
+ created_at TEXT NOT NULL,
883
+ PRIMARY KEY(work_id, user_id)
884
+ )`);
885
+ const actorColumns = [
886
+ ["file_versions", "created_by_user_id"],
887
+ ["chapter_versions", "created_by_user_id"],
888
+ ["character_versions", "created_by_user_id"],
889
+ ["entity_versions", "created_by_user_id"],
890
+ ["ai_calls", "created_by_user_id"],
891
+ ["ai_suggestions", "created_by_user_id"],
892
+ ["ai_suggestions", "decided_by_user_id"],
893
+ ["ai_conversations", "created_by_user_id"],
894
+ ["ai_conversation_messages", "created_by_user_id"],
895
+ ["analysis_tasks", "created_by_user_id"],
896
+ ["audit_logs", "user_id"],
897
+ ["continuation_guard_runs", "created_by_user_id"]
898
+ ];
899
+ for (const [table, column] of actorColumns) {
900
+ const columns = new Set(this.all(`PRAGMA table_info(${table})`).map((row) => String(row.name)));
901
+ if (!columns.has(column))
902
+ this.run(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT REFERENCES users(id) ON DELETE SET NULL`);
903
+ }
904
+ this.run("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status, username)");
905
+ this.run("CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)");
906
+ this.run("CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, expires_at)");
907
+ this.run("CREATE INDEX IF NOT EXISTS idx_work_memberships_user ON work_memberships(user_id, work_id)");
908
+ this.run("CREATE INDEX IF NOT EXISTS idx_works_owner ON works(owner_user_id, updated_at DESC)");
909
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (15, ?)", new Date().toISOString());
910
+ });
911
+ }
912
+ if (!applied.has(16)) {
913
+ this.transaction(() => {
914
+ const chapterVersionColumns = new Set(this.all("PRAGMA table_info(chapter_versions)").map((row) => String(row.name)));
915
+ if (!chapterVersionColumns.has("work_id")) {
916
+ this.run(`CREATE TABLE chapter_versions_v16 (
917
+ id TEXT PRIMARY KEY,
918
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
919
+ chapter_id TEXT NOT NULL,
920
+ version_no INTEGER NOT NULL,
921
+ title TEXT NOT NULL,
922
+ content TEXT NOT NULL,
923
+ volume_id TEXT,
924
+ sort_order INTEGER,
925
+ chapter_type TEXT,
926
+ source TEXT NOT NULL DEFAULT 'manual',
927
+ source_ref TEXT,
928
+ created_at TEXT NOT NULL,
929
+ created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
930
+ UNIQUE(chapter_id, version_no)
931
+ )`);
932
+ this.run(`INSERT INTO chapter_versions_v16 (
933
+ id, work_id, chapter_id, version_no, title, content, volume_id, sort_order, chapter_type,
934
+ source, source_ref, created_at, created_by_user_id
935
+ )
936
+ SELECT version.id, chapter.work_id, version.chapter_id, version.version_no, version.title, version.content,
937
+ chapter.volume_id, chapter.sort_order, chapter.chapter_type, version.source, version.source_ref,
938
+ version.created_at, version.created_by_user_id
939
+ FROM chapter_versions version
940
+ JOIN chapters chapter ON chapter.id = version.chapter_id`);
941
+ this.run("DROP TABLE chapter_versions");
942
+ this.run("ALTER TABLE chapter_versions_v16 RENAME TO chapter_versions");
943
+ }
944
+ const characterVersionColumns = new Set(this.all("PRAGMA table_info(character_versions)").map((row) => String(row.name)));
945
+ if (!characterVersionColumns.has("work_id")) {
946
+ this.run(`CREATE TABLE character_versions_v16 (
947
+ id TEXT PRIMARY KEY,
948
+ work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE,
949
+ character_id TEXT NOT NULL,
950
+ version_no INTEGER NOT NULL,
951
+ snapshot_json TEXT NOT NULL,
952
+ source TEXT NOT NULL DEFAULT 'manual',
953
+ source_ref TEXT,
954
+ change_note TEXT NOT NULL DEFAULT '',
955
+ created_at TEXT NOT NULL,
956
+ created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
957
+ UNIQUE(character_id, version_no)
958
+ )`);
959
+ this.run(`INSERT INTO character_versions_v16 (
960
+ id, work_id, character_id, version_no, snapshot_json, source, source_ref, change_note, created_at, created_by_user_id
961
+ )
962
+ SELECT version.id, character.work_id, version.character_id, version.version_no, version.snapshot_json,
963
+ version.source, version.source_ref, version.change_note, version.created_at, version.created_by_user_id
964
+ FROM character_versions version
965
+ JOIN characters character ON character.id = version.character_id`);
966
+ this.run("DROP TABLE character_versions");
967
+ this.run("ALTER TABLE character_versions_v16 RENAME TO character_versions");
968
+ }
969
+ this.run("CREATE INDEX IF NOT EXISTS idx_chapter_versions_work ON chapter_versions(work_id, chapter_id, version_no)");
970
+ this.run("CREATE INDEX IF NOT EXISTS idx_character_versions_work ON character_versions(work_id, character_id, version_no)");
971
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (16, ?)", new Date().toISOString());
972
+ });
973
+ }
974
+ if (!applied.has(17)) {
975
+ this.transaction(() => {
976
+ const columns = new Set(this.all("PRAGMA table_info(work_ai_settings)").map((row) => String(row.name)));
977
+ if (!columns.has("auto_run_enabled")) {
978
+ this.run("ALTER TABLE work_ai_settings ADD COLUMN auto_run_enabled INTEGER NOT NULL DEFAULT 0");
979
+ }
980
+ if (!columns.has("auto_run_concurrency")) {
981
+ this.run("ALTER TABLE work_ai_settings ADD COLUMN auto_run_concurrency INTEGER NOT NULL DEFAULT 2");
982
+ }
983
+ if (!columns.has("auto_run_batch_limit")) {
984
+ this.run("ALTER TABLE work_ai_settings ADD COLUMN auto_run_batch_limit INTEGER NOT NULL DEFAULT 20");
985
+ }
986
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (17, ?)", new Date().toISOString());
987
+ });
988
+ }
989
+ if (!applied.has(18)) {
990
+ this.transaction(() => {
991
+ const columns = new Set(this.all("PRAGMA table_info(work_ai_settings)").map((row) => String(row.name)));
992
+ if (!columns.has("book_summary_context_percent")) {
993
+ this.run("ALTER TABLE work_ai_settings ADD COLUMN book_summary_context_percent INTEGER NOT NULL DEFAULT 50");
994
+ }
995
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (18, ?)", new Date().toISOString());
996
+ });
997
+ }
998
+ if (!applied.has(19)) {
999
+ this.transaction(() => {
1000
+ const columns = new Set(this.all("PRAGMA table_info(work_ai_settings)").map((row) => String(row.name)));
1001
+ if (!columns.has("agent_tools_json")) {
1002
+ this.run("ALTER TABLE work_ai_settings ADD COLUMN agent_tools_json TEXT NOT NULL DEFAULT '[\"story_index\",\"read_chapters\",\"query_story_knowledge\"]'");
1003
+ }
1004
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (19, ?)", new Date().toISOString());
1005
+ });
1006
+ }
1007
+ if (!applied.has(20)) {
1008
+ this.transaction(() => {
1009
+ const settingsColumns = new Set(this.all("PRAGMA table_info(work_ai_settings)").map((row) => String(row.name)));
1010
+ if (!settingsColumns.has("context_compact_threshold")) {
1011
+ this.run("ALTER TABLE work_ai_settings ADD COLUMN context_compact_threshold INTEGER NOT NULL DEFAULT 85");
1012
+ }
1013
+ const conversationColumns = new Set(this.all("PRAGMA table_info(ai_conversations)").map((row) => String(row.name)));
1014
+ if (!conversationColumns.has("compacted_summary")) {
1015
+ this.run("ALTER TABLE ai_conversations ADD COLUMN compacted_summary TEXT NOT NULL DEFAULT ''");
1016
+ }
1017
+ if (!conversationColumns.has("compacted_message_count")) {
1018
+ this.run("ALTER TABLE ai_conversations ADD COLUMN compacted_message_count INTEGER NOT NULL DEFAULT 0");
1019
+ }
1020
+ if (!conversationColumns.has("context_warning_at")) {
1021
+ this.run("ALTER TABLE ai_conversations ADD COLUMN context_warning_at TEXT");
1022
+ }
1023
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (20, ?)", new Date().toISOString());
1024
+ });
1025
+ }
1026
+ if (!applied.has(21)) {
1027
+ this.transaction(() => {
1028
+ this.run(`CREATE TABLE IF NOT EXISTS user_api_keys (
1029
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
1030
+ key_hash TEXT NOT NULL UNIQUE,
1031
+ key_prefix TEXT NOT NULL,
1032
+ created_at TEXT NOT NULL,
1033
+ rotated_at TEXT NOT NULL,
1034
+ last_used_at TEXT
1035
+ )`);
1036
+ this.run("CREATE INDEX IF NOT EXISTS idx_user_api_keys_hash ON user_api_keys(key_hash)");
1037
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (21, ?)", new Date().toISOString());
1038
+ });
1039
+ }
1040
+ if (!applied.has(22)) {
1041
+ this.transaction(() => {
1042
+ const columns = new Set(this.all("PRAGMA table_info(chapter_versions)").map((row) => String(row.name)));
1043
+ if (!columns.has("change_note")) {
1044
+ this.run("ALTER TABLE chapter_versions ADD COLUMN change_note TEXT NOT NULL DEFAULT ''");
1045
+ }
1046
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (22, ?)", new Date().toISOString());
1047
+ });
1048
+ }
1049
+ if (!applied.has(23)) {
1050
+ this.transaction(() => {
1051
+ const columns = new Set(this.all("PRAGMA table_info(models)").map((row) => String(row.name)));
1052
+ if (!columns.has("thinking_enabled")) {
1053
+ this.run("ALTER TABLE models ADD COLUMN thinking_enabled INTEGER NOT NULL DEFAULT 1");
1054
+ }
1055
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (23, ?)", new Date().toISOString());
1056
+ });
1057
+ }
1058
+ if (!applied.has(24)) {
1059
+ this.transaction(() => {
1060
+ const columns = new Set(this.all("PRAGMA table_info(users)").map((row) => String(row.name)));
1061
+ if (!columns.has("avatar_updated_at")) {
1062
+ this.run("ALTER TABLE users ADD COLUMN avatar_updated_at TEXT");
1063
+ }
1064
+ this.run(`CREATE TABLE IF NOT EXISTS user_avatars (
1065
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
1066
+ mime_type TEXT NOT NULL CHECK(mime_type IN ('image/png', 'image/jpeg', 'image/webp')),
1067
+ content BLOB NOT NULL,
1068
+ byte_length INTEGER NOT NULL,
1069
+ sha256 TEXT NOT NULL,
1070
+ width INTEGER NOT NULL,
1071
+ height INTEGER NOT NULL,
1072
+ updated_at TEXT NOT NULL
1073
+ )`);
1074
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (24, ?)", new Date().toISOString());
1075
+ });
1076
+ }
1077
+ if (!applied.has(25)) {
1078
+ this.transaction(() => {
1079
+ const columns = new Set(this.all("PRAGMA table_info(users)").map((row) => String(row.name)));
1080
+ if (!columns.has("avatar_sha256")) {
1081
+ this.run("ALTER TABLE users ADD COLUMN avatar_sha256 TEXT");
1082
+ }
1083
+ this.run(`UPDATE users SET avatar_sha256 = (
1084
+ SELECT avatar.sha256 FROM user_avatars avatar WHERE avatar.user_id = users.id
1085
+ ) WHERE avatar_updated_at IS NOT NULL AND avatar_sha256 IS NULL`);
1086
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (25, ?)", new Date().toISOString());
1087
+ });
1088
+ }
1089
+ if (!applied.has(26)) {
1090
+ this.transaction(() => {
1091
+ const timestamp = new Date().toISOString();
1092
+ this.run(`CREATE TABLE IF NOT EXISTS platform_ui_settings (
1093
+ id INTEGER PRIMARY KEY CHECK(id = 1),
1094
+ toast_position TEXT NOT NULL DEFAULT 'bottom-right' CHECK(toast_position IN ('bottom-right', 'top-right')),
1095
+ updated_at TEXT NOT NULL
1096
+ )`);
1097
+ this.run("INSERT INTO platform_ui_settings (id, toast_position, updated_at) VALUES (1, 'bottom-right', ?) ON CONFLICT(id) DO NOTHING", timestamp);
1098
+ this.run("INSERT INTO schema_migrations (version, applied_at) VALUES (26, ?)", timestamp);
1099
+ });
1100
+ }
1101
+ }
1102
+ normalizeCharacterName(value) {
1103
+ return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
1104
+ }
1105
+ parseAliases(value) {
1106
+ try {
1107
+ const parsed = JSON.parse(value);
1108
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
1109
+ }
1110
+ catch {
1111
+ return [];
1112
+ }
1113
+ }
1114
+ recoverInterruptedOperations() {
1115
+ const timestamp = new Date().toISOString();
1116
+ this.run(`UPDATE ai_calls SET status = 'failed', failure = COALESCE(failure, '服务重启导致调用中断'), completed_at = ?
1117
+ WHERE status = 'running'`, timestamp);
1118
+ this.run(`UPDATE analysis_tasks SET status = 'partial', failure_json = ?, updated_at = ?
1119
+ WHERE status = 'running'`, JSON.stringify([{ message: "服务重启导致任务中断" }]), timestamp);
1120
+ }
1121
+ }
1122
+ //# sourceMappingURL=database.js.map