@musnows/scriverse 0.6.2 → 0.6.4

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 (40) hide show
  1. package/dist/ai-protocol.js +22 -9
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-tool-results.js +71 -0
  4. package/dist/ai-tool-results.js.map +1 -1
  5. package/dist/ai.js +913 -195
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +245 -46
  8. package/dist/app.js.map +1 -1
  9. package/dist/cli-core.js +23 -13
  10. package/dist/cli-core.js.map +1 -1
  11. package/dist/database.js +251 -2
  12. package/dist/database.js.map +1 -1
  13. package/dist/docx-export.js +89 -0
  14. package/dist/docx-export.js.map +1 -0
  15. package/dist/google-vertex-auth.js +155 -0
  16. package/dist/google-vertex-auth.js.map +1 -0
  17. package/dist/image-captcha.js +8 -5
  18. package/dist/image-captcha.js.map +1 -1
  19. package/dist/public/ai-context-meter.js +4 -0
  20. package/dist/public/ai-mentions.js +10 -0
  21. package/dist/public/ai-message-time.js +3 -9
  22. package/dist/public/ai-tool-call.js +4 -0
  23. package/dist/public/app.js +1004 -131
  24. package/dist/public/display-labels.js +2 -1
  25. package/dist/public/index.html +75 -16
  26. package/dist/public/page-route.js +2 -2
  27. package/dist/public/styles.css +155 -19
  28. package/dist/public/system-status.d.ts +12 -0
  29. package/dist/public/system-status.js +16 -0
  30. package/dist/public/theme-init.js +2 -2
  31. package/dist/security.js +101 -9
  32. package/dist/security.js.map +1 -1
  33. package/dist/store.js +290 -21
  34. package/dist/store.js.map +1 -1
  35. package/dist/user-auth.js +62 -24
  36. package/dist/user-auth.js.map +1 -1
  37. package/dist/version.js +1 -1
  38. package/dist/writing-progress-time.js +23 -0
  39. package/dist/writing-progress-time.js.map +1 -1
  40. package/package.json +2 -1
package/dist/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { DRAFT_SETTING_MODULES } from "./domain.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { PLATFORM_AI_WORK_ID } from "./database.js";
4
+ import { exportWorkDocx } from "./docx-export.js";
4
5
  import { AppError, notFound } from "./errors.js";
5
6
  import { accountReference, logger } from "./logger.js";
6
7
  import { paginated, paginationSql } from "./pagination.js";
@@ -9,6 +10,31 @@ import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModu
9
10
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
10
11
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
11
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
+ }
12
38
  const defaultPlatformPageSizes = {
13
39
  drafts: 30,
14
40
  settings: 30,
@@ -127,6 +153,7 @@ export const versionedEntityTypes = [
127
153
  "chapter-outline",
128
154
  "foreshadow"
129
155
  ];
156
+ export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
130
157
  export function defaultAiConversationTitle(prompt) {
131
158
  const normalized = prompt.replace(/\s+/gu, " ").trim();
132
159
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -205,6 +232,29 @@ function booleanValue(row, key) {
205
232
  export function normalizeCharacterName(value) {
206
233
  return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
207
234
  }
235
+ const EMPTY_AI_INJECTED_ENTITIES = {
236
+ characters: [],
237
+ races: [],
238
+ organizations: []
239
+ };
240
+ function parseAiInjectedEntities(value) {
241
+ const parsed = typeof value === "string" ? json(value, {}) : isRecord(value) ? value : {};
242
+ const uniqueIds = (items) => [...new Set((Array.isArray(items) ? items : [])
243
+ .filter((item) => typeof item === "string" && item.trim().length > 0)
244
+ .map((item) => item.trim()))];
245
+ return {
246
+ characters: uniqueIds(parsed.characters),
247
+ races: uniqueIds(parsed.races),
248
+ organizations: uniqueIds(parsed.organizations)
249
+ };
250
+ }
251
+ function mergeAiInjectedEntities(base, extra) {
252
+ return {
253
+ characters: [...new Set([...base.characters, ...(extra.characters ?? [])])],
254
+ races: [...new Set([...base.races, ...(extra.races ?? [])])],
255
+ organizations: [...new Set([...base.organizations, ...(extra.organizations ?? [])])]
256
+ };
257
+ }
208
258
  export class Store {
209
259
  db;
210
260
  constructor(db) {
@@ -685,6 +735,9 @@ export class Store {
685
735
  return {
686
736
  workId,
687
737
  systemPrompt: String(row?.system_prompt ?? ""),
738
+ dailyTokenQuota: row?.daily_token_quota === null || row?.daily_token_quota === undefined
739
+ ? null
740
+ : Math.max(10_000, Number(row.daily_token_quota)),
688
741
  autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
689
742
  autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
690
743
  autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
@@ -696,9 +749,9 @@ export class Store {
696
749
  autoRunConsecutiveFailures: Math.max(0, Number(row?.auto_run_consecutive_failures ?? 0) || 0),
697
750
  bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
698
751
  contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
699
- 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"])
700
- .map((tool) => tool === "query_story_knowledge" ? "search_story_entities" : tool)
701
- .filter((tool, index, tools) => tools.indexOf(tool) === index),
752
+ agentToolCallLimit: Math.min(48, Math.max(5, Number(row?.agent_tool_call_limit ?? 12) || 12)),
753
+ agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, Number(row?.agent_tool_call_global_multiplier ?? 3) || 3)),
754
+ agentTools: normalizeWorkAgentTools(row?.agent_tools_json),
702
755
  titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
703
756
  ? null
704
757
  : String(row.title_generation_model_id),
@@ -710,6 +763,9 @@ export class Store {
710
763
  const current = this.getWorkAiSettings(workId);
711
764
  const timestamp = now();
712
765
  const nextPrompt = input.systemPrompt ?? String(current.systemPrompt);
766
+ const nextDailyTokenQuota = input.dailyTokenQuota === undefined
767
+ ? (current.dailyTokenQuota === null ? null : Number(current.dailyTokenQuota))
768
+ : input.dailyTokenQuota;
713
769
  const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
714
770
  const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
715
771
  const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
@@ -717,18 +773,22 @@ export class Store {
717
773
  const nextFailureThreshold = input.autoRunFailureThreshold ?? Number(current.autoRunFailureThreshold);
718
774
  const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
719
775
  const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
720
- const nextAgentTools = input.agentTools ?? current.agentTools;
776
+ const nextAgentToolCallLimit = input.agentToolCallLimit ?? Number(current.agentToolCallLimit);
777
+ const nextAgentToolCallGlobalMultiplier = input.agentToolCallGlobalMultiplier ?? Number(current.agentToolCallGlobalMultiplier);
778
+ const nextAgentTools = normalizeWorkAgentTools(input.agentTools ?? current.agentTools);
721
779
  const nextTitleGenerationModelId = input.titleGenerationModelId === undefined
722
780
  ? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
723
781
  : input.titleGenerationModelId?.trim() || null;
724
782
  this.db.run(`INSERT INTO work_ai_settings (
725
- work_id, system_prompt, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
783
+ work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
726
784
  auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
727
785
  auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
728
- context_compact_threshold, agent_tools_json, title_generation_model_id, updated_at
729
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
786
+ context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
787
+ agent_tools_json, title_generation_model_id, updated_at
788
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
730
789
  ON CONFLICT(work_id) DO UPDATE SET
731
790
  system_prompt = excluded.system_prompt,
791
+ daily_token_quota = excluded.daily_token_quota,
732
792
  auto_run_enabled = excluded.auto_run_enabled,
733
793
  auto_run_concurrency = excluded.auto_run_concurrency,
734
794
  auto_run_batch_limit = excluded.auto_run_batch_limit,
@@ -740,11 +800,14 @@ export class Store {
740
800
  auto_run_consecutive_failures = excluded.auto_run_consecutive_failures,
741
801
  book_summary_context_percent = excluded.book_summary_context_percent,
742
802
  context_compact_threshold = excluded.context_compact_threshold,
803
+ agent_tool_call_limit = excluded.agent_tool_call_limit,
804
+ agent_tool_call_global_multiplier = excluded.agent_tool_call_global_multiplier,
743
805
  agent_tools_json = excluded.agent_tools_json,
744
806
  title_generation_model_id = excluded.title_generation_model_id,
745
- 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);
807
+ 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);
746
808
  this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
747
809
  systemPromptChanged: input.systemPrompt !== undefined,
810
+ dailyTokenQuota: nextDailyTokenQuota,
748
811
  autoRunEnabled: nextEnabled,
749
812
  autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
750
813
  autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
@@ -752,6 +815,8 @@ export class Store {
752
815
  autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
753
816
  bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
754
817
  contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
818
+ agentToolCallLimit: Math.min(48, Math.max(5, nextAgentToolCallLimit)),
819
+ agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)),
755
820
  agentTools: nextAgentTools,
756
821
  titleGenerationModelId: nextTitleGenerationModelId
757
822
  });
@@ -856,12 +921,22 @@ export class Store {
856
921
  return this.getWork(workId);
857
922
  }
858
923
  getWorkCover(workId) {
924
+ const cover = this.findWorkCover(workId);
925
+ if (!cover)
926
+ throw notFound("作品封面");
927
+ return cover;
928
+ }
929
+ findWorkCover(workId) {
859
930
  this.getWork(workId);
860
931
  const row = this.db.get("SELECT * FROM work_covers WHERE work_id = ?", workId);
861
932
  if (!row)
862
- throw notFound("作品封面");
933
+ return null;
934
+ const mimeType = requiredString(row, "mime_type");
935
+ if (mimeType !== "image/jpeg" && mimeType !== "image/png" && mimeType !== "image/webp") {
936
+ throw new AppError(500, "INVALID_COVER_MIME", "作品封面类型无效");
937
+ }
863
938
  return {
864
- mimeType: requiredString(row, "mime_type"),
939
+ mimeType,
865
940
  content: Buffer.from(row.content),
866
941
  byteLength: numberValue(row, "byte_length"),
867
942
  sha256: requiredString(row, "sha256"),
@@ -1185,14 +1260,16 @@ export class Store {
1185
1260
  });
1186
1261
  }
1187
1262
  createChapter(workId, input) {
1188
- this.getWork(workId);
1189
- const volume = this.getVolume(input.volumeId);
1190
- if (volume.workId !== workId)
1191
- throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1192
- 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);
1193
- const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
1194
- this.audit(workId, "chapter.created", "chapter", chapterId);
1195
- return this.getChapter(chapterId);
1263
+ return this.db.transaction(() => {
1264
+ this.getWork(workId);
1265
+ const volume = this.getVolume(input.volumeId);
1266
+ if (volume.workId !== workId)
1267
+ throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
1268
+ 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);
1269
+ const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
1270
+ this.audit(workId, "chapter.created", "chapter", chapterId);
1271
+ return this.getChapter(chapterId);
1272
+ });
1196
1273
  }
1197
1274
  getChapter(chapterId) {
1198
1275
  const row = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
@@ -4283,11 +4360,12 @@ export class Store {
4283
4360
  const row = this.db.get("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC LIMIT 1", suggestionId);
4284
4361
  return row ? this.mapContinuationGuard(row) : null;
4285
4362
  }
4286
- createAiConversation(workId, title = "新对话") {
4363
+ createAiConversation(workId, title = "新对话", taskType = null) {
4287
4364
  this.getWork(workId);
4288
4365
  const conversationId = id("conversation");
4289
4366
  const timestamp = now();
4290
- 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);
4367
+ const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
4368
+ this.db.run("INSERT INTO ai_conversations (id, work_id, task_type, title, agent_tools_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", conversationId, workId, taskType, title.trim() || "新对话", JSON.stringify(agentTools), timestamp, timestamp, currentRequestActor()?.userId ?? null);
4291
4369
  return this.getAiConversation(conversationId);
4292
4370
  }
4293
4371
  listAiConversations(workId) {
@@ -4354,10 +4432,12 @@ export class Store {
4354
4432
  const compactedMessageCount = Math.min(rows.length, Math.max(0, numberValue(conversation, "compacted_message_count")));
4355
4433
  return {
4356
4434
  workId,
4435
+ roleplayCharacterId: optionalString(conversation, "roleplay_character_id"),
4357
4436
  summary: requiredString(conversation, "compacted_summary"),
4358
4437
  compactedMessageCount,
4359
4438
  totalMessageCount: rows.length,
4360
4439
  warningPending: Boolean(optionalString(conversation, "context_warning_at")),
4440
+ injectedEntities: parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES),
4361
4441
  messages: rows.slice(compactedMessageCount)
4362
4442
  .filter((message) => requiredString(message, "id") !== excludeMessageId)
4363
4443
  .map((message) => ({
@@ -4368,6 +4448,53 @@ export class Store {
4368
4448
  }))
4369
4449
  };
4370
4450
  }
4451
+ getAiConversationInjectedEntities(conversationId, workId) {
4452
+ const conversation = this.db.get("SELECT work_id, injected_entities_json FROM ai_conversations WHERE id = ?", conversationId);
4453
+ if (!conversation)
4454
+ throw notFound("AI 对话");
4455
+ if (requiredString(conversation, "work_id") !== workId)
4456
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4457
+ return parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES);
4458
+ }
4459
+ mergeAiConversationInjectedEntities(conversationId, workId, extra) {
4460
+ const conversation = this.db.get("SELECT work_id, injected_entities_json FROM ai_conversations WHERE id = ?", conversationId);
4461
+ if (!conversation)
4462
+ throw notFound("AI 对话");
4463
+ if (requiredString(conversation, "work_id") !== workId)
4464
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4465
+ const merged = mergeAiInjectedEntities(parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES), extra);
4466
+ this.db.run("UPDATE ai_conversations SET injected_entities_json = ?, updated_at = ? WHERE id = ?", JSON.stringify(merged), now(), conversationId);
4467
+ return merged;
4468
+ }
4469
+ /** 对话首轮写入 system 时钟文案;已有值则原样返回,禁止后续覆盖。 */
4470
+ ensureAiConversationSystemClock(conversationId, workId, candidate) {
4471
+ const conversation = this.db.get("SELECT work_id, system_clock_text FROM ai_conversations WHERE id = ?", conversationId);
4472
+ if (!conversation)
4473
+ throw notFound("AI 对话");
4474
+ if (requiredString(conversation, "work_id") !== workId)
4475
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4476
+ const existing = (optionalString(conversation, "system_clock_text") ?? "").trim();
4477
+ if (existing)
4478
+ return existing;
4479
+ const clock = candidate.trim();
4480
+ if (!clock)
4481
+ return "";
4482
+ this.db.run("UPDATE ai_conversations SET system_clock_text = ? WHERE id = ? AND TRIM(system_clock_text) = ''", clock, conversationId);
4483
+ const refreshed = this.db.get("SELECT system_clock_text FROM ai_conversations WHERE id = ?", conversationId);
4484
+ return (optionalString(refreshed ?? {}, "system_clock_text") ?? clock).trim() || clock;
4485
+ }
4486
+ listCharacterNameEntries(workId) {
4487
+ this.getWork(workId);
4488
+ return this.db.all(`SELECT character_id, normalized_name, display_name, kind FROM character_names
4489
+ WHERE work_id = ?
4490
+ AND character_id NOT IN (SELECT id FROM characters WHERE work_id = ? AND merged_into_character_id IS NOT NULL)
4491
+ ORDER BY LENGTH(normalized_name) DESC, sort_order ASC`, workId, workId).map((row) => ({
4492
+ characterId: requiredString(row, "character_id"),
4493
+ normalizedName: requiredString(row, "normalized_name"),
4494
+ displayName: requiredString(row, "display_name"),
4495
+ kind: requiredString(row, "kind") === "alias" ? "alias" : "primary"
4496
+ }));
4497
+ }
4371
4498
  getAiConversationTitleContext(conversationId, workId) {
4372
4499
  const conversation = this.db.get("SELECT title, work_id FROM ai_conversations WHERE id = ?", conversationId);
4373
4500
  if (!conversation)
@@ -4401,6 +4528,94 @@ export class Store {
4401
4528
  this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", normalizedTitle, now(), conversationId);
4402
4529
  return this.getAiConversation(conversationId);
4403
4530
  }
4531
+ setAiConversationRoleplayCharacter(conversationId, characterId) {
4532
+ const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4533
+ if (!conversation)
4534
+ throw notFound("AI 对话");
4535
+ const workId = requiredString(conversation, "work_id");
4536
+ const previousCharacterId = optionalString(conversation, "roleplay_character_id");
4537
+ if (previousCharacterId === characterId)
4538
+ return this.getAiConversationSummary(conversationId);
4539
+ const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
4540
+ if (messageCount > 0) {
4541
+ throw new AppError(409, previousCharacterId ? "ROLEPLAY_CHARACTER_LOCKED" : "ROLEPLAY_CONVERSATION_STARTED", previousCharacterId ? "角色扮演对话开始后不能退出模式或更换角色卡" : "当前对话已经开始,不能中途切换为角色扮演");
4542
+ }
4543
+ if (characterId) {
4544
+ const character = this.getCharacter(characterId);
4545
+ if (String(character.workId) !== workId) {
4546
+ throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色卡不属于当前作品");
4547
+ }
4548
+ if (character.mergedIntoCharacterId) {
4549
+ throw new AppError(409, "ROLEPLAY_CHARACTER_MERGED", "已合并角色不能用于角色扮演");
4550
+ }
4551
+ }
4552
+ this.db.transaction(() => {
4553
+ this.db.run("UPDATE ai_conversations SET roleplay_character_id = ?, task_type = CASE WHEN ? IS NOT NULL THEN 'roleplay' ELSE task_type END, updated_at = ? WHERE id = ?", characterId, characterId, now(), conversationId);
4554
+ this.audit(workId, "ai-conversation.roleplay-updated", "ai-conversation", conversationId, {
4555
+ previousCharacterId,
4556
+ characterId
4557
+ });
4558
+ });
4559
+ return this.getAiConversationSummary(conversationId);
4560
+ }
4561
+ setAiConversationTaskType(conversationId, taskType) {
4562
+ const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4563
+ if (!conversation)
4564
+ throw notFound("AI 对话");
4565
+ const workId = requiredString(conversation, "work_id");
4566
+ const previousCharacterId = optionalString(conversation, "roleplay_character_id");
4567
+ const previousTaskType = optionalString(conversation, "task_type") ?? (previousCharacterId ? "roleplay" : "chat");
4568
+ if (previousTaskType === taskType)
4569
+ return this.getAiConversationSummary(conversationId);
4570
+ const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
4571
+ if (messageCount > 0) {
4572
+ throw new AppError(409, "AI_CONVERSATION_TASK_LOCKED", "对话开始后不能切换任务类型");
4573
+ }
4574
+ this.db.transaction(() => {
4575
+ this.db.run("UPDATE ai_conversations SET task_type = ?, roleplay_character_id = CASE WHEN ? = 'roleplay' THEN roleplay_character_id ELSE NULL END, updated_at = ? WHERE id = ?", taskType, taskType, now(), conversationId);
4576
+ this.audit(workId, "ai-conversation.task-type-updated", "ai-conversation", conversationId, {
4577
+ previousTaskType,
4578
+ taskType
4579
+ });
4580
+ });
4581
+ return this.getAiConversationSummary(conversationId);
4582
+ }
4583
+ setAiConversationContextScope(conversationId, scope) {
4584
+ const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4585
+ if (!conversation)
4586
+ throw notFound("AI 对话");
4587
+ const workId = requiredString(conversation, "work_id");
4588
+ const assertWork = (record, code, label) => {
4589
+ if (String(record.workId) !== workId)
4590
+ throw new AppError(400, code, `${label}不属于当前作品`);
4591
+ };
4592
+ if (scope.chapterId)
4593
+ assertWork(this.getChapter(scope.chapterId), "CHAPTER_WORK_MISMATCH", "章节");
4594
+ if (scope.volumeId)
4595
+ assertWork(this.getVolume(scope.volumeId), "VOLUME_WORK_MISMATCH", "卷");
4596
+ for (const chapterId of scope.chapterIds ?? [])
4597
+ assertWork(this.getChapter(chapterId), "CHAPTER_WORK_MISMATCH", "章节");
4598
+ for (const characterId of scope.characterIds ?? [])
4599
+ assertWork(this.getCharacter(characterId), "CHARACTER_WORK_MISMATCH", "角色");
4600
+ for (const settingId of scope.settingIds ?? [])
4601
+ assertWork(this.getSetting(settingId), "SETTING_WORK_MISMATCH", "设定");
4602
+ const previousScope = json(optionalString(conversation, "context_scope_json") ?? "", { type: "none" });
4603
+ const serializedScope = JSON.stringify(scope);
4604
+ if (JSON.stringify(previousScope) === serializedScope)
4605
+ return this.getAiConversationSummary(conversationId);
4606
+ const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
4607
+ if (messageCount > 0) {
4608
+ throw new AppError(409, "AI_CONVERSATION_CONTEXT_LOCKED", "对话开始后不能切换上下文引用");
4609
+ }
4610
+ this.db.transaction(() => {
4611
+ this.db.run("UPDATE ai_conversations SET context_scope_json = ?, updated_at = ? WHERE id = ?", serializedScope, now(), conversationId);
4612
+ this.audit(workId, "ai-conversation.context-scope-updated", "ai-conversation", conversationId, {
4613
+ previousScope,
4614
+ scope
4615
+ });
4616
+ });
4617
+ return this.getAiConversationSummary(conversationId);
4618
+ }
4404
4619
  addAiConversationMessage(conversationId, input) {
4405
4620
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4406
4621
  if (!conversation)
@@ -4444,8 +4659,13 @@ export class Store {
4444
4659
  const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
4445
4660
  const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
4446
4661
  const forkSummary = forkCompactedCount ? requiredString(conversation, "compacted_summary") : "";
4662
+ const injectedEntitiesJson = optionalString(conversation, "injected_entities_json")
4663
+ ?? JSON.stringify(EMPTY_AI_INJECTED_ENTITIES);
4664
+ const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
4447
4665
  this.db.transaction(() => {
4448
- 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);
4666
+ this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
4667
+ ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(requiredString(conversation, "work_id")).agentTools))
4668
+ : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, timestamp, timestamp, currentRequestActor()?.userId ?? null);
4449
4669
  for (const message of messages.slice(0, targetIndex + 1)) {
4450
4670
  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);
4451
4671
  }
@@ -4453,6 +4673,10 @@ export class Store {
4453
4673
  return this.getAiConversation(forkId);
4454
4674
  }
4455
4675
  mapAiConversation(row) {
4676
+ const roleplayCharacterId = optionalString(row, "roleplay_character_id");
4677
+ const roleplayCharacter = roleplayCharacterId
4678
+ ? this.db.get("SELECT id, name, code FROM characters WHERE id = ? AND work_id = ?", roleplayCharacterId, requiredString(row, "work_id"))
4679
+ : undefined;
4456
4680
  return {
4457
4681
  id: requiredString(row, "id"),
4458
4682
  workId: requiredString(row, "work_id"),
@@ -4462,10 +4686,36 @@ export class Store {
4462
4686
  compactedMessageCount: numberValue(row, "compacted_message_count"),
4463
4687
  hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
4464
4688
  contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
4689
+ taskType: optionalString(row, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat"),
4690
+ contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
4691
+ roleplayCharacter: roleplayCharacter ? {
4692
+ id: requiredString(roleplayCharacter, "id"),
4693
+ name: requiredString(roleplayCharacter, "name"),
4694
+ code: requiredString(roleplayCharacter, "code")
4695
+ } : null,
4696
+ agentTools: row.agent_tools_json == null || row.agent_tools_json === undefined
4697
+ ? null
4698
+ : normalizeWorkAgentTools(row.agent_tools_json),
4465
4699
  createdAt: requiredString(row, "created_at"),
4466
4700
  updatedAt: requiredString(row, "updated_at")
4467
4701
  };
4468
4702
  }
4703
+ /** 锁定本对话可用工具集;已锁定则保持不变,避免中途改作品设置破坏 prompt cache。 */
4704
+ ensureAiConversationAgentTools(conversationId, workId) {
4705
+ const conversation = this.db.get("SELECT id, work_id, agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
4706
+ if (!conversation)
4707
+ throw notFound("AI 对话");
4708
+ if (requiredString(conversation, "work_id") !== workId) {
4709
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4710
+ }
4711
+ if (conversation.agent_tools_json != null && conversation.agent_tools_json !== undefined) {
4712
+ return normalizeWorkAgentTools(conversation.agent_tools_json);
4713
+ }
4714
+ const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
4715
+ 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);
4716
+ const locked = this.db.get("SELECT agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
4717
+ return normalizeWorkAgentTools(locked?.agent_tools_json ?? agentTools);
4718
+ }
4469
4719
  mapAiConversationMessage(row) {
4470
4720
  return {
4471
4721
  id: requiredString(row, "id"),
@@ -5757,6 +6007,9 @@ export class Store {
5757
6007
  if (scope.type === "settings") {
5758
6008
  return [{ type: "settings", title: "仅设定集" }];
5759
6009
  }
6010
+ if (scope.type === "settings-catalog") {
6011
+ return [{ type: "settings-catalog", title: "设定库" }];
6012
+ }
5760
6013
  if (scope.type === "selection" && typeof scope.selection === "string") {
5761
6014
  return [{ type: "selection", selection: scope.selection }];
5762
6015
  }
@@ -5869,6 +6122,22 @@ export class Store {
5869
6122
  }
5870
6123
  return lines.join("\n").trimEnd() + "\n";
5871
6124
  }
6125
+ async exportDocx(workId) {
6126
+ const tree = this.getWorkTree(workId);
6127
+ const cover = this.findWorkCover(workId);
6128
+ const volumes = tree.volumes.map((volume) => ({
6129
+ title: String(volume.title),
6130
+ chapters: volume.chapters.map((chapter) => ({
6131
+ title: String(chapter.title),
6132
+ content: String(chapter.content ?? "")
6133
+ }))
6134
+ }));
6135
+ return exportWorkDocx({
6136
+ title: String(tree.title),
6137
+ volumes,
6138
+ cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
6139
+ });
6140
+ }
5872
6141
  listAuditLogs(workId) {
5873
6142
  this.getWork(workId);
5874
6143
  return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username