@musnows/scriverse 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -24,7 +24,8 @@ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/
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";
27
- import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260812-context-usage-remaining-v2";
27
+ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260819-context-percent-v1";
28
+ import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
28
29
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
29
30
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
30
31
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
@@ -178,6 +179,7 @@ const state = {
178
179
  aiConversationModelId: null,
179
180
  aiConversations: [],
180
181
  aiRoleplayCharacter: null,
182
+ aiRoleplayUserCharacter: null,
181
183
  aiLastMessageAt: null,
182
184
  settings: [],
183
185
  dirty: false,
@@ -1330,12 +1332,15 @@ function constrainPanelLayout() {
1330
1332
  }
1331
1333
 
1332
1334
  function applyPanelLayout(persist = false) {
1335
+ const phoneClient = isPhoneClient();
1336
+ if (phoneClient && !aiConversationWorkspaceOpen) panelLayout.aiCollapsed = true;
1333
1337
  constrainPanelLayout();
1334
1338
  const app = $("#app");
1335
1339
  app.style.setProperty("--left-panel-width", `${panelLayout.leftWidth}px`);
1336
1340
  app.style.setProperty("--ai-panel-width", `${panelLayout.aiWidth}px`);
1337
1341
  app.classList.toggle("left-panel-collapsed", panelLayout.leftCollapsed);
1338
1342
  app.classList.toggle("ai-panel-collapsed", panelLayout.aiCollapsed);
1343
+ app.classList.toggle("phone-client", phoneClient);
1339
1344
  const mobileViewport = isMobileViewport();
1340
1345
  $("#left-panel-toggle").textContent = mobileViewport ? "›" : (panelLayout.leftCollapsed ? "›" : "‹");
1341
1346
  $("#left-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
@@ -1355,7 +1360,7 @@ function applyPanelLayout(persist = false) {
1355
1360
 
1356
1361
  function ensureAiPanelExpanded() {
1357
1362
  if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
1358
- if (isMobileViewport()) {
1363
+ if (isPhoneClient() || isMobileViewport()) {
1359
1364
  setAiConversationWorkspaceVisible(true);
1360
1365
  return;
1361
1366
  }
@@ -2040,6 +2045,7 @@ function createAiChatTabState(input = {}) {
2040
2045
  taskType: input.taskType ?? "chat",
2041
2046
  contextScope: input.contextScope ?? { type: "none" },
2042
2047
  roleplayCharacter: input.roleplayCharacter ?? null,
2048
+ roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2043
2049
  citations: input.citations ?? [],
2044
2050
  references: input.references ?? [],
2045
2051
  composer: input.composer ?? { text: "", citations: [], references: [] },
@@ -2067,6 +2073,7 @@ function persistActiveAiChatTab() {
2067
2073
  tab.taskType = state.aiTaskType;
2068
2074
  tab.contextScope = { ...state.aiContextScope };
2069
2075
  tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2076
+ tab.roleplayUserCharacter = state.aiRoleplayUserCharacter ? { ...state.aiRoleplayUserCharacter } : null;
2070
2077
  setAiChatTabComposerSnapshot(tab, captureAiPromptComposer());
2071
2078
  tab.contextUsage = latestAiContextUsage;
2072
2079
  tab.contextWarning = !$("#ai-context-warning").classList.contains("hidden");
@@ -2099,9 +2106,10 @@ function applyAiChatTabState(tab) {
2099
2106
  $("#ai-conversation-title").textContent = tab.title || "新对话";
2100
2107
  applyAiConversationTaskType(tab.taskType);
2101
2108
  applyAiConversationContextScope(tab.contextScope);
2102
- applyAiRoleplayCharacter(tab.roleplayCharacter);
2109
+ applyAiRoleplayCharacter(tab.roleplayCharacter, tab.roleplayUserCharacter);
2103
2110
  const selectedModelId = tab.modelId ?? tab.selectedModelId;
2104
2111
  if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
2112
+ syncAiModelPicker();
2105
2113
  setAiPromptText(tab.composer.text);
2106
2114
  renderAiCitations();
2107
2115
  renderAiReferences();
@@ -2215,7 +2223,7 @@ function setAiConversationSwitcherVisible(visible) {
2215
2223
  }
2216
2224
 
2217
2225
  function setAiConversationWorkspaceVisible(visible) {
2218
- const mobileWorkspace = isMobileViewport();
2226
+ const mobileWorkspace = isPhoneClient() || isMobileViewport();
2219
2227
  aiConversationWorkspaceOpen = Boolean(visible);
2220
2228
  panelLayout.aiCollapsed = aiConversationWorkspaceOpen ? false : mobileWorkspace;
2221
2229
  $("#app").classList.toggle("ai-workspace-mode", aiConversationWorkspaceOpen);
@@ -2340,7 +2348,7 @@ function resetAiChatTabs() {
2340
2348
  panels.replaceChildren();
2341
2349
  aiChatTabManager.reset();
2342
2350
  const tab = createAiChatTabState();
2343
- resetAiFeed(tab.feed, tab.roleplayCharacter);
2351
+ resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
2344
2352
  activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2345
2353
  }
2346
2354
 
@@ -2385,13 +2393,18 @@ function updateMessageCreatedAt(message, createdAt) {
2385
2393
  }
2386
2394
  }
2387
2395
 
2388
- function resetAiFeed(feed = $("#ai-feed"), roleplayCharacter = state.aiRoleplayCharacter) {
2396
+ function resetAiFeed(
2397
+ feed = $("#ai-feed"),
2398
+ roleplayCharacter = state.aiRoleplayCharacter,
2399
+ roleplayUserCharacter = state.aiRoleplayUserCharacter
2400
+ ) {
2389
2401
  const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
2390
2402
  if (tab) tab.lastMessageAt = null;
2391
2403
  if (isActiveAiChatTab(tab)) state.aiLastMessageAt = null;
2392
2404
  const roleplayName = roleplayCharacter?.name;
2405
+ const roleplayUserName = roleplayUserCharacter?.name;
2393
2406
  feed.innerHTML = roleplayName
2394
- ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
2407
+ ? `<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>`
2395
2408
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2396
2409
  }
2397
2410
 
@@ -2400,6 +2413,10 @@ function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayChara
2400
2413
  return suffix ? `${name} · ${suffix}` : name;
2401
2414
  }
2402
2415
 
2416
+ function aiUserLabel(roleplayUserCharacter = state.aiRoleplayUserCharacter) {
2417
+ return roleplayUserCharacter?.name || "作者";
2418
+ }
2419
+
2403
2420
  function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "当前上下文已压缩", title = "" } = {}) {
2404
2421
  const divider = document.createElement("div");
2405
2422
  divider.className = "ai-context-compaction-divider";
@@ -2901,6 +2918,7 @@ function aiConversationHistoryMeta(conversation) {
2901
2918
  aiConversationContextScopeLabel(conversation.contextScope)
2902
2919
  ];
2903
2920
  if (conversation.roleplayCharacter?.name) parts.push(`扮演 ${conversation.roleplayCharacter.name}`);
2921
+ if (conversation.roleplayUserCharacter?.name) parts.push(`用户扮演 ${conversation.roleplayUserCharacter.name}`);
2904
2922
  parts.push(formatDateTime(conversation.updatedAt));
2905
2923
  return parts.join(" · ");
2906
2924
  }
@@ -3117,12 +3135,13 @@ function applyConversationToAiChatTab(tab, conversation) {
3117
3135
  tab.taskType = conversation.taskType ?? "chat";
3118
3136
  tab.contextScope = conversation.contextScope ?? { type: "none" };
3119
3137
  tab.roleplayCharacter = conversation.roleplayCharacter ?? null;
3138
+ tab.roleplayUserCharacter = conversation.roleplayUserCharacter ?? null;
3120
3139
  tab.citations = [];
3121
3140
  tab.references = [];
3122
3141
  tab.composer = { text: "", citations: [], references: [] };
3123
3142
  tab.contextUsage = null;
3124
3143
  tab.contextWarning = conversation.contextWarningPending === true;
3125
- resetAiFeed(tab.feed, tab.roleplayCharacter);
3144
+ resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
3126
3145
  for (const message of conversation.messages ?? []) {
3127
3146
  appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id, { tab });
3128
3147
  }
@@ -3197,10 +3216,65 @@ function renderAiRoleplayCharacterSelect() {
3197
3216
  ? aiConversationOptionLockedMessage
3198
3217
  : "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
3199
3218
  : "当前账户没有角色模块读取权限";
3219
+ renderAiRoleplayUserCharacterSelect();
3220
+ }
3221
+
3222
+ function renderAiRoleplayUserCharacterSelect() {
3223
+ const select = $("#ai-roleplay-user-character");
3224
+ const selectedId = String(state.aiRoleplayUserCharacter?.id ?? "");
3225
+ const aiCharacterId = String(state.aiRoleplayCharacter?.id ?? "");
3226
+ const availableCharacters = state.characters.filter((character) => (
3227
+ !character.mergedIntoCharacterId && String(character.id) !== aiCharacterId
3228
+ ));
3229
+ const options = [{ id: "", name: "选择我的角色(可选)" }, ...availableCharacters.map((character) => ({
3230
+ id: String(character.id),
3231
+ name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
3232
+ }))];
3233
+ if (selectedId && !options.some((option) => option.id === selectedId)) {
3234
+ options.push({ id: selectedId, name: String(state.aiRoleplayUserCharacter.name) });
3235
+ }
3236
+ select.replaceChildren(...options.map((option) => {
3237
+ const element = document.createElement("option");
3238
+ element.value = option.id;
3239
+ element.textContent = option.name;
3240
+ return element;
3241
+ }));
3242
+ select.value = selectedId;
3243
+ const canSelectCharacter = Boolean(state.work)
3244
+ && canReadModule("characters")
3245
+ && canWritePermissionModule(state.work, "ai-chat");
3246
+ select.disabled = aiInteractionBusy() || !canSelectCharacter || !state.aiRoleplayCharacter;
3247
+ select.title = !canSelectCharacter
3248
+ ? "当前账户没有角色模块读取权限"
3249
+ : !state.aiRoleplayCharacter
3250
+ ? "请先选择 AI 扮演的角色"
3251
+ : state.aiPromptSent
3252
+ ? aiConversationOptionLockedMessage
3253
+ : "选择后,AI 会将每条用户消息视为该角色的发言或行动";
3200
3254
  }
3201
3255
 
3202
3256
  const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
3203
3257
 
3258
+ function selectedAiModelLabel() {
3259
+ return $("#ai-model").selectedOptions[0]?.textContent?.trim() || "尚未选择模型";
3260
+ }
3261
+
3262
+ function syncAiModelPicker() {
3263
+ const select = $("#ai-model");
3264
+ const button = $("#ai-model-picker");
3265
+ const interactionBusy = aiInteractionBusy();
3266
+ const selectedLabel = selectedAiModelLabel();
3267
+ const label = state.aiPromptSent
3268
+ ? `当前模型:${selectedLabel}。${aiConversationOptionLockedMessage}`
3269
+ : `选择实际使用模型:${selectedLabel}`;
3270
+ select.disabled = interactionBusy;
3271
+ select.title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3272
+ button.disabled = interactionBusy;
3273
+ button.title = label;
3274
+ button.setAttribute("aria-label", label);
3275
+ if (interactionBusy) setAiModelPickerVisible(false);
3276
+ }
3277
+
3204
3278
  function notifyAiConversationOptionLocked(select) {
3205
3279
  if (!state.aiPromptSent) return false;
3206
3280
  const now = Date.now();
@@ -3224,19 +3298,21 @@ function blockLockedAiConversationOptionKeydown(event) {
3224
3298
 
3225
3299
  function syncAiTaskOptions() {
3226
3300
  const roleplaySelected = $("#ai-task").value === "roleplay";
3301
+ const relationshipRoleplaySelectable = roleplaySelected && Boolean(state.aiRoleplayCharacter);
3227
3302
  const interactionBusy = aiInteractionBusy();
3228
3303
  $("#ai-scope").classList.toggle("hidden", roleplaySelected);
3229
3304
  $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
3230
3305
  $("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
3231
3306
  $("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
3307
+ $("#ai-roleplay-user-character").classList.toggle("hidden", !relationshipRoleplaySelectable);
3308
+ $("#ai-roleplay-user-character").setAttribute("aria-hidden", String(!relationshipRoleplaySelectable));
3232
3309
  $("#ai-task").disabled = interactionBusy;
3233
3310
  $("#ai-task").title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3234
3311
  $("#ai-scope").disabled = interactionBusy || roleplaySelected;
3235
3312
  $("#ai-scope").title = state.aiPromptSent
3236
3313
  ? aiConversationOptionLockedMessage
3237
3314
  : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
3238
- $("#ai-model").disabled = interactionBusy;
3239
- $("#ai-model").title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3315
+ syncAiModelPicker();
3240
3316
  }
3241
3317
 
3242
3318
  function applyAiConversationTaskType(taskType) {
@@ -3262,15 +3338,19 @@ function applyAiConversationContextScope(scope) {
3262
3338
  syncAiTaskOptions();
3263
3339
  }
3264
3340
 
3265
- function applyAiRoleplayCharacter(character) {
3341
+ function applyAiRoleplayCharacter(character, userCharacter = null) {
3266
3342
  state.aiRoleplayCharacter = character?.id ? character : null;
3343
+ state.aiRoleplayUserCharacter = state.aiRoleplayCharacter && userCharacter?.id ? userCharacter : null;
3267
3344
  const tab = activeAiChatTab();
3268
3345
  if (tab) tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
3346
+ if (tab) tab.roleplayUserCharacter = state.aiRoleplayUserCharacter ? { ...state.aiRoleplayUserCharacter } : null;
3269
3347
  const active = Boolean(state.aiRoleplayCharacter);
3270
3348
  if (active) $("#ai-scope").value = "none";
3271
3349
  $(".ai-panel").classList.toggle("is-roleplaying", active);
3272
3350
  $("#ai-prompt").dataset.placeholder = active
3273
- ? `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
3351
+ ? state.aiRoleplayUserCharacter
3352
+ ? `以 ${String(state.aiRoleplayUserCharacter.name)} 的身份与 ${String(state.aiRoleplayCharacter.name)} 对话……`
3353
+ : `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
3274
3354
  : "告诉 AI 你想讨论或修改什么……";
3275
3355
  renderAiRoleplayCharacterSelect();
3276
3356
  syncAiTaskOptions();
@@ -3281,22 +3361,27 @@ function refreshAiMessageRoleLabels() {
3281
3361
  $("#ai-feed").querySelectorAll(".assistant-message .message-heading > span").forEach((label) => {
3282
3362
  label.textContent = aiAssistantLabel();
3283
3363
  });
3364
+ $("#ai-feed").querySelectorAll(".user-message .message-heading > span").forEach((label) => {
3365
+ label.textContent = aiUserLabel();
3366
+ });
3284
3367
  }
3285
3368
 
3286
- async function updateAiRoleplayCharacter(characterId) {
3369
+ async function updateAiRoleplayCharacter(characterId, userCharacterId = null) {
3287
3370
  const conversationId = await ensureAiConversation();
3288
3371
  const conversation = await api(`/api/ai-conversations/${conversationId}/roleplay`, {
3289
3372
  method: "PATCH",
3290
- body: { characterId: characterId || null }
3373
+ body: { characterId: characterId || null, userCharacterId: userCharacterId || null }
3291
3374
  });
3292
3375
  upsertAiConversationSummary(conversation);
3293
3376
  applyAiConversationTaskType(conversation.taskType);
3294
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
3377
+ applyAiRoleplayCharacter(conversation.roleplayCharacter, conversation.roleplayUserCharacter);
3295
3378
  resetAiContextMeter();
3296
3379
  if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
3297
3380
  else resetAiFeed();
3298
3381
  toast(conversation.roleplayCharacter
3299
- ? `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
3382
+ ? conversation.roleplayUserCharacter
3383
+ ? `已进入 ${conversation.roleplayCharacter.name} 与 ${conversation.roleplayUserCharacter.name} 的关系扮演模式`
3384
+ : `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
3300
3385
  : "已退出角色扮演模式");
3301
3386
  }
3302
3387
 
@@ -3314,7 +3399,7 @@ async function persistAiConversationTaskType(taskType) {
3314
3399
  });
3315
3400
  upsertAiConversationSummary(conversation);
3316
3401
  applyAiConversationTaskType(conversation.taskType);
3317
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
3402
+ applyAiRoleplayCharacter(conversation.roleplayCharacter, conversation.roleplayUserCharacter);
3318
3403
  return conversation;
3319
3404
  }
3320
3405
 
@@ -3354,9 +3439,10 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
3354
3439
  upsertAiConversationSummary(taskConversation);
3355
3440
  tab.taskType = taskConversation.taskType;
3356
3441
  tab.roleplayCharacter = taskConversation.roleplayCharacter ?? null;
3442
+ tab.roleplayUserCharacter = taskConversation.roleplayUserCharacter ?? null;
3357
3443
  if (isActiveAiChatTab(tab)) {
3358
3444
  applyAiConversationTaskType(tab.taskType);
3359
- applyAiRoleplayCharacter(tab.roleplayCharacter);
3445
+ applyAiRoleplayCharacter(tab.roleplayCharacter, tab.roleplayUserCharacter);
3360
3446
  }
3361
3447
 
3362
3448
  const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
@@ -3726,8 +3812,7 @@ function renderChapterAnnotations() {
3726
3812
  },
3727
3813
  locate: (annotation) => {
3728
3814
  $("#chapter-annotations-dialog").close();
3729
- paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
3730
- selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
3815
+ revealChapterLines(annotation.startLine, annotation.endLine);
3731
3816
  },
3732
3817
  overlayDialog: $("#chapter-annotations-dialog")
3733
3818
  });
@@ -6303,7 +6388,7 @@ async function runWorkSearch() {
6303
6388
  renderSearchResults(results, query);
6304
6389
  }
6305
6390
 
6306
- function revealChapterSearchLines(startLine, endLine) {
6391
+ function revealChapterLines(startLine, endLine) {
6307
6392
  const start = Math.max(0, Number(startLine) - 1);
6308
6393
  const end = Math.max(start, Number(endLine ?? startLine) - 1);
6309
6394
  const input = $("#chapter-content");
@@ -6358,7 +6443,7 @@ async function openSearchResult(result) {
6358
6443
  if (target.kind === "chapter") {
6359
6444
  await selectChapter(target.id);
6360
6445
  if (state.chapter?.id === target.id && target.startLine) {
6361
- revealChapterSearchLines(target.startLine, target.endLine);
6446
+ revealChapterLines(target.startLine, target.endLine);
6362
6447
  toast(target.startLine === target.endLine ? `已定位到第 ${target.startLine} 行` : `已定位到第 ${target.startLine}-${target.endLine} 行`);
6363
6448
  }
6364
6449
  return;
@@ -6554,6 +6639,8 @@ function resetWorkScopedUiCaches() {
6554
6639
  state.aiConversations = [];
6555
6640
  resetAiChatTabs();
6556
6641
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
6642
+ syncAiModelPicker();
6643
+ setAiModelPickerVisible(false);
6557
6644
  renderAiConversationHistory();
6558
6645
  }
6559
6646
 
@@ -9357,10 +9444,10 @@ async function renderWorkChapterComments(page = moduleListPages.comments) {
9357
9444
  bindChapterAnnotationCards($("#module-content"), result.items, {
9358
9445
  refresh: () => renderWorkChapterComments(pageResult.page),
9359
9446
  locate: async (annotation) => {
9360
- await selectChapter(annotation.chapterId);
9447
+ const selected = await selectChapter(annotation.chapterId);
9448
+ if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
9361
9449
  await new Promise((resolve) => window.requestAnimationFrame(resolve));
9362
- paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
9363
- selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
9450
+ revealChapterLines(annotation.startLine, annotation.endLine);
9364
9451
  }
9365
9452
  });
9366
9453
  }
@@ -11395,12 +11482,16 @@ async function ensureAiModelsLoaded() {
11395
11482
  if (aiModelsLoadPromise && aiModelsLoadWorkId === workId) return aiModelsLoadPromise;
11396
11483
  const select = $("#ai-model");
11397
11484
  select.innerHTML = '<option value="">正在加载模型……</option>';
11485
+ syncAiModelPicker();
11398
11486
  aiModelsLoadWorkId = workId;
11399
11487
  aiModelsLoadPromise = loadModels();
11400
11488
  try {
11401
11489
  await aiModelsLoadPromise;
11402
11490
  } catch (error) {
11403
- if (state.work?.id === workId) select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
11491
+ if (state.work?.id === workId) {
11492
+ select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
11493
+ syncAiModelPicker();
11494
+ }
11404
11495
  throw error;
11405
11496
  } finally {
11406
11497
  if (aiModelsLoadWorkId === workId) {
@@ -11535,6 +11626,14 @@ function setAiContextDistributionVisible(visible) {
11535
11626
  if (!visible && document.activeElement === $("#ai-context-popover-close")) meter.focus();
11536
11627
  }
11537
11628
 
11629
+ function setAiModelPickerVisible(visible) {
11630
+ const button = $("#ai-model-picker");
11631
+ const popover = $("#ai-model-popover");
11632
+ popover.classList.toggle("hidden", !visible);
11633
+ button.setAttribute("aria-expanded", String(visible));
11634
+ if (!visible && document.activeElement === $("#ai-model")) button.focus();
11635
+ }
11636
+
11538
11637
  function showAiContextWarning(usage = null) {
11539
11638
  $("#ai-context-warning-title").textContent = "对话上下文过长";
11540
11639
  $("#ai-context-warning-message").textContent = "当前对话上下文过长,是否进行压缩?不压缩可能会导致后续请求失败";
@@ -12813,8 +12912,17 @@ function updateVditorWordCount(editor, markdown) {
12813
12912
  counter.title = "当前 Markdown 正文的字数,不计空白字符";
12814
12913
  toolbar.append(counter);
12815
12914
  }
12915
+ let value = counter.querySelector("[data-markdown-word-count-value]");
12916
+ if (!value) {
12917
+ value = document.createElement("span");
12918
+ value.className = "markdown-word-count-value";
12919
+ value.dataset.markdownWordCountValue = "true";
12920
+ const unit = document.createElement("span");
12921
+ unit.textContent = " 字";
12922
+ counter.replaceChildren(value, unit);
12923
+ }
12816
12924
  const count = Array.from(String(markdown ?? "").replace(/\s/gu, "")).length;
12817
- counter.textContent = `${count.toLocaleString("zh-CN")} 字`;
12925
+ value.textContent = count.toLocaleString("zh-CN");
12818
12926
  }
12819
12927
 
12820
12928
  function transformVditorPreview(html) {
@@ -14917,6 +15025,22 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14917
15025
  }
14918
15026
  }
14919
15027
 
15028
+ function createAiStreamCharacterCount(value) {
15029
+ const count = document.createElement("span");
15030
+ count.className = "ai-stream-character-count";
15031
+ count.textContent = Math.max(0, Number(value) || 0).toLocaleString("zh-CN");
15032
+ return count;
15033
+ }
15034
+
15035
+ function renderAiStreamingCharacterProgress(meta, visibleCharacters, receivedCharacters) {
15036
+ const visible = Math.max(0, Number(visibleCharacters) || 0);
15037
+ const received = Math.max(visible, Number(receivedCharacters) || 0);
15038
+ const children = ["正在生成 · ", createAiStreamCharacterCount(visible)];
15039
+ if (received > visible) children.push(" / ", createAiStreamCharacterCount(received));
15040
+ children.push(" 字");
15041
+ meta.replaceChildren(...children);
15042
+ }
15043
+
14920
15044
  async function streamChat(requestHolder, body, idempotencyKey) {
14921
15045
  const tab = aiChatTabForRequest(requestHolder.snapshot);
14922
15046
  if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
@@ -14944,9 +15068,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14944
15068
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
14945
15069
  content.innerHTML = renderMarkdown(text);
14946
15070
  const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
14947
- meta.textContent = progress.pendingCharacters > 0
14948
- ? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
14949
- : `正在生成 · ${progress.visibleCharacters} 字`;
15071
+ renderAiStreamingCharacterProgress(meta, progress.visibleCharacters, receivedCharacters);
14950
15072
  scrollAiFeedToBottom(feed);
14951
15073
  }
14952
15074
  });
@@ -15201,7 +15323,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15201
15323
  });
15202
15324
  const heading = attachMessageHeading(
15203
15325
  message,
15204
- role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15326
+ role === "user"
15327
+ ? aiUserLabel(tab?.roleplayUserCharacter)
15328
+ : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15205
15329
  createdAt ?? undefined,
15206
15330
  tab
15207
15331
  );
@@ -16821,7 +16945,7 @@ $("#ai-assistant-entry").addEventListener("click", () => {
16821
16945
  return toast("当前账户没有创作助手读取权限", "error");
16822
16946
  }
16823
16947
  setModuleNavExpanded(false);
16824
- if (isMobileViewport()) {
16948
+ if (isPhoneClient() || isMobileViewport()) {
16825
16949
  panelLayout.leftCollapsed = true;
16826
16950
  applyPanelLayout(true);
16827
16951
  setAiConversationWorkspaceVisible(true);
@@ -16863,7 +16987,7 @@ $("#ai-prompt").addEventListener("input", async () => {
16863
16987
  $("#ai-prompt").addEventListener("focus", () => {
16864
16988
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
16865
16989
  });
16866
- for (const select of [$("#ai-task"), $("#ai-scope"), $("#ai-roleplay-character"), $("#ai-model")]) {
16990
+ for (const select of [$("#ai-task"), $("#ai-scope"), $("#ai-roleplay-character"), $("#ai-roleplay-user-character"), $("#ai-model")]) {
16867
16991
  select.addEventListener("pointerdown", blockLockedAiConversationOptionInteraction);
16868
16992
  select.addEventListener("click", blockLockedAiConversationOptionInteraction);
16869
16993
  select.addEventListener("keydown", blockLockedAiConversationOptionKeydown);
@@ -16874,9 +16998,26 @@ $("#ai-model").addEventListener("focus", () => {
16874
16998
  $("#ai-model").addEventListener("change", (event) => {
16875
16999
  if (state.aiPromptSent) {
16876
17000
  event.currentTarget.value = state.aiConversationModelId ?? event.currentTarget.value;
17001
+ syncAiModelPicker();
16877
17002
  return toast(aiConversationOptionLockedMessage);
16878
17003
  }
16879
17004
  setAiContextMeter(null);
17005
+ syncAiModelPicker();
17006
+ setAiModelPickerVisible(false);
17007
+ });
17008
+ $("#ai-model-picker").addEventListener("click", async (event) => {
17009
+ const button = event.currentTarget;
17010
+ if (notifyAiConversationOptionLocked(button)) return;
17011
+ const willOpen = $("#ai-model-popover").classList.contains("hidden");
17012
+ setAiContextDistributionVisible(false);
17013
+ setAiModelPickerVisible(willOpen);
17014
+ if (!willOpen) return;
17015
+ try {
17016
+ await ensureAiModelsLoaded();
17017
+ } catch (error) {
17018
+ toast(`模型加载失败:${error.message}`, "error");
17019
+ }
17020
+ if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled) $("#ai-model").focus();
16880
17021
  });
16881
17022
  $("#ai-roleplay-character").addEventListener("focus", async () => {
16882
17023
  try {
@@ -16903,6 +17044,31 @@ $("#ai-roleplay-character").addEventListener("change", async (event) => {
16903
17044
  renderAiRoleplayCharacterSelect();
16904
17045
  }
16905
17046
  });
17047
+ $("#ai-roleplay-user-character").addEventListener("focus", async () => {
17048
+ try {
17049
+ await ensureAiReferencesLoaded();
17050
+ renderAiRoleplayUserCharacterSelect();
17051
+ } catch (error) {
17052
+ toast(`角色卡加载失败:${error.message}`, "error");
17053
+ }
17054
+ });
17055
+ $("#ai-roleplay-user-character").addEventListener("change", async (event) => {
17056
+ const select = event.currentTarget;
17057
+ if (state.aiPromptSent) {
17058
+ renderAiRoleplayUserCharacterSelect();
17059
+ return toast(aiConversationOptionLockedMessage);
17060
+ }
17061
+ const userCharacterId = select.value;
17062
+ select.disabled = true;
17063
+ try {
17064
+ await updateAiRoleplayCharacter($("#ai-roleplay-character").value, userCharacterId);
17065
+ } catch (error) {
17066
+ renderAiRoleplayUserCharacterSelect();
17067
+ toast(`关系扮演模式切换失败:${error.message}`, "error");
17068
+ } finally {
17069
+ renderAiRoleplayUserCharacterSelect();
17070
+ }
17071
+ });
16906
17072
  $("#ai-task").addEventListener("change", async (event) => {
16907
17073
  const select = event.currentTarget;
16908
17074
  const previousTaskType = select.dataset.previousValue || state.aiTaskType || "chat";
@@ -17097,6 +17263,7 @@ document.addEventListener("pointerdown", (event) => {
17097
17263
  if (!event.target.closest("#ai-conversation-switcher-menu") && !event.target.closest("#ai-conversation-switcher")) setAiConversationSwitcherVisible(false);
17098
17264
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
17099
17265
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
17266
+ if (!event.target.closest("#ai-model-picker") && !event.target.closest("#ai-model-popover")) setAiModelPickerVisible(false);
17100
17267
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
17101
17268
  $("#account-menu").classList.add("hidden");
17102
17269
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -17114,6 +17281,10 @@ document.addEventListener("keydown", (event) => {
17114
17281
  return;
17115
17282
  }
17116
17283
  if (event.key === "Escape") {
17284
+ if (!$("#ai-model-popover").classList.contains("hidden")) {
17285
+ setAiModelPickerVisible(false);
17286
+ return;
17287
+ }
17117
17288
  if (!$("#chapter-search-panel").classList.contains("hidden")) {
17118
17289
  closeChapterSearchPanel();
17119
17290
  return;
@@ -17149,6 +17320,7 @@ document.addEventListener("keydown", (event) => {
17149
17320
  openSearchDialog().catch((error) => toast(error.message, "error"));
17150
17321
  }, { capture: true });
17151
17322
  $("#ai-context-meter").addEventListener("click", () => {
17323
+ setAiModelPickerVisible(false);
17152
17324
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
17153
17325
  });
17154
17326
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
@@ -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=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -379,9 +379,9 @@
379
379
  <select id="ai-roleplay-character" class="ai-roleplay-character hidden" aria-label="角色扮演角色卡">
380
380
  <option value="">选择角色卡</option>
381
381
  </select>
382
- </div>
383
- <div class="field-label prompt-model-field">
384
- <select id="ai-model" aria-label="实际使用模型"></select>
382
+ <select id="ai-roleplay-user-character" class="ai-roleplay-character ai-roleplay-user-character hidden" aria-label="我扮演的角色">
383
+ <option value="">选择我的角色(可选)</option>
384
+ </select>
385
385
  </div>
386
386
  <div class="prompt-composer">
387
387
  <div id="ai-prompt" class="ai-prompt" contenteditable="true" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-controls="ai-mention-menu" aria-haspopup="listbox" aria-expanded="false" aria-keyshortcuts="Enter" title="Enter 发送,Shift+Enter 换行" data-placeholder="告诉 AI 你想讨论或修改什么……"></div>
@@ -392,6 +392,11 @@
392
392
  <header class="ai-context-popover-header"><div><strong id="ai-context-popover-title">Token 分布</strong><small id="ai-context-popover-description">按当前模型上下文窗口计算</small></div><button id="ai-context-popover-close" class="ai-context-popover-close" type="button" aria-label="关闭 Token 分布">×</button></header>
393
393
  <div id="ai-context-distribution" class="ai-context-distribution" role="list" aria-label="当前 Token 分布"></div>
394
394
  </section>
395
+ <button id="ai-model-picker" class="ai-model-picker" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="ai-model-popover" aria-label="选择实际使用模型" title="选择实际使用模型"><svg class="ai-model-picker-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5.25a3.25 3.25 0 0 0-5.95 1.82A3.74 3.74 0 0 0 4.7 13.5a3.25 3.25 0 0 0 4.55 4.53A3.6 3.6 0 0 0 12 19.5"></path><path d="M12 5.25a3.25 3.25 0 0 1 5.95 1.82 3.74 3.74 0 0 1 1.35 6.43 3.25 3.25 0 0 1-4.55 4.53A3.6 3.6 0 0 1 12 19.5"></path><path d="M12 5.25v14.25"></path><path d="M8 9.1c1.1.1 2 .9 2.25 1.95"></path><path d="M16 9.1c-1.1.1-2 .9-2.25 1.95"></path><path d="M8.3 15.3c1.15-.05 2.1-.85 2.3-1.95"></path><path d="M15.7 15.3c-1.15-.05-2.1-.85-2.3-1.95"></path></svg></button>
396
+ <section id="ai-model-popover" class="ai-model-popover hidden" role="dialog" aria-labelledby="ai-model-popover-title">
397
+ <label id="ai-model-popover-title" for="ai-model">实际使用模型</label>
398
+ <select id="ai-model" aria-label="实际使用模型"><option value="">使用创作助手时加载模型</option></select>
399
+ </section>
395
400
  <button id="ai-send" class="ai-send-button" type="button" data-state="send" aria-label="发送消息" title="发送消息"><svg class="ai-send-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 3 9.5 14.5M21 3l-7 18-4.5-6.5L3 10l18-7Z"></path></svg></button>
396
401
  </div>
397
402
  </div>
@@ -1205,6 +1210,6 @@
1205
1210
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1206
1211
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1207
1212
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1208
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v1&feature=volume-detail-icon-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1"></script>
1213
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v1&feature=volume-detail-icon-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v1&feature=phone-client-entry-v1"></script>
1209
1214
  </body>
1210
1215
  </html>
@@ -0,0 +1,6 @@
1
+ export function isPhoneClient(navigatorLike = globalThis.navigator) {
2
+ const mobile = navigatorLike?.userAgentData?.mobile;
3
+ if (typeof mobile === "boolean") return mobile;
4
+ const userAgent = String(navigatorLike?.userAgent ?? "");
5
+ return /(?:Android.*Mobile|iPhone|iPod|IEMobile|Windows Phone|Opera Mini)/iu.test(userAgent);
6
+ }
@@ -327,10 +327,18 @@ export function normalizeGalaxyMotionMode(value) {
327
327
  return GALAXY_MOTION_MODES.includes(value) ? value : "auto";
328
328
  }
329
329
 
330
+ export function isGalaxyPerformanceThresholdExceeded(nodeCount, edgeCount) {
331
+ return Math.max(0, Number(nodeCount) || 0) > GALAXY_REDUCED_MOTION_NODE_THRESHOLD
332
+ || Math.max(0, Number(edgeCount) || 0) > GALAXY_REDUCED_MOTION_EDGE_THRESHOLD;
333
+ }
334
+
335
+ export function shouldRenderGalaxyEdgeLabel(nodeCount, edgeCount, edgeSelected, highlighted) {
336
+ return Boolean(edgeSelected) || (Boolean(highlighted) && !isGalaxyPerformanceThresholdExceeded(nodeCount, edgeCount));
337
+ }
338
+
330
339
  export function getGalaxyMotionProfile(mode, nodeCount, edgeCount, prefersReducedMotion = false) {
331
340
  const requestedMode = normalizeGalaxyMotionMode(mode);
332
- const thresholdExceeded = Math.max(0, Number(nodeCount) || 0) > GALAXY_REDUCED_MOTION_NODE_THRESHOLD
333
- || Math.max(0, Number(edgeCount) || 0) > GALAXY_REDUCED_MOTION_EDGE_THRESHOLD;
341
+ const thresholdExceeded = isGalaxyPerformanceThresholdExceeded(nodeCount, edgeCount);
334
342
  const reducedBySystem = requestedMode === "auto" && Boolean(prefersReducedMotion);
335
343
  const reducedByThreshold = requestedMode === "auto" && thresholdExceeded;
336
344
  const effectiveMode = requestedMode === "off"
@@ -2528,7 +2536,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2528
2536
  context.lineTo(to.x - Math.cos(angle + 0.45) * arrowSize, to.y - Math.sin(angle + 0.45) * arrowSize);
2529
2537
  context.fill();
2530
2538
  }
2531
- if (highlighted) {
2539
+ if (shouldRenderGalaxyEdgeLabel(graph.stats.nodeCount, graph.stats.edgeCount, edgeSelected, highlighted)) {
2532
2540
  const fullLabel = formatRelationshipLabel(edge);
2533
2541
  if (!highlightedKeywordSet.has(fullLabel)) {
2534
2542
  highlightedKeywordSet.add(fullLabel);