@musnows/scriverse 0.6.3 → 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.
@@ -68,7 +68,8 @@ export function providerConnectionLabel(value) {
68
68
  export function providerProtocolLabel(value) {
69
69
  return enumLabel({
70
70
  "openai-chat-completions": "OpenAI Chat Completions",
71
- "anthropic-messages": "Anthropic Messages"
71
+ "anthropic-messages": "Anthropic Messages",
72
+ "google-vertex": "Google Vertex"
72
73
  }, value, "未知接口协议");
73
74
  }
74
75
 
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260801-merge-pr-291-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260801-ai-roleplay-chat-merge-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -306,6 +306,7 @@
306
306
  <div class="prompt-options">
307
307
  <select id="ai-task" aria-label="任务类型">
308
308
  <option value="chat">问答</option>
309
+ <option value="roleplay">角色扮演</option>
309
310
  <option value="continue">续写</option>
310
311
  <option value="polish">润色选中文本</option>
311
312
  </select>
@@ -315,7 +316,15 @@
315
316
  <option value="chapter-summary">当前章节 + 全书概要</option>
316
317
  <option value="volume">当前卷</option>
317
318
  <option value="book">全书</option>
319
+ <option value="settings-catalog">设定库</option>
318
320
  </select>
321
+ <select id="ai-roleplay-character" class="ai-roleplay-character hidden" aria-label="角色扮演角色卡">
322
+ <option value="">选择角色卡</option>
323
+ </select>
324
+ <label class="ai-include-setting-info" for="ai-include-setting-info">
325
+ <input id="ai-include-setting-info" type="checkbox" checked>
326
+ <span>注入设定信息</span>
327
+ </label>
319
328
  </div>
320
329
  <div class="field-label prompt-model-field">
321
330
  <select id="ai-model" aria-label="实际使用模型"></select>
@@ -657,6 +666,24 @@
657
666
  <option value="consolas">Consolas</option>
658
667
  </select>
659
668
  </label>
669
+ <label>界面字号
670
+ <select id="appearance-ui-font-size" name="uiFontSize">
671
+ <option value="14">14 px(小)</option>
672
+ <option value="15">15 px</option>
673
+ <option value="16">16 px(标准)</option>
674
+ <option value="17">17 px</option>
675
+ <option value="18">18 px(大)</option>
676
+ </select>
677
+ </label>
678
+ <label>Agent 对话字号
679
+ <select id="appearance-ai-font-size" name="aiFontSize">
680
+ <option value="12">12 px(小)</option>
681
+ <option value="13">13 px</option>
682
+ <option value="14">14 px(标准)</option>
683
+ <option value="15">15 px</option>
684
+ <option value="16">16 px(大)</option>
685
+ </select>
686
+ </label>
660
687
  <label>正文字号
661
688
  <select id="appearance-font-size" name="fontSize">
662
689
  <option value="15">15 px</option>
@@ -681,6 +708,7 @@
681
708
  <strong>字体预览 Typography 0123</strong>
682
709
  <span>中文默认使用黑体,English letters always use monospace.</span>
683
710
  <span>第二段用于预览正文的行距与段落密度。</span>
711
+ <span id="font-size-preview">界面 16 px · Agent 对话 14 px</span>
684
712
  </div>
685
713
  </div>
686
714
  <div class="dialog-actions">
@@ -927,6 +955,6 @@
927
955
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
928
956
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
929
957
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
930
- <script type="module" src="/app.js?v=20260801-merge-pr-291-v1"></script>
958
+ <script type="module" src="/app.js?v=20260801-ai-roleplay-chat-merge-v1"></script>
931
959
  </body>
932
960
  </html>
@@ -55,11 +55,16 @@
55
55
  --relationship-detail-muted: #67758c;
56
56
  --font-latin: "SFMono-Regular", "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono";
57
57
  --font-cjk: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC";
58
+ --ui-font-size: 16px;
59
+ --ui-font-scale: 1;
60
+ --ai-font-size: 14px;
61
+ --ai-font-scale: 1;
58
62
  --editor-font-size: 17px;
59
63
  --editor-line-height: 1.55;
60
64
  --left-panel-width: 280px;
61
65
  --ai-panel-width: 360px;
62
66
  font-family: var(--font-latin), var(--font-cjk), monospace, sans-serif;
67
+ font-size: var(--ui-font-size);
63
68
  line-height: 1.5;
64
69
  color: var(--ink);
65
70
  background: var(--paper);
@@ -2205,6 +2210,29 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2205
2210
  .ai-context-warning button:first-child:hover, .ai-context-warning button:first-child:focus-visible { background: var(--accent-dark); color: #fff; }
2206
2211
  .prompt-options { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 7px; }
2207
2212
  .prompt-options select { padding: 6px; color: var(--muted); font-size: 10px; }
2213
+ .prompt-options .ai-roleplay-character { min-width: 0; }
2214
+ .ai-panel.is-roleplaying .ai-roleplay-character { border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--accent-dark); }
2215
+ .prompt-options .ai-include-setting-info {
2216
+ grid-column: 1 / -1;
2217
+ display: inline-flex;
2218
+ align-items: center;
2219
+ gap: 6px;
2220
+ min-height: 28px;
2221
+ color: var(--muted);
2222
+ font-size: 10px;
2223
+ cursor: pointer;
2224
+ user-select: none;
2225
+ }
2226
+ .prompt-options .ai-include-setting-info input {
2227
+ width: 14px;
2228
+ height: 14px;
2229
+ margin: 0;
2230
+ accent-color: var(--accent);
2231
+ }
2232
+ .prompt-options .ai-include-setting-info:has(input:disabled) {
2233
+ opacity: 0.55;
2234
+ cursor: not-allowed;
2235
+ }
2208
2236
  .prompt-model-field { margin: 0 0 7px; }
2209
2237
  .prompt-model-field select { padding: 6px; color: var(--muted); font-size: 10px; }
2210
2238
  .prompt-composer { position: relative; }
@@ -3235,3 +3263,53 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3235
3263
  *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
3236
3264
  .dialog-submit-progress > span { width: 100%; transform: none; }
3237
3265
  }
3266
+
3267
+ /* 显示设置中的界面字号覆盖常用控件和辅助文字;正文与 Agent 对话分别独立调节。 */
3268
+ body { font-size: var(--ui-font-size); }
3269
+ :is(.module-nav button, .ghost-button, .primary-button, .module-header p, .eyebrow,
3270
+ .panel-heading, .field-label, .dialog-header-meta, .dialog-fields label,
3271
+ .dialog-fields .form-field-note, .appearance-grid label, .config-section-header p,
3272
+ .config-section .config-save-button, .font-preview span, .settings-hub-card small,
3273
+ .settings-hub-card strong, .shelf-header p, .topbar, .book-card small) {
3274
+ font-size: calc(1em * var(--ui-font-scale));
3275
+ }
3276
+ .appearance-grid select, .dialog-fields input, .dialog-fields textarea, .dialog-fields select { font-size: calc(13px * var(--ui-font-scale)); }
3277
+
3278
+ /* Agent 面板原有的细小字号统一以可读的对话字号为基准缩放。 */
3279
+ .ai-panel,
3280
+ .ai-history-dialog,
3281
+ .ai-tool-call-dialog { font-size: var(--ai-font-size); }
3282
+ .ai-heading-title strong { font-size: calc(14px * var(--ai-font-scale)); }
3283
+ .ai-heading-actions > span, .ai-heading-actions button { font-size: calc(9px * var(--ai-font-scale)); }
3284
+ .quick-actions button { font-size: calc(10px * var(--ai-font-scale)); }
3285
+ .ai-context-compaction-divider { font-size: calc(9px * var(--ai-font-scale)); }
3286
+ .assistant-message, .user-message { font-size: var(--ai-font-size); }
3287
+ .assistant-message > .message-heading, .user-message > .message-heading { font-size: calc(9px * var(--ai-font-scale)); }
3288
+ .ai-message-status, .message-card-actions button, .message-meta { font-size: calc(9px * var(--ai-font-scale)); }
3289
+ .message-body h1 { font-size: calc(17px * var(--ai-font-scale)); }
3290
+ .message-body h2 { font-size: calc(15px * var(--ai-font-scale)); }
3291
+ .message-body h3, .message-body h4 { font-size: calc(13px * var(--ai-font-scale)); }
3292
+ .message-body h5, .message-body h6 { font-size: calc(11px * var(--ai-font-scale)); }
3293
+ .message-body pre { font-size: calc(10px * var(--ai-font-scale)); }
3294
+ .message-body .markdown-image small { font-size: calc(9px * var(--ai-font-scale)); }
3295
+ .ai-process-details > summary, .ai-process-step-body, .ai-tool-call-summary { font-size: calc(10px * var(--ai-font-scale)); }
3296
+ .ai-process-details > summary > small, .ai-process-step > small { font-size: calc(9px * var(--ai-font-scale)); }
3297
+ .guard-card strong, .guard-card p, .message-actions button { font-size: calc(10px * var(--ai-font-scale)); }
3298
+ .ai-context-warning strong { font-size: calc(11px * var(--ai-font-scale)); }
3299
+ .ai-context-warning p, .ai-context-warning button { font-size: calc(9px * var(--ai-font-scale)); }
3300
+ .prompt-options select, .prompt-model-field select, .ai-mention-option strong, .ai-mention-empty { font-size: calc(10px * var(--ai-font-scale)); }
3301
+ .ai-mention-option small { font-size: calc(8px * var(--ai-font-scale)); }
3302
+ .ai-prompt { font-size: var(--ai-font-size); }
3303
+ .ai-prompt-reference { font-size: calc(10px * var(--ai-font-scale)); }
3304
+ .ai-context-meter b { font-size: calc(9px * var(--ai-font-scale)); }
3305
+ .ai-context-popover-header strong { font-size: calc(12px * var(--ai-font-scale)); }
3306
+ .ai-context-popover-header small, .ai-context-distribution-label { font-size: calc(10px * var(--ai-font-scale)); }
3307
+ .ai-context-distribution-label small, .ai-context-distribution-label strong { font-size: calc(9px * var(--ai-font-scale)); }
3308
+ .ai-send-button { font-size: calc(10px * var(--ai-font-scale)); }
3309
+ .ai-citation-main strong { font-size: calc(10px * var(--ai-font-scale)); }
3310
+ .ai-citation-main small, .ai-citation-main span, .message-citations span { font-size: calc(9px * var(--ai-font-scale)); }
3311
+ .ai-history-item strong { font-size: calc(14px * var(--ai-font-scale)); }
3312
+ .ai-history-item small { font-size: calc(10px * var(--ai-font-scale)); }
3313
+ .ai-tool-call-info dt, .ai-tool-call-detail h3 { font-size: calc(9px * var(--ai-font-scale)); }
3314
+ .ai-tool-call-info dd { font-size: calc(11px * var(--ai-font-scale)); }
3315
+ .ai-tool-call-detail pre { font-size: calc(10px * var(--ai-font-scale)); }
package/dist/store.js CHANGED
@@ -153,6 +153,7 @@ export const versionedEntityTypes = [
153
153
  "chapter-outline",
154
154
  "foreshadow"
155
155
  ];
156
+ export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
156
157
  export function defaultAiConversationTitle(prompt) {
157
158
  const normalized = prompt.replace(/\s+/gu, " ").trim();
158
159
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -231,6 +232,29 @@ function booleanValue(row, key) {
231
232
  export function normalizeCharacterName(value) {
232
233
  return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
233
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
+ }
234
258
  export class Store {
235
259
  db;
236
260
  constructor(db) {
@@ -4336,12 +4360,12 @@ export class Store {
4336
4360
  const row = this.db.get("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC LIMIT 1", suggestionId);
4337
4361
  return row ? this.mapContinuationGuard(row) : null;
4338
4362
  }
4339
- createAiConversation(workId, title = "新对话") {
4363
+ createAiConversation(workId, title = "新对话", taskType = null) {
4340
4364
  this.getWork(workId);
4341
4365
  const conversationId = id("conversation");
4342
4366
  const timestamp = now();
4343
4367
  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);
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);
4345
4369
  return this.getAiConversation(conversationId);
4346
4370
  }
4347
4371
  listAiConversations(workId) {
@@ -4408,10 +4432,12 @@ export class Store {
4408
4432
  const compactedMessageCount = Math.min(rows.length, Math.max(0, numberValue(conversation, "compacted_message_count")));
4409
4433
  return {
4410
4434
  workId,
4435
+ roleplayCharacterId: optionalString(conversation, "roleplay_character_id"),
4411
4436
  summary: requiredString(conversation, "compacted_summary"),
4412
4437
  compactedMessageCount,
4413
4438
  totalMessageCount: rows.length,
4414
4439
  warningPending: Boolean(optionalString(conversation, "context_warning_at")),
4440
+ injectedEntities: parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES),
4415
4441
  messages: rows.slice(compactedMessageCount)
4416
4442
  .filter((message) => requiredString(message, "id") !== excludeMessageId)
4417
4443
  .map((message) => ({
@@ -4422,6 +4448,53 @@ export class Store {
4422
4448
  }))
4423
4449
  };
4424
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
+ }
4425
4498
  getAiConversationTitleContext(conversationId, workId) {
4426
4499
  const conversation = this.db.get("SELECT title, work_id FROM ai_conversations WHERE id = ?", conversationId);
4427
4500
  if (!conversation)
@@ -4455,6 +4528,94 @@ export class Store {
4455
4528
  this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", normalizedTitle, now(), conversationId);
4456
4529
  return this.getAiConversation(conversationId);
4457
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
+ }
4458
4619
  addAiConversationMessage(conversationId, input) {
4459
4620
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4460
4621
  if (!conversation)
@@ -4498,10 +4659,13 @@ export class Store {
4498
4659
  const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
4499
4660
  const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
4500
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") ?? "";
4501
4665
  this.db.transaction(() => {
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
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
4503
4667
  ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(requiredString(conversation, "work_id")).agentTools))
4504
- : String(conversation.agent_tools_json), timestamp, timestamp, currentRequestActor()?.userId ?? null);
4668
+ : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, timestamp, timestamp, currentRequestActor()?.userId ?? null);
4505
4669
  for (const message of messages.slice(0, targetIndex + 1)) {
4506
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);
4507
4671
  }
@@ -4509,6 +4673,10 @@ export class Store {
4509
4673
  return this.getAiConversation(forkId);
4510
4674
  }
4511
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;
4512
4680
  return {
4513
4681
  id: requiredString(row, "id"),
4514
4682
  workId: requiredString(row, "work_id"),
@@ -4518,6 +4686,13 @@ export class Store {
4518
4686
  compactedMessageCount: numberValue(row, "compacted_message_count"),
4519
4687
  hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
4520
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,
4521
4696
  agentTools: row.agent_tools_json == null || row.agent_tools_json === undefined
4522
4697
  ? null
4523
4698
  : normalizeWorkAgentTools(row.agent_tools_json),
@@ -5832,6 +6007,9 @@ export class Store {
5832
6007
  if (scope.type === "settings") {
5833
6008
  return [{ type: "settings", title: "仅设定集" }];
5834
6009
  }
6010
+ if (scope.type === "settings-catalog") {
6011
+ return [{ type: "settings-catalog", title: "设定库" }];
6012
+ }
5835
6013
  if (scope.type === "selection" && typeof scope.selection === "string") {
5836
6014
  return [{ type: "selection", selection: scope.selection }];
5837
6015
  }