@musnows/scriverse 0.8.0 → 0.8.2

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.
@@ -1,4 +1,4 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
@@ -20,7 +20,7 @@ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_
20
20
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
21
21
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
22
22
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
23
- import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260815-ai-stream-typewriter-v4";
23
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
24
24
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
25
25
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
26
26
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
@@ -73,7 +73,7 @@ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelatio
73
73
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
74
74
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
75
75
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
76
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
76
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260818-annotation-permissions-v1";
77
77
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
78
78
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
79
79
  import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
@@ -178,6 +178,7 @@ const state = {
178
178
  aiConversationModelId: null,
179
179
  aiConversations: [],
180
180
  aiRoleplayCharacter: null,
181
+ aiRoleplayUserCharacter: null,
181
182
  aiLastMessageAt: null,
182
183
  settings: [],
183
184
  dirty: false,
@@ -230,6 +231,8 @@ let systemBootCheckTimer = null;
230
231
  let systemBootCheckPromise = null;
231
232
  let systemRestartDetected = false;
232
233
  let chapterAnnotations = [];
234
+ let chapterAnnotationCounts = new Map();
235
+ let chapterAnnotationsLineIndex = null;
233
236
  let workAuditRecords = [];
234
237
  let workAuditNextPage = null;
235
238
  const chapterBatchSelectedIds = new Set();
@@ -376,6 +379,13 @@ function canEditProse(work = state.work) {
376
379
  return canWriteUiModule(work, "editor");
377
380
  }
378
381
 
382
+ function canManageChapterAnnotation(annotation, work = state.work) {
383
+ if (!work) return false;
384
+ if (annotation.kind !== "todo") return canEditProse(work);
385
+ if (["admin", "owner"].includes(String(work.accessRole))) return true;
386
+ return annotation.createdByUserId === state.user?.userId && canWritePermissionModule(work, "todos");
387
+ }
388
+
379
389
  function canReplaceProse(work = state.work) {
380
390
  return WORK_PERMISSION_MODULES
381
391
  .filter((item) => item.id !== "ai-settings")
@@ -1334,9 +1344,9 @@ function applyPanelLayout(persist = false) {
1334
1344
  ? (panelLayout.leftCollapsed ? "打开作品模块" : "关闭作品模块")
1335
1345
  : (panelLayout.leftCollapsed ? "展开作品侧栏" : "收起作品侧栏"));
1336
1346
  $("#mobile-module-tab").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
1337
- $("#ai-panel-toggle").textContent = panelLayout.aiCollapsed ? "‹" : "›";
1347
+ $("#ai-panel-toggle").textContent = aiConversationWorkspaceOpen ? "×" : (panelLayout.aiCollapsed ? "‹" : "›");
1338
1348
  $("#ai-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.aiCollapsed));
1339
- $("#ai-panel-toggle").setAttribute("aria-label", panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手");
1349
+ $("#ai-panel-toggle").setAttribute("aria-label", aiConversationWorkspaceOpen ? "关闭创作助手全屏面板" : (panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手"));
1340
1350
  syncMobileAiPanelSafeTop();
1341
1351
  scheduleChapterLineNumbers();
1342
1352
  if (persist) {
@@ -1346,7 +1356,12 @@ function applyPanelLayout(persist = false) {
1346
1356
 
1347
1357
  function ensureAiPanelExpanded() {
1348
1358
  if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
1349
- setAiConversationWorkspaceVisible(true);
1359
+ if (isMobileViewport()) {
1360
+ setAiConversationWorkspaceVisible(true);
1361
+ return;
1362
+ }
1363
+ panelLayout.aiCollapsed = false;
1364
+ applyPanelLayout(true);
1350
1365
  }
1351
1366
 
1352
1367
  function setupPanelResize(handle, side) {
@@ -1496,7 +1511,7 @@ function applyChapterEditorMode() {
1496
1511
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
1497
1512
  $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
1498
1513
  $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1499
- $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
1514
+ $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
1500
1515
  $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1501
1516
  syncChapterSearchControls();
1502
1517
  if (viewOnly) cancelChapterAutoSave();
@@ -1788,22 +1803,36 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1788
1803
  );
1789
1804
  const numbers = document.createDocumentFragment();
1790
1805
  for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
1791
- const number = document.createElement("button");
1792
- number.type = "button";
1806
+ const number = document.createElement("div");
1793
1807
  number.className = "chapter-line-number";
1794
- number.textContent = String(index + 1);
1795
1808
  number.dataset.lineIndex = String(index);
1796
- number.setAttribute("aria-label", `选择第 ${index + 1} 行`);
1797
- number.tabIndex = -1;
1809
+ const lineSelect = document.createElement("button");
1810
+ lineSelect.type = "button";
1811
+ lineSelect.className = "chapter-line-number-select";
1812
+ lineSelect.textContent = String(index + 1);
1813
+ lineSelect.setAttribute("aria-label", `选择第 ${index + 1} 行`);
1814
+ lineSelect.tabIndex = -1;
1815
+ number.append(lineSelect);
1816
+ const annotationCount = chapterAnnotationCounts.get(index + 1) ?? 0;
1817
+ if (annotationCount > 0) {
1818
+ const bubble = document.createElement("button");
1819
+ bubble.type = "button";
1820
+ bubble.className = "chapter-line-annotation-count";
1821
+ bubble.dataset.lineIndex = String(index);
1822
+ bubble.dataset.lineAnnotationCount = String(annotationCount);
1823
+ bubble.textContent = String(annotationCount);
1824
+ bubble.setAttribute("aria-label", `查看第 ${index + 1} 行的 ${annotationCount} 条评论和待办`);
1825
+ number.append(bubble);
1826
+ }
1798
1827
  const selected = chapterLineSelection && index >= chapterLineSelection.start && index <= chapterLineSelection.end;
1799
1828
  if (selected) {
1800
1829
  number.classList.add("is-line-selected");
1801
- number.setAttribute("aria-pressed", "true");
1830
+ lineSelect.setAttribute("aria-pressed", "true");
1802
1831
  }
1803
1832
  const bounds = getLineBounds(index);
1804
1833
  number.style.top = `${bounds.top}px`;
1805
1834
  number.style.height = `${bounds.bottom - bounds.top}px`;
1806
- number.style.paddingTop = `${numberTextOffset}px`;
1835
+ lineSelect.style.paddingTop = `${numberTextOffset}px`;
1807
1836
  numbers.append(number);
1808
1837
  }
1809
1838
  inner.replaceChildren(numbers);
@@ -1879,7 +1908,7 @@ function paintChapterLineSelection(anchor, focus) {
1879
1908
  $("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number").forEach((row) => {
1880
1909
  const selected = Number(row.dataset.lineIndex) >= start && Number(row.dataset.lineIndex) <= end;
1881
1910
  row.classList.toggle("is-line-selected", selected);
1882
- row.setAttribute("aria-pressed", String(selected));
1911
+ row.querySelector(".chapter-line-number-select")?.setAttribute("aria-pressed", String(selected));
1883
1912
  });
1884
1913
  }
1885
1914
 
@@ -2012,6 +2041,7 @@ function createAiChatTabState(input = {}) {
2012
2041
  taskType: input.taskType ?? "chat",
2013
2042
  contextScope: input.contextScope ?? { type: "none" },
2014
2043
  roleplayCharacter: input.roleplayCharacter ?? null,
2044
+ roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2015
2045
  citations: input.citations ?? [],
2016
2046
  references: input.references ?? [],
2017
2047
  composer: input.composer ?? { text: "", citations: [], references: [] },
@@ -2039,6 +2069,7 @@ function persistActiveAiChatTab() {
2039
2069
  tab.taskType = state.aiTaskType;
2040
2070
  tab.contextScope = { ...state.aiContextScope };
2041
2071
  tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2072
+ tab.roleplayUserCharacter = state.aiRoleplayUserCharacter ? { ...state.aiRoleplayUserCharacter } : null;
2042
2073
  setAiChatTabComposerSnapshot(tab, captureAiPromptComposer());
2043
2074
  tab.contextUsage = latestAiContextUsage;
2044
2075
  tab.contextWarning = !$("#ai-context-warning").classList.contains("hidden");
@@ -2071,9 +2102,10 @@ function applyAiChatTabState(tab) {
2071
2102
  $("#ai-conversation-title").textContent = tab.title || "新对话";
2072
2103
  applyAiConversationTaskType(tab.taskType);
2073
2104
  applyAiConversationContextScope(tab.contextScope);
2074
- applyAiRoleplayCharacter(tab.roleplayCharacter);
2105
+ applyAiRoleplayCharacter(tab.roleplayCharacter, tab.roleplayUserCharacter);
2075
2106
  const selectedModelId = tab.modelId ?? tab.selectedModelId;
2076
2107
  if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
2108
+ syncAiModelPicker();
2077
2109
  setAiPromptText(tab.composer.text);
2078
2110
  renderAiCitations();
2079
2111
  renderAiReferences();
@@ -2187,20 +2219,21 @@ function setAiConversationSwitcherVisible(visible) {
2187
2219
  }
2188
2220
 
2189
2221
  function setAiConversationWorkspaceVisible(visible) {
2222
+ const mobileWorkspace = isMobileViewport();
2190
2223
  aiConversationWorkspaceOpen = Boolean(visible);
2191
- if (aiConversationWorkspaceOpen) {
2192
- panelLayout.aiCollapsed = false;
2193
- $("#app").classList.remove("ai-panel-collapsed");
2194
- }
2224
+ panelLayout.aiCollapsed = aiConversationWorkspaceOpen ? false : mobileWorkspace;
2195
2225
  $("#app").classList.toggle("ai-workspace-mode", aiConversationWorkspaceOpen);
2196
2226
  $(".ai-panel").classList.toggle("is-conversation-workspace", aiConversationWorkspaceOpen);
2197
2227
  $("#ai-assistant-entry").classList.toggle("active", aiConversationWorkspaceOpen);
2198
2228
  $("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
2229
+ applyPanelLayout(true);
2199
2230
  $("#ai-chat-tabs").classList.add("hidden");
2200
2231
  $("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
2201
2232
  $("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
2202
2233
  setAiConversationSwitcherVisible(false);
2203
2234
  renderAiChatTabs();
2235
+ $("#ai-panel-toggle").textContent = aiConversationWorkspaceOpen ? "×" : (panelLayout.aiCollapsed ? "‹" : "›");
2236
+ $("#ai-panel-toggle").setAttribute("aria-label", aiConversationWorkspaceOpen ? "关闭创作助手全屏面板" : (panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手"));
2204
2237
  if (aiConversationWorkspaceOpen) {
2205
2238
  window.requestAnimationFrame(() => $("#ai-prompt").focus({ preventScroll: true }));
2206
2239
  }
@@ -2311,7 +2344,7 @@ function resetAiChatTabs() {
2311
2344
  panels.replaceChildren();
2312
2345
  aiChatTabManager.reset();
2313
2346
  const tab = createAiChatTabState();
2314
- resetAiFeed(tab.feed, tab.roleplayCharacter);
2347
+ resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
2315
2348
  activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2316
2349
  }
2317
2350
 
@@ -2356,13 +2389,18 @@ function updateMessageCreatedAt(message, createdAt) {
2356
2389
  }
2357
2390
  }
2358
2391
 
2359
- function resetAiFeed(feed = $("#ai-feed"), roleplayCharacter = state.aiRoleplayCharacter) {
2392
+ function resetAiFeed(
2393
+ feed = $("#ai-feed"),
2394
+ roleplayCharacter = state.aiRoleplayCharacter,
2395
+ roleplayUserCharacter = state.aiRoleplayUserCharacter
2396
+ ) {
2360
2397
  const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
2361
2398
  if (tab) tab.lastMessageAt = null;
2362
2399
  if (isActiveAiChatTab(tab)) state.aiLastMessageAt = null;
2363
2400
  const roleplayName = roleplayCharacter?.name;
2401
+ const roleplayUserName = roleplayUserCharacter?.name;
2364
2402
  feed.innerHTML = roleplayName
2365
- ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
2403
+ ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我只能通过角色卡和与自己有关的记忆回答。"}</p></div></div>`
2366
2404
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2367
2405
  }
2368
2406
 
@@ -2371,6 +2409,10 @@ function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayChara
2371
2409
  return suffix ? `${name} · ${suffix}` : name;
2372
2410
  }
2373
2411
 
2412
+ function aiUserLabel(roleplayUserCharacter = state.aiRoleplayUserCharacter) {
2413
+ return roleplayUserCharacter?.name || "作者";
2414
+ }
2415
+
2374
2416
  function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "当前上下文已压缩", title = "" } = {}) {
2375
2417
  const divider = document.createElement("div");
2376
2418
  divider.className = "ai-context-compaction-divider";
@@ -2411,16 +2453,18 @@ function renderMessageCardActions(message) {
2411
2453
  message.append(actions);
2412
2454
  }
2413
2455
  actions.replaceChildren();
2414
- if (Object.hasOwn(message.dataset, "rawMarkdown")) {
2456
+ const hasCopyValue = Object.hasOwn(message.dataset, "rawMarkdown") || Object.hasOwn(message.dataset, "copyText");
2457
+ if (hasCopyValue) {
2415
2458
  const copy = document.createElement("button");
2416
2459
  copy.type = "button";
2417
2460
  copy.className = "message-copy-button";
2418
- copy.setAttribute("aria-label", "复制 AI 原始 Markdown");
2461
+ const isUserMessage = message.classList.contains("user-message");
2462
+ copy.setAttribute("aria-label", isUserMessage ? "复制用户指令" : "复制 AI 回复");
2419
2463
  copy.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>复制</span>';
2420
2464
  const copyLabel = copy.querySelector("span");
2421
2465
  copy.addEventListener("click", async () => {
2422
2466
  try {
2423
- await copyAiRawMarkdown(message.dataset.rawMarkdown);
2467
+ await copyAiRawMarkdown(message.dataset.rawMarkdown ?? message.dataset.copyText ?? "");
2424
2468
  copyLabel.textContent = "已复制";
2425
2469
  window.setTimeout(() => { copyLabel.textContent = "复制"; }, 1200);
2426
2470
  } catch (error) {
@@ -2470,6 +2514,11 @@ function attachAssistantCopyAction(message, rawMarkdown) {
2470
2514
  renderMessageCardActions(message);
2471
2515
  }
2472
2516
 
2517
+ function attachUserCopyAction(message, text) {
2518
+ message.dataset.copyText = String(text ?? "");
2519
+ renderMessageCardActions(message);
2520
+ }
2521
+
2473
2522
  function attachMessageIdentity(message, messageId) {
2474
2523
  if (!messageId) return;
2475
2524
  message.dataset.messageId = messageId;
@@ -2865,6 +2914,7 @@ function aiConversationHistoryMeta(conversation) {
2865
2914
  aiConversationContextScopeLabel(conversation.contextScope)
2866
2915
  ];
2867
2916
  if (conversation.roleplayCharacter?.name) parts.push(`扮演 ${conversation.roleplayCharacter.name}`);
2917
+ if (conversation.roleplayUserCharacter?.name) parts.push(`用户扮演 ${conversation.roleplayUserCharacter.name}`);
2868
2918
  parts.push(formatDateTime(conversation.updatedAt));
2869
2919
  return parts.join(" · ");
2870
2920
  }
@@ -3081,12 +3131,13 @@ function applyConversationToAiChatTab(tab, conversation) {
3081
3131
  tab.taskType = conversation.taskType ?? "chat";
3082
3132
  tab.contextScope = conversation.contextScope ?? { type: "none" };
3083
3133
  tab.roleplayCharacter = conversation.roleplayCharacter ?? null;
3134
+ tab.roleplayUserCharacter = conversation.roleplayUserCharacter ?? null;
3084
3135
  tab.citations = [];
3085
3136
  tab.references = [];
3086
3137
  tab.composer = { text: "", citations: [], references: [] };
3087
3138
  tab.contextUsage = null;
3088
3139
  tab.contextWarning = conversation.contextWarningPending === true;
3089
- resetAiFeed(tab.feed, tab.roleplayCharacter);
3140
+ resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
3090
3141
  for (const message of conversation.messages ?? []) {
3091
3142
  appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id, { tab });
3092
3143
  }
@@ -3161,10 +3212,65 @@ function renderAiRoleplayCharacterSelect() {
3161
3212
  ? aiConversationOptionLockedMessage
3162
3213
  : "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
3163
3214
  : "当前账户没有角色模块读取权限";
3215
+ renderAiRoleplayUserCharacterSelect();
3216
+ }
3217
+
3218
+ function renderAiRoleplayUserCharacterSelect() {
3219
+ const select = $("#ai-roleplay-user-character");
3220
+ const selectedId = String(state.aiRoleplayUserCharacter?.id ?? "");
3221
+ const aiCharacterId = String(state.aiRoleplayCharacter?.id ?? "");
3222
+ const availableCharacters = state.characters.filter((character) => (
3223
+ !character.mergedIntoCharacterId && String(character.id) !== aiCharacterId
3224
+ ));
3225
+ const options = [{ id: "", name: "选择我的角色(可选)" }, ...availableCharacters.map((character) => ({
3226
+ id: String(character.id),
3227
+ name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
3228
+ }))];
3229
+ if (selectedId && !options.some((option) => option.id === selectedId)) {
3230
+ options.push({ id: selectedId, name: String(state.aiRoleplayUserCharacter.name) });
3231
+ }
3232
+ select.replaceChildren(...options.map((option) => {
3233
+ const element = document.createElement("option");
3234
+ element.value = option.id;
3235
+ element.textContent = option.name;
3236
+ return element;
3237
+ }));
3238
+ select.value = selectedId;
3239
+ const canSelectCharacter = Boolean(state.work)
3240
+ && canReadModule("characters")
3241
+ && canWritePermissionModule(state.work, "ai-chat");
3242
+ select.disabled = aiInteractionBusy() || !canSelectCharacter || !state.aiRoleplayCharacter;
3243
+ select.title = !canSelectCharacter
3244
+ ? "当前账户没有角色模块读取权限"
3245
+ : !state.aiRoleplayCharacter
3246
+ ? "请先选择 AI 扮演的角色"
3247
+ : state.aiPromptSent
3248
+ ? aiConversationOptionLockedMessage
3249
+ : "选择后,AI 会将每条用户消息视为该角色的发言或行动";
3164
3250
  }
3165
3251
 
3166
3252
  const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
3167
3253
 
3254
+ function selectedAiModelLabel() {
3255
+ return $("#ai-model").selectedOptions[0]?.textContent?.trim() || "尚未选择模型";
3256
+ }
3257
+
3258
+ function syncAiModelPicker() {
3259
+ const select = $("#ai-model");
3260
+ const button = $("#ai-model-picker");
3261
+ const interactionBusy = aiInteractionBusy();
3262
+ const selectedLabel = selectedAiModelLabel();
3263
+ const label = state.aiPromptSent
3264
+ ? `当前模型:${selectedLabel}。${aiConversationOptionLockedMessage}`
3265
+ : `选择实际使用模型:${selectedLabel}`;
3266
+ select.disabled = interactionBusy;
3267
+ select.title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3268
+ button.disabled = interactionBusy;
3269
+ button.title = label;
3270
+ button.setAttribute("aria-label", label);
3271
+ if (interactionBusy) setAiModelPickerVisible(false);
3272
+ }
3273
+
3168
3274
  function notifyAiConversationOptionLocked(select) {
3169
3275
  if (!state.aiPromptSent) return false;
3170
3276
  const now = Date.now();
@@ -3188,19 +3294,21 @@ function blockLockedAiConversationOptionKeydown(event) {
3188
3294
 
3189
3295
  function syncAiTaskOptions() {
3190
3296
  const roleplaySelected = $("#ai-task").value === "roleplay";
3297
+ const relationshipRoleplaySelectable = roleplaySelected && Boolean(state.aiRoleplayCharacter);
3191
3298
  const interactionBusy = aiInteractionBusy();
3192
3299
  $("#ai-scope").classList.toggle("hidden", roleplaySelected);
3193
3300
  $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
3194
3301
  $("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
3195
3302
  $("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
3303
+ $("#ai-roleplay-user-character").classList.toggle("hidden", !relationshipRoleplaySelectable);
3304
+ $("#ai-roleplay-user-character").setAttribute("aria-hidden", String(!relationshipRoleplaySelectable));
3196
3305
  $("#ai-task").disabled = interactionBusy;
3197
3306
  $("#ai-task").title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3198
3307
  $("#ai-scope").disabled = interactionBusy || roleplaySelected;
3199
3308
  $("#ai-scope").title = state.aiPromptSent
3200
3309
  ? aiConversationOptionLockedMessage
3201
3310
  : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
3202
- $("#ai-model").disabled = interactionBusy;
3203
- $("#ai-model").title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3311
+ syncAiModelPicker();
3204
3312
  }
3205
3313
 
3206
3314
  function applyAiConversationTaskType(taskType) {
@@ -3226,15 +3334,19 @@ function applyAiConversationContextScope(scope) {
3226
3334
  syncAiTaskOptions();
3227
3335
  }
3228
3336
 
3229
- function applyAiRoleplayCharacter(character) {
3337
+ function applyAiRoleplayCharacter(character, userCharacter = null) {
3230
3338
  state.aiRoleplayCharacter = character?.id ? character : null;
3339
+ state.aiRoleplayUserCharacter = state.aiRoleplayCharacter && userCharacter?.id ? userCharacter : null;
3231
3340
  const tab = activeAiChatTab();
3232
3341
  if (tab) tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
3342
+ if (tab) tab.roleplayUserCharacter = state.aiRoleplayUserCharacter ? { ...state.aiRoleplayUserCharacter } : null;
3233
3343
  const active = Boolean(state.aiRoleplayCharacter);
3234
3344
  if (active) $("#ai-scope").value = "none";
3235
3345
  $(".ai-panel").classList.toggle("is-roleplaying", active);
3236
3346
  $("#ai-prompt").dataset.placeholder = active
3237
- ? `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
3347
+ ? state.aiRoleplayUserCharacter
3348
+ ? `以 ${String(state.aiRoleplayUserCharacter.name)} 的身份与 ${String(state.aiRoleplayCharacter.name)} 对话……`
3349
+ : `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
3238
3350
  : "告诉 AI 你想讨论或修改什么……";
3239
3351
  renderAiRoleplayCharacterSelect();
3240
3352
  syncAiTaskOptions();
@@ -3245,22 +3357,27 @@ function refreshAiMessageRoleLabels() {
3245
3357
  $("#ai-feed").querySelectorAll(".assistant-message .message-heading > span").forEach((label) => {
3246
3358
  label.textContent = aiAssistantLabel();
3247
3359
  });
3360
+ $("#ai-feed").querySelectorAll(".user-message .message-heading > span").forEach((label) => {
3361
+ label.textContent = aiUserLabel();
3362
+ });
3248
3363
  }
3249
3364
 
3250
- async function updateAiRoleplayCharacter(characterId) {
3365
+ async function updateAiRoleplayCharacter(characterId, userCharacterId = null) {
3251
3366
  const conversationId = await ensureAiConversation();
3252
3367
  const conversation = await api(`/api/ai-conversations/${conversationId}/roleplay`, {
3253
3368
  method: "PATCH",
3254
- body: { characterId: characterId || null }
3369
+ body: { characterId: characterId || null, userCharacterId: userCharacterId || null }
3255
3370
  });
3256
3371
  upsertAiConversationSummary(conversation);
3257
3372
  applyAiConversationTaskType(conversation.taskType);
3258
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
3373
+ applyAiRoleplayCharacter(conversation.roleplayCharacter, conversation.roleplayUserCharacter);
3259
3374
  resetAiContextMeter();
3260
3375
  if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
3261
3376
  else resetAiFeed();
3262
3377
  toast(conversation.roleplayCharacter
3263
- ? `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
3378
+ ? conversation.roleplayUserCharacter
3379
+ ? `已进入 ${conversation.roleplayCharacter.name} 与 ${conversation.roleplayUserCharacter.name} 的关系扮演模式`
3380
+ : `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
3264
3381
  : "已退出角色扮演模式");
3265
3382
  }
3266
3383
 
@@ -3278,7 +3395,7 @@ async function persistAiConversationTaskType(taskType) {
3278
3395
  });
3279
3396
  upsertAiConversationSummary(conversation);
3280
3397
  applyAiConversationTaskType(conversation.taskType);
3281
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
3398
+ applyAiRoleplayCharacter(conversation.roleplayCharacter, conversation.roleplayUserCharacter);
3282
3399
  return conversation;
3283
3400
  }
3284
3401
 
@@ -3318,9 +3435,10 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
3318
3435
  upsertAiConversationSummary(taskConversation);
3319
3436
  tab.taskType = taskConversation.taskType;
3320
3437
  tab.roleplayCharacter = taskConversation.roleplayCharacter ?? null;
3438
+ tab.roleplayUserCharacter = taskConversation.roleplayUserCharacter ?? null;
3321
3439
  if (isActiveAiChatTab(tab)) {
3322
3440
  applyAiConversationTaskType(tab.taskType);
3323
- applyAiRoleplayCharacter(tab.roleplayCharacter);
3441
+ applyAiRoleplayCharacter(tab.roleplayCharacter, tab.roleplayUserCharacter);
3324
3442
  }
3325
3443
 
3326
3444
  const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
@@ -3576,7 +3694,8 @@ function addSelectedLinesAsCitation() {
3576
3694
  }
3577
3695
 
3578
3696
  async function createSelectedLineAnnotation(kind) {
3579
- if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
3697
+ const permissionModule = kind === "todo" ? "todos" : "comments";
3698
+ if (!state.chapter || !chapterLineSelection || !canWritePermissionModule(state.work, permissionModule)) return;
3580
3699
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
3581
3700
  closeLineCitationMenu();
3582
3701
  const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
@@ -3592,7 +3711,8 @@ async function createSelectedLineAnnotation(kind) {
3592
3711
  body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
3593
3712
  });
3594
3713
  toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
3595
- await openChapterAnnotationsDialog();
3714
+ await loadChapterAnnotationCounts();
3715
+ await openChapterAnnotationsDialog(selection.safeStart);
3596
3716
  } catch (error) {
3597
3717
  toast(error.message, "error");
3598
3718
  }
@@ -3620,7 +3740,7 @@ function chapterAnnotationCard(annotation, { showSource = false } = {}) {
3620
3740
  <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
3621
3741
  <p data-annotation-content>${esc(annotation.note)}</p>
3622
3742
  <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
3623
- <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
3743
+ <footer><button type="button" data-annotation-locate>定位原文</button>${canManageChapterAnnotation(annotation) ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
3624
3744
  </article>`;
3625
3745
  }
3626
3746
 
@@ -3680,9 +3800,12 @@ function renderChapterAnnotations() {
3680
3800
  const host = $("#chapter-annotations-list");
3681
3801
  host.innerHTML = chapterAnnotations.length
3682
3802
  ? chapterAnnotations.map((annotation) => chapterAnnotationCard(annotation)).join("")
3683
- : '<p class="entity-history-empty">本章还没有评论。在正文行上点击右键即可添加。</p>';
3803
+ : `<p class="entity-history-empty">${chapterAnnotationsLineIndex === null ? "本章还没有评论。在正文行上点击右键即可添加。" : `第 ${chapterAnnotationsLineIndex + 1} 行还没有评论或待办。`}</p>`;
3684
3804
  bindChapterAnnotationCards(host, chapterAnnotations, {
3685
- refresh: loadChapterAnnotations,
3805
+ refresh: async () => {
3806
+ await loadChapterAnnotationCounts();
3807
+ await loadChapterAnnotations(chapterAnnotationsLineIndex);
3808
+ },
3686
3809
  locate: (annotation) => {
3687
3810
  $("#chapter-annotations-dialog").close();
3688
3811
  paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
@@ -3692,19 +3815,43 @@ function renderChapterAnnotations() {
3692
3815
  });
3693
3816
  }
3694
3817
 
3695
- async function loadChapterAnnotations() {
3818
+ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
3819
+ if (!chapterId || !canReadModule("comments") && !canReadModule("todos")) {
3820
+ chapterAnnotationCounts = new Map();
3821
+ scheduleChapterLineNumbers();
3822
+ return;
3823
+ }
3824
+ const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
3825
+ if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
3826
+ chapterAnnotationCounts = new Map(
3827
+ (Array.isArray(counts) ? counts : [])
3828
+ .map((item) => [Number(item.line), Number(item.count)])
3829
+ .filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
3830
+ );
3831
+ scheduleChapterLineNumbers();
3832
+ }
3833
+
3834
+ async function loadChapterAnnotations(lineIndex = chapterAnnotationsLineIndex) {
3696
3835
  if (!state.chapter) return;
3697
- chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
3836
+ const chapterId = state.chapter.id;
3837
+ const line = Number.isInteger(lineIndex) && lineIndex >= 0 ? lineIndex + 1 : null;
3838
+ const query = line === null ? "" : `?line=${encodeURIComponent(line)}`;
3839
+ const annotations = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotations${query}`);
3840
+ if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
3841
+ chapterAnnotationsLineIndex = line === null ? null : line - 1;
3842
+ chapterAnnotations = annotations;
3698
3843
  renderChapterAnnotations();
3699
3844
  }
3700
3845
 
3701
- async function openChapterAnnotationsDialog() {
3846
+ async function openChapterAnnotationsDialog(lineIndex = null) {
3702
3847
  if (!state.chapter) return;
3703
- $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文评论`;
3848
+ chapterAnnotationsLineIndex = Number.isInteger(lineIndex) && lineIndex >= 0 ? lineIndex : null;
3849
+ const lineLabel = chapterAnnotationsLineIndex === null ? "" : `第 ${chapterAnnotationsLineIndex + 1} 行的`;
3850
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》${lineLabel}正文评论与待办`;
3704
3851
  $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载评论…</p>';
3705
3852
  if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
3706
3853
  try {
3707
- await loadChapterAnnotations();
3854
+ await loadChapterAnnotations(chapterAnnotationsLineIndex);
3708
3855
  } catch (error) {
3709
3856
  $("#chapter-annotations-dialog").close();
3710
3857
  toast(error.message, "error");
@@ -3722,8 +3869,8 @@ function showLineCitationMenu(event, lineIndex) {
3722
3869
  selectChapterLines(lineIndex, lineIndex);
3723
3870
  }
3724
3871
  const menu = $("#line-citation-menu");
3725
- $("#add-line-annotation").classList.toggle("hidden", !canEditProse());
3726
- $("#add-line-todo").classList.toggle("hidden", !canEditProse());
3872
+ $("#add-line-annotation").classList.toggle("hidden", !canWritePermissionModule(state.work, "comments"));
3873
+ $("#add-line-todo").classList.toggle("hidden", !canWritePermissionModule(state.work, "todos"));
3727
3874
  const { start, end } = chapterLineSelection;
3728
3875
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
3729
3876
  menu.classList.remove("hidden");
@@ -3736,7 +3883,7 @@ function clearChapterLineSelection() {
3736
3883
  chapterLineSelection = null;
3737
3884
  $("#chapter-line-numbers-inner")?.querySelectorAll(".is-line-selected").forEach((row) => {
3738
3885
  row.classList.remove("is-line-selected");
3739
- row.setAttribute("aria-pressed", "false");
3886
+ row.querySelector(".chapter-line-number-select")?.setAttribute("aria-pressed", "false");
3740
3887
  });
3741
3888
  }
3742
3889
 
@@ -5860,7 +6007,7 @@ function renderMemberPermissionGrid(value) {
5860
6007
  <select data-member-permission="${esc(item.id)}" aria-label="${esc(item.label)}权限">
5861
6008
  <option value="none" ${permissions[item.id] === "none" ? "selected" : ""}>无权限</option>
5862
6009
  <option value="read" ${permissions[item.id] === "read" ? "selected" : ""}>只读</option>
5863
- <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>可编辑</option>
6010
+ <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>${["comments", "todos"].includes(item.id) ? "可添加" : "可编辑"}</option>
5864
6011
  </select>
5865
6012
  </label>`).join("");
5866
6013
  }
@@ -6458,6 +6605,7 @@ function resetWorkScopedUiCaches() {
6458
6605
  state.characters = [];
6459
6606
  state.settings = [];
6460
6607
  state.races = [];
6608
+ state.organizations = [];
6461
6609
  characterListPage = 1;
6462
6610
  draftTypeFilter = "all";
6463
6611
  draftBindingFilters = [];
@@ -6488,6 +6636,8 @@ function resetWorkScopedUiCaches() {
6488
6636
  state.aiConversations = [];
6489
6637
  resetAiChatTabs();
6490
6638
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
6639
+ syncAiModelPicker();
6640
+ setAiModelPickerVisible(false);
6491
6641
  renderAiConversationHistory();
6492
6642
  }
6493
6643
 
@@ -7174,8 +7324,12 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
7174
7324
  const normalizedContent = normalizeParagraphSpacing(state.chapter.content);
7175
7325
  const spacingChanged = normalizedContent !== state.chapter.content;
7176
7326
  $("#chapter-content").value = normalizedContent;
7327
+ chapterAnnotationCounts = new Map();
7177
7328
  clearChapterLineSelection();
7178
7329
  scheduleChapterLineNumbers();
7330
+ void loadChapterAnnotationCounts(state.chapter.id).catch((error) => {
7331
+ if (String(state.chapter?.id ?? "") === String(chapterId)) toast("正文评论数量读取失败,请稍后重试", "error");
7332
+ });
7179
7333
  dismissChapterInsightToast();
7180
7334
  updateChapterStats();
7181
7335
  if (!canEditProse()) setSaveState("正文只读");
@@ -7711,7 +7865,7 @@ const moduleMeta = {
7711
7865
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
7712
7866
  outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
7713
7867
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
7714
- comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
7868
+ comments: ["正文协作", "正文评论与待办", "集中查看并处理当前作品所有章节的评论与待办。", ""],
7715
7869
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
7716
7870
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
7717
7871
  "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
@@ -9281,8 +9435,8 @@ async function renderWorkChapterComments(page = moduleListPages.comments) {
9281
9435
  moduleListPages.comments = pageResult.page;
9282
9436
  mountModuleCount(total);
9283
9437
  $("#module-content").innerHTML = result.items.length
9284
- ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论列表")}`
9285
- : emptyModule("还没有正文评论", "在任一正文行上点击右键,即可添加评论或待办。");
9438
+ ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
9439
+ : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
9286
9440
  bindModulePagination("comments", renderWorkChapterComments);
9287
9441
  bindChapterAnnotationCards($("#module-content"), result.items, {
9288
9442
  refresh: () => renderWorkChapterComments(pageResult.page),
@@ -11325,12 +11479,16 @@ async function ensureAiModelsLoaded() {
11325
11479
  if (aiModelsLoadPromise && aiModelsLoadWorkId === workId) return aiModelsLoadPromise;
11326
11480
  const select = $("#ai-model");
11327
11481
  select.innerHTML = '<option value="">正在加载模型……</option>';
11482
+ syncAiModelPicker();
11328
11483
  aiModelsLoadWorkId = workId;
11329
11484
  aiModelsLoadPromise = loadModels();
11330
11485
  try {
11331
11486
  await aiModelsLoadPromise;
11332
11487
  } catch (error) {
11333
- if (state.work?.id === workId) select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
11488
+ if (state.work?.id === workId) {
11489
+ select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
11490
+ syncAiModelPicker();
11491
+ }
11334
11492
  throw error;
11335
11493
  } finally {
11336
11494
  if (aiModelsLoadWorkId === workId) {
@@ -11465,6 +11623,14 @@ function setAiContextDistributionVisible(visible) {
11465
11623
  if (!visible && document.activeElement === $("#ai-context-popover-close")) meter.focus();
11466
11624
  }
11467
11625
 
11626
+ function setAiModelPickerVisible(visible) {
11627
+ const button = $("#ai-model-picker");
11628
+ const popover = $("#ai-model-popover");
11629
+ popover.classList.toggle("hidden", !visible);
11630
+ button.setAttribute("aria-expanded", String(visible));
11631
+ if (!visible && document.activeElement === $("#ai-model")) button.focus();
11632
+ }
11633
+
11468
11634
  function showAiContextWarning(usage = null) {
11469
11635
  $("#ai-context-warning-title").textContent = "对话上下文过长";
11470
11636
  $("#ai-context-warning-message").textContent = "当前对话上下文过长,是否进行压缩?不压缩可能会导致后续请求失败";
@@ -11491,13 +11657,17 @@ async function loadAiReferences() {
11491
11657
  const workId = state.work?.id;
11492
11658
  if (!workId) return;
11493
11659
  const generation = workScopedUiGeneration;
11494
- const [characters, settings] = await Promise.all([
11660
+ const [characters, settings, races, organizations] = await Promise.all([
11495
11661
  canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
11496
- canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([])
11662
+ canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([]),
11663
+ canReadModule("races") ? api(`/api/works/${workId}/races`) : Promise.resolve([]),
11664
+ canReadModule("organizations") ? apiAllPages(`/api/works/${workId}/organizations`) : Promise.resolve([])
11497
11665
  ]);
11498
11666
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
11499
11667
  state.characters = characters;
11500
11668
  state.settings = settings;
11669
+ state.races = races;
11670
+ state.organizations = organizations;
11501
11671
  renderAiRoleplayCharacterSelect();
11502
11672
  loadedAiReferencesWorkId = workId;
11503
11673
  }
@@ -14629,6 +14799,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14629
14799
  try {
14630
14800
  try {
14631
14801
  await ensureAiModelsLoaded();
14802
+ await ensureAiReferencesLoaded();
14632
14803
  } catch (error) {
14633
14804
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
14634
14805
  setAiChatTabStatus(tab, "error");
@@ -14876,6 +15047,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14876
15047
  }
14877
15048
  });
14878
15049
  let streamedText = "";
15050
+ let streamedPendingText = "";
14879
15051
  let generatedMetadata = {};
14880
15052
  let toolCalls = [];
14881
15053
  let processSteps = [];
@@ -14886,7 +15058,6 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14886
15058
  let contextAction = "ready";
14887
15059
  let warningOnly = false;
14888
15060
  let streamContextCompacted = false;
14889
- let finalAnswerStarted = false;
14890
15061
  const processStartedAt = Date.now();
14891
15062
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
14892
15063
  const processStepTypewriters = new Map();
@@ -14904,7 +15075,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14904
15075
  onRender: (text) => {
14905
15076
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
14906
15077
  processStepVisibleContents.set(step, text);
14907
- renderStreamingProcessSteps(finalAnswerStarted);
15078
+ renderStreamingProcessSteps(false);
14908
15079
  scrollAiFeedToBottom(feed);
14909
15080
  }
14910
15081
  });
@@ -14977,18 +15148,21 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14977
15148
  }
14978
15149
  } else if (eventName === "delta") {
14979
15150
  mountAssistantMessage();
14980
- const firstFinalDelta = streamedText.length === 0;
14981
15151
  const delta = typeof payload.delta === "string" ? payload.delta : "";
14982
15152
  streamedText += delta;
14983
- if (streamedText.length > 0) finalAnswerStarted = true;
15153
+ streamedPendingText += delta;
14984
15154
  typewriter.append(delta);
14985
- if (firstFinalDelta && processSteps.length) renderStreamingProcessSteps(true, elapsedProcessTime());
14986
15155
  meta.textContent = "正在生成回复……";
14987
15156
  } else if (eventName === "process_step") {
14988
15157
  mountAssistantMessage();
14989
15158
  const step = { ...payload };
14990
15159
  const append = step.append === true;
14991
15160
  delete step.append;
15161
+ if (step.type === "intermediate" && typeof step.content === "string" && streamedPendingText.endsWith(step.content)) {
15162
+ streamedPendingText = streamedPendingText.slice(0, -step.content.length);
15163
+ streamedText = streamedText.slice(0, -step.content.length);
15164
+ typewriter.replace(streamedText);
15165
+ }
14992
15166
  const existing = append ? processSteps.find((item) => item.id === step.id && item.type === step.type) : null;
14993
15167
  if (existing && typeof step.content === "string") existing.content += step.content;
14994
15168
  else processSteps.push(step);
@@ -14996,7 +15170,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14996
15170
  if (typeof step.content === "string" && step.content.length > 0 && step.type === "thinking") {
14997
15171
  processStepTypewriter(targetStep).append(step.content);
14998
15172
  }
14999
- renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
15173
+ renderStreamingProcessSteps(false, elapsedProcessTime());
15000
15174
  meta.textContent = step.type === "thinking"
15001
15175
  ? `正在思考 · 第 ${Number(step.round) || 1} 轮`
15002
15176
  : step.type === "context_compaction"
@@ -15011,7 +15185,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15011
15185
  if (toolCall.status === "failed") setAiChatTabStatus(tab, "error");
15012
15186
  toolCalls.push(toolCall);
15013
15187
  processSteps.push(aiToolProcessStep(toolCall, round));
15014
- renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
15188
+ renderStreamingProcessSteps(false, elapsedProcessTime());
15015
15189
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
15016
15190
  scrollAiFeedToBottom(feed);
15017
15191
  } else if (eventName === "context_compacted") {
@@ -15123,7 +15297,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15123
15297
  });
15124
15298
  const heading = attachMessageHeading(
15125
15299
  message,
15126
- role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15300
+ role === "user"
15301
+ ? aiUserLabel(tab?.roleplayUserCharacter)
15302
+ : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15127
15303
  createdAt ?? undefined,
15128
15304
  tab
15129
15305
  );
@@ -15135,20 +15311,27 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15135
15311
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
15136
15312
  heading.firstElementChild?.append(failureBadge);
15137
15313
  }
15138
- const mentionNames = role === "user"
15139
- ? userMessageMentionNames(metadata?.mentionCharacterIds, state.characters)
15314
+ const mentionGroups = role === "user"
15315
+ ? [
15316
+ ["角色", metadata?.mentionCharacterIds, state.characters],
15317
+ ["种族", metadata?.mentionRaceIds, state.races],
15318
+ ["组织", metadata?.mentionOrganizationIds, state.organizations]
15319
+ ]
15320
+ .flatMap(([kind, ids, items]) => userMessageMentionNames(ids, items).map((name) => ({ kind, name })))
15140
15321
  : [];
15141
- if (mentionNames.length) {
15322
+ if (mentionGroups.length) {
15142
15323
  const references = document.createElement("div");
15143
15324
  references.className = "user-message-mentions";
15144
15325
  const label = document.createElement("span");
15145
15326
  label.className = "user-message-mentions-label";
15146
- label.textContent = "引用角色";
15327
+ label.textContent = "引用";
15147
15328
  references.append(label);
15148
- for (const name of mentionNames) {
15329
+ for (const { kind, name } of mentionGroups) {
15149
15330
  const reference = document.createElement("span");
15150
15331
  reference.className = "user-message-mention";
15151
15332
  reference.textContent = name;
15333
+ reference.title = `${kind}:${name}`;
15334
+ reference.setAttribute("aria-label", `${kind}:${name}`);
15152
15335
  references.append(reference);
15153
15336
  }
15154
15337
  message.append(references);
@@ -15174,6 +15357,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15174
15357
  if (role === "assistant") {
15175
15358
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
15176
15359
  }
15360
+ if (role === "user") attachUserCopyAction(message, text);
15177
15361
  if (role === "assistant" && !text.startsWith("调用失败:")) {
15178
15362
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
15179
15363
  const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
@@ -16648,6 +16832,7 @@ $("#chapter-content").addEventListener("contextmenu", (event) => {
16648
16832
  showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
16649
16833
  });
16650
16834
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
16835
+ if (event.target.closest(".chapter-line-annotation-count")) return;
16651
16836
  const row = event.target.closest(".chapter-line-number");
16652
16837
  if (!row || event.button !== 0) return;
16653
16838
  event.preventDefault();
@@ -16670,7 +16855,23 @@ const finishChapterLineDrag = (event) => {
16670
16855
  };
16671
16856
  $("#chapter-line-numbers-inner").addEventListener("pointerup", finishChapterLineDrag);
16672
16857
  $("#chapter-line-numbers-inner").addEventListener("pointercancel", finishChapterLineDrag);
16858
+ $("#chapter-line-numbers-inner").addEventListener("click", (event) => {
16859
+ const bubble = event.target.closest(".chapter-line-annotation-count");
16860
+ if (bubble) {
16861
+ event.preventDefault();
16862
+ event.stopPropagation();
16863
+ openChapterAnnotationsDialog(Number(bubble.dataset.lineIndex)).catch((error) => toast(error.message, "error"));
16864
+ return;
16865
+ }
16866
+ const lineSelect = event.target.closest(".chapter-line-number-select");
16867
+ if (!lineSelect) return;
16868
+ const lineIndex = Number(lineSelect.closest(".chapter-line-number")?.dataset.lineIndex);
16869
+ if (!Number.isInteger(lineIndex)) return;
16870
+ paintChapterLineSelection(lineIndex, lineIndex);
16871
+ selectChapterLines(lineIndex, lineIndex);
16872
+ });
16673
16873
  $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
16874
+ if (event.target.closest(".chapter-line-annotation-count")) return;
16674
16875
  const row = event.target.closest(".chapter-line-number");
16675
16876
  if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
16676
16877
  });
@@ -16680,7 +16881,10 @@ $("#add-line-todo").addEventListener("click", () => createSelectedLineAnnotation
16680
16881
  $("#chapter-annotations-button").addEventListener("click", () => openChapterAnnotationsDialog().catch((error) => toast(error.message, "error")));
16681
16882
  $("#chapter-annotations-close").addEventListener("click", () => $("#chapter-annotations-dialog").close());
16682
16883
  $("#chapter-annotations-done").addEventListener("click", () => $("#chapter-annotations-dialog").close());
16683
- $("#chapter-annotations-refresh").addEventListener("click", () => loadChapterAnnotations().catch((error) => toast(error.message, "error")));
16884
+ $("#chapter-annotations-refresh").addEventListener("click", () => Promise.all([
16885
+ loadChapterAnnotationCounts(),
16886
+ loadChapterAnnotations(chapterAnnotationsLineIndex)
16887
+ ]).catch((error) => toast(error.message, "error")));
16684
16888
  $("#left-panel-toggle").addEventListener("click", () => {
16685
16889
  panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
16686
16890
  applyPanelLayout(true);
@@ -16718,6 +16922,8 @@ $("#ai-assistant-entry").addEventListener("click", () => {
16718
16922
  if (isMobileViewport()) {
16719
16923
  panelLayout.leftCollapsed = true;
16720
16924
  applyPanelLayout(true);
16925
+ setAiConversationWorkspaceVisible(true);
16926
+ return;
16721
16927
  }
16722
16928
  setAiConversationWorkspaceVisible(true);
16723
16929
  });
@@ -16755,7 +16961,7 @@ $("#ai-prompt").addEventListener("input", async () => {
16755
16961
  $("#ai-prompt").addEventListener("focus", () => {
16756
16962
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
16757
16963
  });
16758
- for (const select of [$("#ai-task"), $("#ai-scope"), $("#ai-roleplay-character"), $("#ai-model")]) {
16964
+ for (const select of [$("#ai-task"), $("#ai-scope"), $("#ai-roleplay-character"), $("#ai-roleplay-user-character"), $("#ai-model")]) {
16759
16965
  select.addEventListener("pointerdown", blockLockedAiConversationOptionInteraction);
16760
16966
  select.addEventListener("click", blockLockedAiConversationOptionInteraction);
16761
16967
  select.addEventListener("keydown", blockLockedAiConversationOptionKeydown);
@@ -16766,9 +16972,26 @@ $("#ai-model").addEventListener("focus", () => {
16766
16972
  $("#ai-model").addEventListener("change", (event) => {
16767
16973
  if (state.aiPromptSent) {
16768
16974
  event.currentTarget.value = state.aiConversationModelId ?? event.currentTarget.value;
16975
+ syncAiModelPicker();
16769
16976
  return toast(aiConversationOptionLockedMessage);
16770
16977
  }
16771
16978
  setAiContextMeter(null);
16979
+ syncAiModelPicker();
16980
+ setAiModelPickerVisible(false);
16981
+ });
16982
+ $("#ai-model-picker").addEventListener("click", async (event) => {
16983
+ const button = event.currentTarget;
16984
+ if (notifyAiConversationOptionLocked(button)) return;
16985
+ const willOpen = $("#ai-model-popover").classList.contains("hidden");
16986
+ setAiContextDistributionVisible(false);
16987
+ setAiModelPickerVisible(willOpen);
16988
+ if (!willOpen) return;
16989
+ try {
16990
+ await ensureAiModelsLoaded();
16991
+ } catch (error) {
16992
+ toast(`模型加载失败:${error.message}`, "error");
16993
+ }
16994
+ if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled) $("#ai-model").focus();
16772
16995
  });
16773
16996
  $("#ai-roleplay-character").addEventListener("focus", async () => {
16774
16997
  try {
@@ -16795,6 +17018,31 @@ $("#ai-roleplay-character").addEventListener("change", async (event) => {
16795
17018
  renderAiRoleplayCharacterSelect();
16796
17019
  }
16797
17020
  });
17021
+ $("#ai-roleplay-user-character").addEventListener("focus", async () => {
17022
+ try {
17023
+ await ensureAiReferencesLoaded();
17024
+ renderAiRoleplayUserCharacterSelect();
17025
+ } catch (error) {
17026
+ toast(`角色卡加载失败:${error.message}`, "error");
17027
+ }
17028
+ });
17029
+ $("#ai-roleplay-user-character").addEventListener("change", async (event) => {
17030
+ const select = event.currentTarget;
17031
+ if (state.aiPromptSent) {
17032
+ renderAiRoleplayUserCharacterSelect();
17033
+ return toast(aiConversationOptionLockedMessage);
17034
+ }
17035
+ const userCharacterId = select.value;
17036
+ select.disabled = true;
17037
+ try {
17038
+ await updateAiRoleplayCharacter($("#ai-roleplay-character").value, userCharacterId);
17039
+ } catch (error) {
17040
+ renderAiRoleplayUserCharacterSelect();
17041
+ toast(`关系扮演模式切换失败:${error.message}`, "error");
17042
+ } finally {
17043
+ renderAiRoleplayUserCharacterSelect();
17044
+ }
17045
+ });
16798
17046
  $("#ai-task").addEventListener("change", async (event) => {
16799
17047
  const select = event.currentTarget;
16800
17048
  const previousTaskType = select.dataset.previousValue || state.aiTaskType || "chat";
@@ -16989,6 +17237,7 @@ document.addEventListener("pointerdown", (event) => {
16989
17237
  if (!event.target.closest("#ai-conversation-switcher-menu") && !event.target.closest("#ai-conversation-switcher")) setAiConversationSwitcherVisible(false);
16990
17238
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
16991
17239
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
17240
+ if (!event.target.closest("#ai-model-picker") && !event.target.closest("#ai-model-popover")) setAiModelPickerVisible(false);
16992
17241
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
16993
17242
  $("#account-menu").classList.add("hidden");
16994
17243
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -17006,6 +17255,10 @@ document.addEventListener("keydown", (event) => {
17006
17255
  return;
17007
17256
  }
17008
17257
  if (event.key === "Escape") {
17258
+ if (!$("#ai-model-popover").classList.contains("hidden")) {
17259
+ setAiModelPickerVisible(false);
17260
+ return;
17261
+ }
17009
17262
  if (!$("#chapter-search-panel").classList.contains("hidden")) {
17010
17263
  closeChapterSearchPanel();
17011
17264
  return;
@@ -17041,6 +17294,7 @@ document.addEventListener("keydown", (event) => {
17041
17294
  openSearchDialog().catch((error) => toast(error.message, "error"));
17042
17295
  }, { capture: true });
17043
17296
  $("#ai-context-meter").addEventListener("click", () => {
17297
+ setAiModelPickerVisible(false);
17044
17298
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
17045
17299
  });
17046
17300
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));