@musnows/scriverse 0.6.1 → 0.6.3

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 (47) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/ai-tool-results.js +71 -0
  4. package/dist/ai-tool-results.js.map +1 -1
  5. package/dist/ai.js +267 -54
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +256 -57
  8. package/dist/app.js.map +1 -1
  9. package/dist/attachment-storage.js +9 -1
  10. package/dist/attachment-storage.js.map +1 -1
  11. package/dist/cli-contract.js +4 -0
  12. package/dist/cli-contract.js.map +1 -1
  13. package/dist/cli-core.js +23 -7
  14. package/dist/cli-core.js.map +1 -1
  15. package/dist/credential-vault.js +7 -5
  16. package/dist/credential-vault.js.map +1 -1
  17. package/dist/database.js +243 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/docx-export.js +89 -0
  20. package/dist/docx-export.js.map +1 -0
  21. package/dist/domain.js +9 -0
  22. package/dist/domain.js.map +1 -1
  23. package/dist/image-captcha.js +8 -5
  24. package/dist/image-captcha.js.map +1 -1
  25. package/dist/public/ai-context-meter.js +4 -0
  26. package/dist/public/ai-message-time.js +3 -9
  27. package/dist/public/ai-tool-call.js +4 -0
  28. package/dist/public/app.js +765 -111
  29. package/dist/public/index.html +49 -17
  30. package/dist/public/markdown.js +1 -2
  31. package/dist/public/page-route.js +2 -2
  32. package/dist/public/styles.css +87 -29
  33. package/dist/public/system-status.d.ts +12 -0
  34. package/dist/public/system-status.js +16 -0
  35. package/dist/public/theme-init.js +2 -2
  36. package/dist/security.js +143 -16
  37. package/dist/security.js.map +1 -1
  38. package/dist/server-runtime.js +56 -1
  39. package/dist/server-runtime.js.map +1 -1
  40. package/dist/store.js +289 -39
  41. package/dist/store.js.map +1 -1
  42. package/dist/user-auth.js +56 -88
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/dist/writing-progress-time.js +15 -0
  46. package/dist/writing-progress-time.js.map +1 -1
  47. package/package.json +2 -1
package/dist/store.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { DRAFT_SETTING_MODULES } from "./domain.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { PLATFORM_AI_WORK_ID } from "./database.js";
4
+ import { exportWorkDocx } from "./docx-export.js";
3
5
  import { AppError, notFound } from "./errors.js";
4
6
  import { accountReference, logger } from "./logger.js";
5
7
  import { paginated, paginationSql } from "./pagination.js";
@@ -7,6 +9,32 @@ import { currentRequestActor } from "./request-context.js";
7
9
  import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
8
10
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
9
11
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
12
+ export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
13
+ export const WORK_AGENT_TOOL_IDS = [
14
+ "story_index",
15
+ "read_chapters",
16
+ "grep",
17
+ "search_story_entities",
18
+ "read_character_sections",
19
+ "search_drafts"
20
+ ];
21
+ const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
22
+ export function normalizeWorkAgentTools(value) {
23
+ const source = Array.isArray(value)
24
+ ? value
25
+ : typeof value === "string"
26
+ ? json(value, DEFAULT_WORK_AGENT_TOOLS)
27
+ : DEFAULT_WORK_AGENT_TOOLS;
28
+ const enabled = new Set();
29
+ for (const item of source) {
30
+ if (typeof item !== "string")
31
+ continue;
32
+ const toolId = item === "query_story_knowledge" ? "search_story_entities" : item;
33
+ if (WORK_AGENT_TOOL_IDS.includes(toolId))
34
+ enabled.add(toolId);
35
+ }
36
+ return WORK_AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId));
37
+ }
10
38
  const defaultPlatformPageSizes = {
11
39
  drafts: 30,
12
40
  settings: 30,
@@ -302,6 +330,8 @@ export class Store {
302
330
  if (type === "draft")
303
331
  return {
304
332
  draftType: entity.draftType,
333
+ volumeId: entity.volumeId,
334
+ settingModule: entity.settingModule,
305
335
  title: entity.title,
306
336
  content: entity.content
307
337
  };
@@ -681,6 +711,9 @@ export class Store {
681
711
  return {
682
712
  workId,
683
713
  systemPrompt: String(row?.system_prompt ?? ""),
714
+ dailyTokenQuota: row?.daily_token_quota === null || row?.daily_token_quota === undefined
715
+ ? null
716
+ : Math.max(10_000, Number(row.daily_token_quota)),
684
717
  autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
685
718
  autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
686
719
  autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
@@ -692,9 +725,9 @@ export class Store {
692
725
  autoRunConsecutiveFailures: Math.max(0, Number(row?.auto_run_consecutive_failures ?? 0) || 0),
693
726
  bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
694
727
  contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
695
- agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","search_story_entities","grep","read_character_sections","search_drafts"]'), ["story_index", "read_chapters", "search_story_entities", "grep", "read_character_sections", "search_drafts"])
696
- .map((tool) => tool === "query_story_knowledge" ? "search_story_entities" : tool)
697
- .filter((tool, index, tools) => tools.indexOf(tool) === index),
728
+ agentToolCallLimit: Math.min(48, Math.max(5, Number(row?.agent_tool_call_limit ?? 12) || 12)),
729
+ agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, Number(row?.agent_tool_call_global_multiplier ?? 3) || 3)),
730
+ agentTools: normalizeWorkAgentTools(row?.agent_tools_json),
698
731
  titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
699
732
  ? null
700
733
  : String(row.title_generation_model_id),
@@ -706,6 +739,9 @@ export class Store {
706
739
  const current = this.getWorkAiSettings(workId);
707
740
  const timestamp = now();
708
741
  const nextPrompt = input.systemPrompt ?? String(current.systemPrompt);
742
+ const nextDailyTokenQuota = input.dailyTokenQuota === undefined
743
+ ? (current.dailyTokenQuota === null ? null : Number(current.dailyTokenQuota))
744
+ : input.dailyTokenQuota;
709
745
  const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
710
746
  const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
711
747
  const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
@@ -713,18 +749,22 @@ export class Store {
713
749
  const nextFailureThreshold = input.autoRunFailureThreshold ?? Number(current.autoRunFailureThreshold);
714
750
  const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
715
751
  const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
716
- const nextAgentTools = input.agentTools ?? current.agentTools;
752
+ const nextAgentToolCallLimit = input.agentToolCallLimit ?? Number(current.agentToolCallLimit);
753
+ const nextAgentToolCallGlobalMultiplier = input.agentToolCallGlobalMultiplier ?? Number(current.agentToolCallGlobalMultiplier);
754
+ const nextAgentTools = normalizeWorkAgentTools(input.agentTools ?? current.agentTools);
717
755
  const nextTitleGenerationModelId = input.titleGenerationModelId === undefined
718
756
  ? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
719
757
  : input.titleGenerationModelId?.trim() || null;
720
758
  this.db.run(`INSERT INTO work_ai_settings (
721
- work_id, system_prompt, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
759
+ work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
722
760
  auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
723
761
  auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
724
- context_compact_threshold, agent_tools_json, title_generation_model_id, updated_at
725
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
762
+ context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
763
+ agent_tools_json, title_generation_model_id, updated_at
764
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
726
765
  ON CONFLICT(work_id) DO UPDATE SET
727
766
  system_prompt = excluded.system_prompt,
767
+ daily_token_quota = excluded.daily_token_quota,
728
768
  auto_run_enabled = excluded.auto_run_enabled,
729
769
  auto_run_concurrency = excluded.auto_run_concurrency,
730
770
  auto_run_batch_limit = excluded.auto_run_batch_limit,
@@ -736,11 +776,14 @@ export class Store {
736
776
  auto_run_consecutive_failures = excluded.auto_run_consecutive_failures,
737
777
  book_summary_context_percent = excluded.book_summary_context_percent,
738
778
  context_compact_threshold = excluded.context_compact_threshold,
779
+ agent_tool_call_limit = excluded.agent_tool_call_limit,
780
+ agent_tool_call_global_multiplier = excluded.agent_tool_call_global_multiplier,
739
781
  agent_tools_json = excluded.agent_tools_json,
740
782
  title_generation_model_id = excluded.title_generation_model_id,
741
- updated_at = excluded.updated_at`, workId, nextPrompt, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
783
+ updated_at = excluded.updated_at`, workId, nextPrompt, nextDailyTokenQuota, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), Math.min(48, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
742
784
  this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
743
785
  systemPromptChanged: input.systemPrompt !== undefined,
786
+ dailyTokenQuota: nextDailyTokenQuota,
744
787
  autoRunEnabled: nextEnabled,
745
788
  autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
746
789
  autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
@@ -748,6 +791,8 @@ export class Store {
748
791
  autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
749
792
  bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
750
793
  contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
794
+ agentToolCallLimit: Math.min(48, Math.max(5, nextAgentToolCallLimit)),
795
+ agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)),
751
796
  agentTools: nextAgentTools,
752
797
  titleGenerationModelId: nextTitleGenerationModelId
753
798
  });
@@ -817,13 +862,23 @@ export class Store {
817
862
  const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
818
863
  .map((row) => requiredString(row, "storage_key"));
819
864
  this.db.transaction(() => {
865
+ this.db.raw.exec("PRAGMA defer_foreign_keys = ON");
820
866
  const current = this.getWork(workId);
821
867
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
822
868
  this.recordEntityVersion("work", workId, "delete", null, "删除作品");
823
869
  this.audit(null, "work.deleted", "work", workId, { title: work.title });
870
+ this.db.run("DELETE FROM characters WHERE work_id = ?", workId);
871
+ this.db.run("DELETE FROM organizations WHERE work_id = ?", workId);
872
+ this.db.run("UPDATE races SET parent_race_id = NULL WHERE work_id = ? AND parent_race_id IS NOT NULL", workId);
873
+ this.db.run("DELETE FROM races WHERE work_id = ?", workId);
824
874
  this.db.run("DELETE FROM works WHERE id = ?", workId);
875
+ this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
876
+ for (const storageKey of storageKeys) {
877
+ if (!this.attachmentStorageKeyInUse(storageKey))
878
+ this.enqueueAttachmentCleanup(storageKey);
879
+ }
825
880
  });
826
- return storageKeys.filter((storageKey) => Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) === 0);
881
+ return storageKeys.filter((storageKey) => !this.attachmentStorageKeyInUse(storageKey));
827
882
  }
828
883
  setWorkCover(workId, mimeType, content, expectedVersionNo) {
829
884
  const sha256 = createHash("sha256").update(content).digest("hex");
@@ -842,12 +897,22 @@ export class Store {
842
897
  return this.getWork(workId);
843
898
  }
844
899
  getWorkCover(workId) {
900
+ const cover = this.findWorkCover(workId);
901
+ if (!cover)
902
+ throw notFound("作品封面");
903
+ return cover;
904
+ }
905
+ findWorkCover(workId) {
845
906
  this.getWork(workId);
846
907
  const row = this.db.get("SELECT * FROM work_covers WHERE work_id = ?", workId);
847
908
  if (!row)
848
- throw notFound("作品封面");
909
+ return null;
910
+ const mimeType = requiredString(row, "mime_type");
911
+ if (mimeType !== "image/jpeg" && mimeType !== "image/png" && mimeType !== "image/webp") {
912
+ throw new AppError(500, "INVALID_COVER_MIME", "作品封面类型无效");
913
+ }
849
914
  return {
850
- mimeType: requiredString(row, "mime_type"),
915
+ mimeType,
851
916
  content: Buffer.from(row.content),
852
917
  byteLength: numberValue(row, "byte_length"),
853
918
  sha256: requiredString(row, "sha256"),
@@ -1026,6 +1091,7 @@ export class Store {
1026
1091
  for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
1027
1092
  this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "替换作品树前保存分卷历史");
1028
1093
  }
1094
+ this.clearDraftVolumeBindings(workId, null, fileVersionId, "恢复文件版本时原分卷已被替换");
1029
1095
  this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
1030
1096
  for (const volume of volumes) {
1031
1097
  const volumeId = id("volume");
@@ -1077,6 +1143,7 @@ export class Store {
1077
1143
  for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
1078
1144
  this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "导入前保存分卷历史");
1079
1145
  }
1146
+ this.clearDraftVolumeBindings(workId, null, fileVersionId, "覆盖导入时原分卷已被替换");
1080
1147
  this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
1081
1148
  }
1082
1149
  else {
@@ -1163,19 +1230,22 @@ export class Store {
1163
1230
  throw new AppError(409, "VOLUME_HAS_DELETED_CHAPTERS", "分卷回收站中仍有章节,请先彻底删除或恢复并移动这些章节");
1164
1231
  }
1165
1232
  this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
1233
+ this.clearDraftVolumeBindings(String(current.workId), [volumeId], null, "绑定的分卷已删除");
1166
1234
  this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
1167
1235
  this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
1168
1236
  });
1169
1237
  }
1170
1238
  createChapter(workId, input) {
1171
- this.getWork(workId);
1172
- const volume = this.getVolume(input.volumeId);
1173
- if (volume.workId !== workId)
1174
- throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1175
- const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId);
1176
- const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
1177
- this.audit(workId, "chapter.created", "chapter", chapterId);
1178
- return this.getChapter(chapterId);
1239
+ return this.db.transaction(() => {
1240
+ this.getWork(workId);
1241
+ const volume = this.getVolume(input.volumeId);
1242
+ if (volume.workId !== workId)
1243
+ throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1244
+ const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId);
1245
+ const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
1246
+ this.audit(workId, "chapter.created", "chapter", chapterId);
1247
+ return this.getChapter(chapterId);
1248
+ });
1179
1249
  }
1180
1250
  getChapter(chapterId) {
1181
1251
  const row = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
@@ -2276,9 +2346,10 @@ export class Store {
2276
2346
  }
2277
2347
  insertDraftWithId(workId, draftId, input, source = "create", sourceRef = null, changeNote = "") {
2278
2348
  const timestamp = now();
2349
+ const binding = this.normalizeDraftBinding(workId, input.draftType, input.volumeId ?? null, input.settingModule ?? null);
2279
2350
  this.db.transaction(() => {
2280
- this.db.run(`INSERT INTO drafts (id, work_id, draft_type, title, content, created_at, updated_at)
2281
- VALUES (?, ?, ?, ?, ?, ?, ?)`, draftId, workId, input.draftType, input.title, input.content, timestamp, timestamp);
2351
+ this.db.run(`INSERT INTO drafts (id, work_id, draft_type, volume_id, setting_module, title, content, created_at, updated_at)
2352
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, draftId, workId, input.draftType, binding.volumeId, binding.settingModule, input.title, input.content, timestamp, timestamp);
2282
2353
  this.syncMarkdownAttachmentReferences(workId, "draft", draftId, input.content);
2283
2354
  this.recordEntityVersion("draft", draftId, source, sourceRef, changeNote || "建立创作想法", timestamp);
2284
2355
  this.audit(workId, source === "restore" ? "draft.restored" : "draft.created", "draft", draftId, {
@@ -2291,14 +2362,18 @@ export class Store {
2291
2362
  }
2292
2363
  listDrafts(workId, draftType, includeContent = false) {
2293
2364
  this.getWork(workId);
2294
- return this.db.all(`SELECT * FROM drafts WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2295
- ORDER BY updated_at DESC, title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
2365
+ return this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
2366
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id
2367
+ WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
2368
+ ORDER BY draft.updated_at DESC, draft.title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
2296
2369
  }
2297
2370
  listDraftsPage(workId, pagination, draftType, includeContent = false) {
2298
2371
  this.getWork(workId);
2299
2372
  const page = paginationSql(pagination);
2300
- const rows = this.db.all(`SELECT * FROM drafts WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2301
- ORDER BY updated_at DESC, title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
2373
+ const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
2374
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id
2375
+ WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
2376
+ ORDER BY draft.updated_at DESC, draft.title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
2302
2377
  return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
2303
2378
  }
2304
2379
  searchDrafts(workId, query, draftType, limit = 20) {
@@ -2308,17 +2383,21 @@ export class Store {
2308
2383
  const escapedQuery = normalizedQuery.replace(/[\\%_]/gu, "\\$&");
2309
2384
  const pattern = `%${escapedQuery}%`;
2310
2385
  const rows = normalizedQuery
2311
- ? this.db.all(`SELECT * FROM drafts
2312
- WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2313
- AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR content LIKE ? ESCAPE '\\' COLLATE NOCASE)
2314
- ORDER BY CASE WHEN title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, updated_at DESC
2386
+ ? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
2387
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id
2388
+ WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
2389
+ AND (draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE OR draft.content LIKE ? ESCAPE '\\' COLLATE NOCASE)
2390
+ ORDER BY CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
2315
2391
  LIMIT ?`, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
2316
- : this.db.all(`SELECT * FROM drafts WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2317
- ORDER BY updated_at DESC, title LIMIT ?`, workId, draftType ?? null, draftType ?? null, safeLimit);
2392
+ : this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
2393
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id
2394
+ WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
2395
+ ORDER BY draft.updated_at DESC, draft.title LIMIT ?`, workId, draftType ?? null, draftType ?? null, safeLimit);
2318
2396
  return rows.map((row) => this.mapDraft(row, true));
2319
2397
  }
2320
2398
  getDraft(draftId) {
2321
- const row = this.db.get("SELECT * FROM drafts WHERE id = ?", draftId);
2399
+ const row = this.db.get(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
2400
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id WHERE draft.id = ?`, draftId);
2322
2401
  if (!row)
2323
2402
  throw notFound("想法");
2324
2403
  return this.mapDraft(row, true);
@@ -2326,9 +2405,15 @@ export class Store {
2326
2405
  updateDraft(draftId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2327
2406
  const current = this.getDraft(draftId);
2328
2407
  const content = input.content ?? String(current.content);
2408
+ const draftType = input.draftType ?? current.draftType;
2409
+ const typeChanged = input.draftType !== undefined && input.draftType !== current.draftType;
2410
+ const restoreMissingBinding = source === "restore";
2411
+ const volumeId = Object.hasOwn(input, "volumeId") ? input.volumeId ?? null : typeChanged || restoreMissingBinding ? null : current.volumeId;
2412
+ const settingModule = Object.hasOwn(input, "settingModule") ? input.settingModule ?? null : typeChanged || restoreMissingBinding ? null : current.settingModule;
2413
+ const binding = this.normalizeDraftBinding(String(current.workId), draftType, volumeId, settingModule);
2329
2414
  this.db.transaction(() => {
2330
2415
  this.assertExpectedVersion("draft", draftId, expectedVersionNo, "想法");
2331
- this.db.run("UPDATE drafts SET draft_type = ?, title = ?, content = ?, updated_at = ? WHERE id = ?", input.draftType ?? String(current.draftType), input.title ?? String(current.title), content, now(), draftId);
2416
+ this.db.run("UPDATE drafts SET draft_type = ?, volume_id = ?, setting_module = ?, title = ?, content = ?, updated_at = ? WHERE id = ?", draftType, binding.volumeId, binding.settingModule, input.title ?? String(current.title), content, now(), draftId);
2332
2417
  this.syncMarkdownAttachmentReferences(String(current.workId), "draft", draftId, content);
2333
2418
  this.recordEntityVersion("draft", draftId, source, sourceRef, changeNote || "更新创作想法");
2334
2419
  this.audit(String(current.workId), "draft.updated", "draft", draftId, { fields: Object.keys(input), source, sourceRef });
@@ -2351,6 +2436,9 @@ export class Store {
2351
2436
  id: requiredString(row, "id"),
2352
2437
  workId: requiredString(row, "work_id"),
2353
2438
  draftType: requiredString(row, "draft_type"),
2439
+ volumeId: optionalString(row, "volume_id"),
2440
+ volumeTitle: optionalString(row, "volume_title"),
2441
+ settingModule: optionalString(row, "setting_module"),
2354
2442
  title: requiredString(row, "title"),
2355
2443
  ...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
2356
2444
  versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
@@ -2358,6 +2446,41 @@ export class Store {
2358
2446
  updatedAt: requiredString(row, "updated_at")
2359
2447
  };
2360
2448
  }
2449
+ normalizeDraftBinding(workId, draftType, volumeId, settingModule) {
2450
+ if (draftType === "prose") {
2451
+ if (settingModule !== null) {
2452
+ throw new AppError(400, "DRAFT_BINDING_TYPE_MISMATCH", "正文想法不能绑定设定模块");
2453
+ }
2454
+ if (volumeId !== null) {
2455
+ const volume = this.getVolume(volumeId);
2456
+ if (volume.workId !== workId) {
2457
+ throw new AppError(400, "DRAFT_VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
2458
+ }
2459
+ }
2460
+ return { volumeId, settingModule: null };
2461
+ }
2462
+ if (volumeId !== null) {
2463
+ throw new AppError(400, "DRAFT_BINDING_TYPE_MISMATCH", "设定想法不能绑定分卷");
2464
+ }
2465
+ if (settingModule !== null && !DRAFT_SETTING_MODULES.includes(settingModule)) {
2466
+ throw new AppError(400, "DRAFT_SETTING_MODULE_INVALID", "设定想法绑定模块无效");
2467
+ }
2468
+ return { volumeId: null, settingModule };
2469
+ }
2470
+ clearDraftVolumeBindings(workId, volumeIds, sourceRef, changeNote) {
2471
+ const drafts = volumeIds === null
2472
+ ? this.db.all("SELECT id FROM drafts WHERE work_id = ? AND volume_id IS NOT NULL", workId)
2473
+ : volumeIds.length
2474
+ ? this.db.all(`SELECT id FROM drafts WHERE work_id = ? AND volume_id IN (${volumeIds.map(() => "?").join(", ")})`, workId, ...volumeIds)
2475
+ : [];
2476
+ const timestamp = now();
2477
+ for (const draft of drafts) {
2478
+ const draftId = requiredString(draft, "id");
2479
+ this.db.run("UPDATE drafts SET volume_id = NULL, updated_at = ? WHERE id = ?", timestamp, draftId);
2480
+ this.recordEntityVersion("draft", draftId, "manual", sourceRef, changeNote, timestamp);
2481
+ this.audit(workId, "draft.updated", "draft", draftId, { fields: ["volumeId"], source: "volume-deleted", sourceRef });
2482
+ }
2483
+ }
2361
2484
  createSetting(workId, input, source = "create", sourceRef = null) {
2362
2485
  this.getWork(workId);
2363
2486
  return this.insertSettingWithId(workId, id("setting"), input, source, sourceRef);
@@ -3224,11 +3347,16 @@ export class Store {
3224
3347
  createdAt: requiredString(row, "created_at")
3225
3348
  };
3226
3349
  }
3227
- createAttachment(workId, input) {
3350
+ createAttachment(workId, input, accessModule = "settings") {
3228
3351
  this.getWork(workId);
3229
3352
  const existing = this.db.get("SELECT * FROM attachments WHERE work_id = ? AND stored_sha256 = ?", workId, input.storedSha256);
3230
- if (existing)
3353
+ if (existing) {
3354
+ this.db.transaction(() => {
3355
+ this.db.run("INSERT OR IGNORE INTO attachment_access_modules (attachment_id, module, created_at) VALUES (?, ?, ?)", requiredString(existing, "id"), accessModule, now());
3356
+ this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", requiredString(existing, "storage_key"));
3357
+ });
3231
3358
  return { attachment: this.mapAttachment(existing), created: false };
3359
+ }
3232
3360
  const attachmentId = id("attachment");
3233
3361
  const timestamp = now();
3234
3362
  this.db.transaction(() => {
@@ -3236,6 +3364,8 @@ export class Store {
3236
3364
  (id, work_id, original_name, original_mime_type, stored_mime_type, original_byte_length, stored_byte_length,
3237
3365
  original_sha256, stored_sha256, storage_key, width, height, page_count, animated, created_at, created_by_user_id)
3238
3366
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, attachmentId, workId, input.originalName, input.originalMimeType, input.storedMimeType, input.originalByteLength, input.storedByteLength, input.originalSha256, input.storedSha256, input.storageKey, input.width, input.height, input.pageCount, input.animated ? 1 : 0, timestamp, currentRequestActor()?.userId ?? null);
3367
+ this.db.run("INSERT INTO attachment_access_modules (attachment_id, module, created_at) VALUES (?, ?, ?)", attachmentId, accessModule, timestamp);
3368
+ this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", input.storageKey);
3239
3369
  this.audit(workId, "attachment.created", "attachment", attachmentId, {
3240
3370
  originalMimeType: input.originalMimeType,
3241
3371
  storedMimeType: input.storedMimeType,
@@ -3262,18 +3392,100 @@ export class Store {
3262
3392
  throw notFound("附件");
3263
3393
  return this.mapAttachment(row);
3264
3394
  }
3395
+ attachmentModules(attachmentId) {
3396
+ this.getAttachment(attachmentId);
3397
+ const modules = new Set();
3398
+ for (const row of this.db.all("SELECT module FROM attachment_access_modules WHERE attachment_id = ?", attachmentId)) {
3399
+ const module = String(row.module);
3400
+ if (attachmentPermissionModules.includes(module))
3401
+ modules.add(module);
3402
+ }
3403
+ const referenceModules = {
3404
+ chapter: "prose",
3405
+ draft: "drafts",
3406
+ setting: "settings",
3407
+ "character-section": "characters",
3408
+ race: "races",
3409
+ organization: "organizations"
3410
+ };
3411
+ for (const row of this.db.all("SELECT DISTINCT entity_type FROM attachment_references WHERE attachment_id = ?", attachmentId)) {
3412
+ const module = referenceModules[String(row.entity_type)];
3413
+ if (module)
3414
+ modules.add(module);
3415
+ }
3416
+ return [...modules];
3417
+ }
3418
+ attachmentStorageKeyInUse(storageKey) {
3419
+ return Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) > 0;
3420
+ }
3421
+ enqueueAttachmentCleanup(storageKey) {
3422
+ const timestamp = now();
3423
+ this.db.run(`INSERT INTO attachment_cleanup_queue (storage_key, attempts, last_error, created_at, updated_at)
3424
+ VALUES (?, 0, NULL, ?, ?) ON CONFLICT(storage_key) DO UPDATE SET updated_at = excluded.updated_at`, storageKey, timestamp, timestamp);
3425
+ }
3426
+ listAttachmentCleanupQueue(limit = 100) {
3427
+ return this.db.all("SELECT storage_key, attempts FROM attachment_cleanup_queue ORDER BY updated_at, storage_key LIMIT ?", Math.max(1, Math.min(1_000, Math.trunc(limit)))).map((row) => ({ storageKey: requiredString(row, "storage_key"), attempts: numberValue(row, "attempts") }));
3428
+ }
3429
+ attachmentCleanupStillRequired(storageKey) {
3430
+ return !this.attachmentStorageKeyInUse(storageKey);
3431
+ }
3432
+ completeAttachmentCleanup(storageKey) {
3433
+ this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", storageKey);
3434
+ }
3435
+ failAttachmentCleanup(storageKey, message) {
3436
+ this.db.run("UPDATE attachment_cleanup_queue SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE storage_key = ?", message.slice(0, 500), now(), storageKey);
3437
+ }
3438
+ attachmentHistoricalReferenceCount(attachmentId) {
3439
+ const needle = `attachment://${attachmentId}`;
3440
+ const sources = [
3441
+ ["entity_versions", "snapshot_json"],
3442
+ ["character_profile_section_versions", "snapshot_json"],
3443
+ ["character_versions", "snapshot_json"],
3444
+ ["chapter_versions", "content"],
3445
+ ["file_versions", "snapshot_json"]
3446
+ ];
3447
+ return sources.reduce((count, [table, column]) => count + Number(this.db.get(`SELECT COUNT(*) AS count FROM ${table} WHERE instr(${column}, ?) > 0`, needle)?.count ?? 0), 0);
3448
+ }
3449
+ queueUnreferencedAttachments(retentionMs = 24 * 60 * 60_000, limit = 100) {
3450
+ const cutoff = new Date(Date.now() - Math.max(0, retentionMs)).toISOString();
3451
+ const candidates = this.db.all(`SELECT attachment.* FROM attachments attachment
3452
+ WHERE attachment.created_at <= ?
3453
+ AND NOT EXISTS (
3454
+ SELECT 1 FROM attachment_references reference WHERE reference.attachment_id = attachment.id
3455
+ )
3456
+ ORDER BY attachment.created_at, attachment.id LIMIT ?`, cutoff, Math.max(1, Math.min(1_000, Math.trunc(limit))));
3457
+ let queued = 0;
3458
+ for (const candidate of candidates) {
3459
+ const attachmentId = requiredString(candidate, "id");
3460
+ if (this.attachmentHistoricalReferenceCount(attachmentId) > 0)
3461
+ continue;
3462
+ const storageKey = requiredString(candidate, "storage_key");
3463
+ this.db.transaction(() => {
3464
+ this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
3465
+ this.audit(requiredString(candidate, "work_id"), "attachment.garbage-collected", "attachment", attachmentId, { storageKey });
3466
+ if (!this.attachmentStorageKeyInUse(storageKey))
3467
+ this.enqueueAttachmentCleanup(storageKey);
3468
+ });
3469
+ queued += 1;
3470
+ }
3471
+ return queued;
3472
+ }
3265
3473
  deleteAttachment(attachmentId) {
3266
3474
  const attachment = this.getAttachment(attachmentId);
3267
3475
  const references = Number(this.db.get("SELECT COUNT(*) AS count FROM attachment_references WHERE attachment_id = ?", attachmentId)?.count ?? 0);
3268
3476
  if (references > 0)
3269
3477
  throw new AppError(409, "ATTACHMENT_IN_USE", "附件仍被资料引用,无法删除");
3478
+ if (this.attachmentHistoricalReferenceCount(attachmentId) > 0) {
3479
+ throw new AppError(409, "ATTACHMENT_IN_VERSION_HISTORY", "附件仍被历史版本引用,无法删除");
3480
+ }
3270
3481
  const storageKey = String(attachment.storageKey);
3271
3482
  this.db.transaction(() => {
3272
3483
  this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
3273
3484
  this.audit(String(attachment.workId), "attachment.deleted", "attachment", attachmentId, { storageKey });
3485
+ if (!this.attachmentStorageKeyInUse(storageKey))
3486
+ this.enqueueAttachmentCleanup(storageKey);
3274
3487
  });
3275
- const remaining = Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0);
3276
- return { storageKey, removeStoredFile: remaining === 0 };
3488
+ return { storageKey, cleanupQueued: !this.attachmentStorageKeyInUse(storageKey) };
3277
3489
  }
3278
3490
  getCharacter(characterId) {
3279
3491
  const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
@@ -4128,7 +4340,8 @@ export class Store {
4128
4340
  this.getWork(workId);
4129
4341
  const conversationId = id("conversation");
4130
4342
  const timestamp = now();
4131
- this.db.run("INSERT INTO ai_conversations (id, work_id, title, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?)", conversationId, workId, title.trim() || "新对话", timestamp, timestamp, currentRequestActor()?.userId ?? null);
4343
+ const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
4344
+ this.db.run("INSERT INTO ai_conversations (id, work_id, title, agent_tools_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)", conversationId, workId, title.trim() || "新对话", JSON.stringify(agentTools), timestamp, timestamp, currentRequestActor()?.userId ?? null);
4132
4345
  return this.getAiConversation(conversationId);
4133
4346
  }
4134
4347
  listAiConversations(workId) {
@@ -4286,7 +4499,9 @@ export class Store {
4286
4499
  const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
4287
4500
  const forkSummary = forkCompactedCount ? requiredString(conversation, "compacted_summary") : "";
4288
4501
  this.db.transaction(() => {
4289
- this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount, timestamp, timestamp, currentRequestActor()?.userId ?? null);
4502
+ this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, agent_tools_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
4503
+ ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(requiredString(conversation, "work_id")).agentTools))
4504
+ : String(conversation.agent_tools_json), timestamp, timestamp, currentRequestActor()?.userId ?? null);
4290
4505
  for (const message of messages.slice(0, targetIndex + 1)) {
4291
4506
  this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, request_id, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id("message"), forkId, requiredString(message, "role"), requiredString(message, "content"), requiredString(message, "citations_json"), requiredString(message, "metadata_json"), optionalString(message, "request_id"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
4292
4507
  }
@@ -4303,10 +4518,29 @@ export class Store {
4303
4518
  compactedMessageCount: numberValue(row, "compacted_message_count"),
4304
4519
  hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
4305
4520
  contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
4521
+ agentTools: row.agent_tools_json == null || row.agent_tools_json === undefined
4522
+ ? null
4523
+ : normalizeWorkAgentTools(row.agent_tools_json),
4306
4524
  createdAt: requiredString(row, "created_at"),
4307
4525
  updatedAt: requiredString(row, "updated_at")
4308
4526
  };
4309
4527
  }
4528
+ /** 锁定本对话可用工具集;已锁定则保持不变,避免中途改作品设置破坏 prompt cache。 */
4529
+ ensureAiConversationAgentTools(conversationId, workId) {
4530
+ const conversation = this.db.get("SELECT id, work_id, agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
4531
+ if (!conversation)
4532
+ throw notFound("AI 对话");
4533
+ if (requiredString(conversation, "work_id") !== workId) {
4534
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4535
+ }
4536
+ if (conversation.agent_tools_json != null && conversation.agent_tools_json !== undefined) {
4537
+ return normalizeWorkAgentTools(conversation.agent_tools_json);
4538
+ }
4539
+ const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
4540
+ this.db.run("UPDATE ai_conversations SET agent_tools_json = ?, updated_at = ? WHERE id = ? AND agent_tools_json IS NULL", JSON.stringify(agentTools), now(), conversationId);
4541
+ const locked = this.db.get("SELECT agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
4542
+ return normalizeWorkAgentTools(locked?.agent_tools_json ?? agentTools);
4543
+ }
4310
4544
  mapAiConversationMessage(row) {
4311
4545
  return {
4312
4546
  id: requiredString(row, "id"),
@@ -5710,6 +5944,22 @@ export class Store {
5710
5944
  }
5711
5945
  return lines.join("\n").trimEnd() + "\n";
5712
5946
  }
5947
+ async exportDocx(workId) {
5948
+ const tree = this.getWorkTree(workId);
5949
+ const cover = this.findWorkCover(workId);
5950
+ const volumes = tree.volumes.map((volume) => ({
5951
+ title: String(volume.title),
5952
+ chapters: volume.chapters.map((chapter) => ({
5953
+ title: String(chapter.title),
5954
+ content: String(chapter.content ?? "")
5955
+ }))
5956
+ }));
5957
+ return exportWorkDocx({
5958
+ title: String(tree.title),
5959
+ volumes,
5960
+ cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
5961
+ });
5962
+ }
5713
5963
  listAuditLogs(workId) {
5714
5964
  this.getWork(workId);
5715
5965
  return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username