@musnows/scriverse 0.8.6 → 0.8.8

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.
@@ -2,6 +2,14 @@ import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate,
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";
5
+ import {
6
+ emptyRoleplayScenePin,
7
+ normalizeRoleplayScenePin,
8
+ parseRoleplayUserTurn,
9
+ roleplayScenePinHasContent,
10
+ roleplayUserTurnDisplayText,
11
+ roleplayUserTurnTitleSource
12
+ } from "/roleplay-turn.js?v=20260823-ai-roleplay-scene-turn-v2";
5
13
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
14
  import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.js?v=20260816-ai-chat-switcher-v2";
7
15
  import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
@@ -16,8 +24,8 @@ import {
16
24
  visibleForeshadowReminders
17
25
  } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
18
26
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
19
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260821-ai-model-thinking-label-v1&feature=ai-provider-responses-v1";
20
- import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
27
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1";
28
+ import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
21
29
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
22
30
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
23
31
  import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
@@ -100,6 +108,7 @@ import { systemStatusPresentation } from "/system-status.js?v=20260801-system-he
100
108
  import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
101
109
  import { createPresenceClientId, stagePresenceClientIdForRelogin } from "/presence-client-id.js?v=20260810-presence-relogin-v1";
102
110
  import { normalizeUploadProgress, uploadProgressText } from "/upload-progress.js?v=20260812-upload-progress-v1";
111
+ import { resolveToastRegionHost } from "/toast-layer.js?v=20260822-toast-modal-host-v1";
103
112
  import { buildGlobalReplaceRefreshPlan, resolveGlobalReplaceChapterCount } from "/global-replace-refresh.js?v=20260812-global-replace-tree-v2";
104
113
  import {
105
114
  clampCropRect,
@@ -461,6 +470,10 @@ function applyWorkAccessMode() {
461
470
  $(".ai-panel").classList.toggle("permission-hidden", aiHidden);
462
471
  $("#ai-prompt").readOnly = aiReadOnly;
463
472
  $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
473
+ $("#ai-scene-direction").readOnly = aiReadOnly;
474
+ $("#ai-scene-location").readOnly = aiReadOnly;
475
+ $("#ai-scene-present").readOnly = aiReadOnly;
476
+ $("#ai-scene-time").readOnly = aiReadOnly;
464
477
  $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
465
478
  renderAiRoleplayCharacterSelect();
466
479
  updateBackgroundTaskCenterVisibility();
@@ -2082,7 +2095,7 @@ function createAiChatTabState(input = {}) {
2082
2095
  roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2083
2096
  citations: input.citations ?? [],
2084
2097
  references: input.references ?? [],
2085
- composer: input.composer ?? { text: "", citations: [], references: [], images: [] },
2098
+ composer: input.composer ?? { text: "", citations: [], references: [], images: [], sceneDirection: "", scenePin: emptyRoleplayScenePin() },
2086
2099
  contextUsage: input.contextUsage ?? null,
2087
2100
  contextWarning: input.contextWarning === true,
2088
2101
  lastMessageAt: input.lastMessageAt ?? null,
@@ -2124,12 +2137,21 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
2124
2137
  text: snapshot.text,
2125
2138
  citations: tab.citations.map((citation) => ({ ...citation })),
2126
2139
  references: tab.references.map((reference) => ({ ...reference })),
2127
- images: normalizeAiChatImageAttachments(snapshot.images)
2140
+ images: normalizeAiChatImageAttachments(snapshot.images),
2141
+ sceneDirection: String(snapshot.sceneDirection ?? ""),
2142
+ scenePin: normalizeRoleplayScenePin(snapshot.scenePin)
2128
2143
  };
2129
2144
  }
2130
2145
 
2131
2146
  function clearAiChatTabComposer(tab) {
2132
- setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [], images: [] });
2147
+ setAiChatTabComposerSnapshot(tab, {
2148
+ text: "",
2149
+ citations: [],
2150
+ references: [],
2151
+ images: [],
2152
+ sceneDirection: "",
2153
+ scenePin: normalizeRoleplayScenePin(tab.composer?.scenePin)
2154
+ });
2133
2155
  }
2134
2156
 
2135
2157
  function applyAiChatTabState(tab) {
@@ -2149,6 +2171,7 @@ function applyAiChatTabState(tab) {
2149
2171
  if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
2150
2172
  syncAiModelPicker();
2151
2173
  setAiPromptText(tab.composer.text);
2174
+ restoreAiSceneComposer(tab.composer);
2152
2175
  renderAiCitations();
2153
2176
  renderAiReferences();
2154
2177
  renderAiImageAttachments();
@@ -2170,7 +2193,9 @@ function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
2170
2193
  && !(tab.composer?.text || "").trim()
2171
2194
  && !(tab.composer?.citations?.length)
2172
2195
  && !(tab.composer?.references?.length)
2173
- && !(tab.composer?.images?.length));
2196
+ && !(tab.composer?.images?.length)
2197
+ && !(tab.composer?.sceneDirection || "").trim()
2198
+ && !roleplayScenePinHasContent(tab.composer?.scenePin));
2174
2199
  }
2175
2200
 
2176
2201
  function aiChatTabOpeningSlot() {
@@ -2443,7 +2468,7 @@ function resetAiFeed(
2443
2468
  const roleplayName = roleplayCharacter?.name;
2444
2469
  const roleplayUserName = roleplayUserCharacter?.name;
2445
2470
  feed.innerHTML = roleplayName
2446
- ? `<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>`
2471
+ ? `<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>`
2447
2472
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2448
2473
  }
2449
2474
 
@@ -2521,8 +2546,9 @@ async function retryAiMessage(message) {
2521
2546
  const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2522
2547
  const userMessage = findAiRetryUserMessage(message);
2523
2548
  const prompt = userMessage?.dataset.copyText ?? "";
2549
+ const sceneDirection = userMessage?.dataset.sceneDirection ?? "";
2524
2550
  const userMessageId = userMessage?.dataset.messageId ?? "";
2525
- if (!sourceTab?.conversationId || !userMessageId || !prompt.trim()) {
2551
+ if (!sourceTab?.conversationId || !userMessageId || !(prompt.trim() || sceneDirection.trim())) {
2526
2552
  toast("找不到需要重试的用户指令", "error");
2527
2553
  return;
2528
2554
  }
@@ -2535,6 +2561,7 @@ async function retryAiMessage(message) {
2535
2561
  retry: {
2536
2562
  message,
2537
2563
  prompt,
2564
+ sceneDirection,
2538
2565
  citations: aiMessageCitations(userMessage),
2539
2566
  images: aiMessageImageAttachments(userMessage),
2540
2567
  userMessageId
@@ -2670,6 +2697,8 @@ const AI_TOOL_DISPLAY_NAMES = {
2670
2697
  recall_self: "回忆自身",
2671
2698
  image: "读取设定图片",
2672
2699
  recall_relationship: "回忆人物关系",
2700
+ recall_other: "回忆相识角色",
2701
+ recall_known: "回忆知情设定",
2673
2702
  recall_story: "回忆故事",
2674
2703
  calculate_time: "计算日期"
2675
2704
  };
@@ -2683,9 +2712,11 @@ const AI_TOOL_DESCRIPTIONS = {
2683
2712
  search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
2684
2713
  recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
2685
2714
  image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
2686
- recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2687
- recall_story: "查询当前作品已保存正文中的关键词,返回匹配段落及章节信息。",
2688
- calculate_time: "计算日期差值,或从起始日期推算目标日期。"
2715
+ recall_relationship: "不传角色列表时读取有关系的角色公开摘要;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2716
+ recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
2717
+ recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
2718
+ recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
2719
+ calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
2689
2720
  };
2690
2721
 
2691
2722
  const aiFeedScrollFrames = new WeakMap();
@@ -3183,7 +3214,7 @@ function renderAiConversationHistory() {
3183
3214
  }
3184
3215
 
3185
3216
  function defaultAiConversationTitle(prompt) {
3186
- const normalized = String(prompt ?? "").replace(/\s+/gu, " ").trim();
3217
+ const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
3187
3218
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
3188
3219
  }
3189
3220
 
@@ -3191,7 +3222,7 @@ function upsertAiConversationSummary(conversation) {
3191
3222
  if (!conversation?.id) return;
3192
3223
  const current = state.aiConversations.find((item) => item.id === conversation.id);
3193
3224
  const lastMessage = Array.isArray(conversation.messages) ? conversation.messages.at(-1) : null;
3194
- const summary = { ...current, ...conversation, ...(lastMessage ? { preview: lastMessage.content } : {}) };
3225
+ const summary = { ...current, ...conversation, ...(lastMessage ? { preview: roleplayUserTurnDisplayText(lastMessage.content) } : {}) };
3195
3226
  delete summary.messages;
3196
3227
  delete summary.messagesPage;
3197
3228
  if (!current && loadedAiConversationsWorkId === state.work?.id) {
@@ -3214,7 +3245,7 @@ function updateAiConversationSummaryFromMessage(message) {
3214
3245
  ...current,
3215
3246
  title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
3216
3247
  messageCount: Number(current.messageCount ?? 0) + 1,
3217
- preview: message.content,
3248
+ preview: roleplayUserTurnDisplayText(message.content),
3218
3249
  ...(aiConversationMessageHasImages(message) ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
3219
3250
  updatedAt: message.createdAt ?? current.updatedAt
3220
3251
  });
@@ -3349,7 +3380,14 @@ function applyConversationToAiChatTab(tab, conversation) {
3349
3380
  tab.roleplayUserCharacter = conversation.roleplayUserCharacter ?? null;
3350
3381
  tab.citations = [];
3351
3382
  tab.references = [];
3352
- tab.composer = { text: "", citations: [], references: [], images: [] };
3383
+ tab.composer = {
3384
+ text: "",
3385
+ citations: [],
3386
+ references: [],
3387
+ images: [],
3388
+ sceneDirection: "",
3389
+ scenePin: normalizeRoleplayScenePin(conversation.scenePin)
3390
+ };
3353
3391
  tab.contextUsage = null;
3354
3392
  tab.contextWarning = conversation.contextWarningPending === true;
3355
3393
  resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
@@ -3400,16 +3438,30 @@ async function createNewAiConversation(taskType = "chat") {
3400
3438
  }
3401
3439
  }
3402
3440
 
3441
+ function favoriteCharactersFirst(characters) {
3442
+ const items = Array.isArray(characters) ? characters : [];
3443
+ return [
3444
+ ...items.filter((character) => character.isFavorite === true),
3445
+ ...items.filter((character) => character.isFavorite !== true)
3446
+ ];
3447
+ }
3448
+
3449
+ function roleplayCharacterOptionLabel(character) {
3450
+ const favoriteLabel = character?.isFavorite === true ? "[已收藏] " : "";
3451
+ const deathLabel = character?.isDead ? "(已死亡)" : "";
3452
+ return `${favoriteLabel}${String(character?.name ?? "")}${deathLabel}`;
3453
+ }
3454
+
3403
3455
  function renderAiRoleplayCharacterSelect() {
3404
3456
  const select = $("#ai-roleplay-character");
3405
3457
  const selectedId = String(state.aiRoleplayCharacter?.id ?? "");
3406
- const availableCharacters = state.characters.filter((character) => !character.mergedIntoCharacterId);
3458
+ const availableCharacters = favoriteCharactersFirst(state.characters.filter((character) => !character.mergedIntoCharacterId));
3407
3459
  const options = [{ id: "", name: "选择角色卡" }, ...availableCharacters.map((character) => ({
3408
3460
  id: String(character.id),
3409
- name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
3461
+ name: roleplayCharacterOptionLabel(character)
3410
3462
  }))];
3411
3463
  if (selectedId && !options.some((option) => option.id === selectedId)) {
3412
- options.push({ id: selectedId, name: String(state.aiRoleplayCharacter.name) });
3464
+ options.push({ id: selectedId, name: roleplayCharacterOptionLabel(state.aiRoleplayCharacter) });
3413
3465
  }
3414
3466
  select.replaceChildren(...options.map((option) => {
3415
3467
  const element = document.createElement("option");
@@ -3425,7 +3477,7 @@ function renderAiRoleplayCharacterSelect() {
3425
3477
  select.title = canSelectCharacter
3426
3478
  ? state.aiPromptSent
3427
3479
  ? aiConversationOptionLockedMessage
3428
- : "为当前对话选择角色卡;角色扮演时 Agent 可以查询角色记忆、人物关系和故事正文"
3480
+ : "为当前对话选择角色卡;角色扮演时 Agent 可以查询角色记忆、相识角色、知情设定、故事正文和设定图片"
3429
3481
  : "当前账户没有角色模块读取权限";
3430
3482
  renderAiRoleplayUserCharacterSelect();
3431
3483
  }
@@ -3434,15 +3486,15 @@ function renderAiRoleplayUserCharacterSelect() {
3434
3486
  const select = $("#ai-roleplay-user-character");
3435
3487
  const selectedId = String(state.aiRoleplayUserCharacter?.id ?? "");
3436
3488
  const aiCharacterId = String(state.aiRoleplayCharacter?.id ?? "");
3437
- const availableCharacters = state.characters.filter((character) => (
3489
+ const availableCharacters = favoriteCharactersFirst(state.characters.filter((character) => (
3438
3490
  !character.mergedIntoCharacterId && String(character.id) !== aiCharacterId
3439
- ));
3491
+ )));
3440
3492
  const options = [{ id: "", name: "选择我的角色(可选)" }, ...availableCharacters.map((character) => ({
3441
3493
  id: String(character.id),
3442
- name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
3494
+ name: roleplayCharacterOptionLabel(character)
3443
3495
  }))];
3444
3496
  if (selectedId && !options.some((option) => option.id === selectedId)) {
3445
- options.push({ id: selectedId, name: String(state.aiRoleplayUserCharacter.name) });
3497
+ options.push({ id: selectedId, name: roleplayCharacterOptionLabel(state.aiRoleplayUserCharacter) });
3446
3498
  }
3447
3499
  select.replaceChildren(...options.map((option) => {
3448
3500
  const element = document.createElement("option");
@@ -3461,7 +3513,7 @@ function renderAiRoleplayUserCharacterSelect() {
3461
3513
  ? "请先选择 AI 扮演的角色"
3462
3514
  : state.aiPromptSent
3463
3515
  ? aiConversationOptionLockedMessage
3464
- : "选择后,AI 会将每条用户消息视为该角色的发言或行动";
3516
+ : "选择后,主输入是该角色的台词或行动;旁白请写在场景框";
3465
3517
  }
3466
3518
 
3467
3519
  const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
@@ -3600,8 +3652,9 @@ function syncAiTaskOptions() {
3600
3652
  $("#ai-scope").disabled = interactionBusy || roleplaySelected;
3601
3653
  $("#ai-scope").title = state.aiPromptSent
3602
3654
  ? aiConversationOptionLockedMessage
3603
- : roleplaySelected ? "角色扮演模式可以查询角色记忆、人物关系和故事正文" : "";
3655
+ : roleplaySelected ? "角色扮演模式可以查询角色记忆、相识角色、知情设定、故事正文和设定图片" : "";
3604
3656
  syncAiModelPicker();
3657
+ syncAiSceneComposer();
3605
3658
  }
3606
3659
 
3607
3660
  function applyAiConversationTaskType(taskType) {
@@ -3638,8 +3691,8 @@ function applyAiRoleplayCharacter(character, userCharacter = null) {
3638
3691
  $(".ai-panel").classList.toggle("is-roleplaying", active);
3639
3692
  $("#ai-prompt").dataset.placeholder = active
3640
3693
  ? state.aiRoleplayUserCharacter
3641
- ? `以 ${String(state.aiRoleplayUserCharacter.name)} 的身份与 ${String(state.aiRoleplayCharacter.name)} 对话……`
3642
- : `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
3694
+ ? `以 ${String(state.aiRoleplayUserCharacter.name)} 的身份输入台词或行动……`
3695
+ : `输入对 ${String(state.aiRoleplayCharacter.name)} 说的台词或行动……`
3643
3696
  : "告诉 AI 你想讨论或修改什么……";
3644
3697
  renderAiRoleplayCharacterSelect();
3645
3698
  syncAiTaskOptions();
@@ -3946,9 +3999,11 @@ function clearAiPromptComposer() {
3946
3999
  state.aiReferences = [];
3947
4000
  state.aiImageAttachments = [];
3948
4001
  setAiPromptText("");
4002
+ setAiSceneDirection("");
3949
4003
  renderAiCitations();
3950
4004
  renderAiImageAttachments();
3951
4005
  hideAiMentionMenu();
4006
+ syncAiSceneComposer();
3952
4007
  }
3953
4008
 
3954
4009
  function captureAiPromptComposer() {
@@ -3956,7 +4011,9 @@ function captureAiPromptComposer() {
3956
4011
  text: aiPromptText(),
3957
4012
  citations: state.aiCitations.map((citation) => ({ ...citation })),
3958
4013
  references: state.aiReferences.map((reference) => ({ ...reference })),
3959
- images: normalizeAiChatImageAttachments(state.aiImageAttachments)
4014
+ images: normalizeAiChatImageAttachments(state.aiImageAttachments),
4015
+ sceneDirection: aiSceneDirectionText(),
4016
+ scenePin: captureAiScenePin()
3960
4017
  };
3961
4018
  }
3962
4019
 
@@ -3965,11 +4022,77 @@ function restoreAiPromptComposer(snapshot) {
3965
4022
  state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
3966
4023
  state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
3967
4024
  setAiPromptText(snapshot.text);
4025
+ restoreAiSceneComposer(snapshot);
3968
4026
  renderAiCitations();
3969
4027
  renderAiImageAttachments();
3970
4028
  hideAiMentionMenu();
3971
4029
  }
3972
4030
 
4031
+ function aiSceneDirectionText() {
4032
+ return String($("#ai-scene-direction")?.value ?? "");
4033
+ }
4034
+
4035
+ function setAiSceneDirection(value) {
4036
+ const input = $("#ai-scene-direction");
4037
+ if (input) input.value = String(value ?? "");
4038
+ }
4039
+
4040
+ function captureAiScenePin() {
4041
+ return normalizeRoleplayScenePin({
4042
+ location: $("#ai-scene-location")?.value ?? "",
4043
+ present: $("#ai-scene-present")?.value ?? "",
4044
+ timeLabel: $("#ai-scene-time")?.value ?? ""
4045
+ });
4046
+ }
4047
+
4048
+ function setAiScenePin(pin) {
4049
+ const normalized = normalizeRoleplayScenePin(pin);
4050
+ const location = $("#ai-scene-location");
4051
+ const present = $("#ai-scene-present");
4052
+ const timeLabel = $("#ai-scene-time");
4053
+ if (location) location.value = normalized.location;
4054
+ if (present) present.value = normalized.present;
4055
+ if (timeLabel) timeLabel.value = normalized.timeLabel;
4056
+ }
4057
+
4058
+ function restoreAiSceneComposer(snapshot = {}) {
4059
+ setAiSceneDirection(snapshot.sceneDirection ?? "");
4060
+ setAiScenePin(snapshot.scenePin);
4061
+ syncAiSceneComposer();
4062
+ }
4063
+
4064
+ function roleplaySceneComposerVisible() {
4065
+ return $("#ai-task").value === "roleplay" && Boolean(state.aiRoleplayCharacter);
4066
+ }
4067
+
4068
+ function syncAiSceneComposer() {
4069
+ const button = $("#ai-scene-button");
4070
+ const panel = $("#ai-scene-panel");
4071
+ if (!button || !panel) return;
4072
+ const visible = roleplaySceneComposerVisible();
4073
+ const busy = aiInteractionBusy();
4074
+ const readOnly = Boolean(state.work) && !canWritePermissionModule(state.work, "ai-chat");
4075
+ button.classList.toggle("hidden", !visible);
4076
+ button.disabled = !visible || busy || readOnly;
4077
+ button.setAttribute("aria-hidden", String(!visible));
4078
+ if (!visible) {
4079
+ panel.classList.add("hidden");
4080
+ button.setAttribute("aria-expanded", "false");
4081
+ }
4082
+ const hasContent = Boolean(aiSceneDirectionText().trim()) || roleplayScenePinHasContent(captureAiScenePin());
4083
+ button.classList.toggle("is-active", hasContent);
4084
+ }
4085
+
4086
+ function toggleAiScenePanel() {
4087
+ const button = $("#ai-scene-button");
4088
+ const panel = $("#ai-scene-panel");
4089
+ if (!button || !panel || button.classList.contains("hidden")) return;
4090
+ const willOpen = panel.classList.contains("hidden");
4091
+ panel.classList.toggle("hidden", !willOpen);
4092
+ button.setAttribute("aria-expanded", String(willOpen));
4093
+ if (willOpen) $("#ai-scene-direction")?.focus();
4094
+ }
4095
+
3973
4096
  function aiPromptTextBeforeCursor() {
3974
4097
  const prompt = $("#ai-prompt");
3975
4098
  const selection = window.getSelection();
@@ -5109,8 +5232,18 @@ async function initializeAuthentication() {
5109
5232
  return true;
5110
5233
  }
5111
5234
 
5235
+ function syncToastRegionHost() {
5236
+ const region = $("#toast-region");
5237
+ const host = resolveToastRegionHost([...document.querySelectorAll("dialog[open]")], document.body);
5238
+ if (!host || region.parentElement === host) return false;
5239
+ if (typeof region.hidePopover === "function" && region.matches(":popover-open")) region.hidePopover();
5240
+ host.append(region);
5241
+ return true;
5242
+ }
5243
+
5112
5244
  function raiseToastRegion() {
5113
5245
  const region = $("#toast-region");
5246
+ syncToastRegionHost();
5114
5247
  if (typeof region.showPopover !== "function") return;
5115
5248
  if (region.matches(":popover-open")) region.hidePopover();
5116
5249
  region.showPopover();
@@ -5263,9 +5396,6 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
5263
5396
  confirm.textContent = confirmLabel;
5264
5397
  actions.append(cancel, confirm);
5265
5398
  element.append(heading, description, actions);
5266
- region.append(element);
5267
- raiseToastRegion();
5268
- cancel.focus();
5269
5399
  return new Promise((resolve) => {
5270
5400
  const finish = (confirmed) => {
5271
5401
  element.remove();
@@ -5280,6 +5410,9 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
5280
5410
  event.preventDefault();
5281
5411
  finish(false);
5282
5412
  });
5413
+ region.append(element);
5414
+ raiseToastRegion();
5415
+ cancel.focus();
5283
5416
  });
5284
5417
  }
5285
5418
 
@@ -5340,8 +5473,15 @@ function inputToast(message, { title = "请输入", inputLabel = title, value =
5340
5473
 
5341
5474
  document.addEventListener("toggle", (event) => {
5342
5475
  const target = event.target;
5343
- if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
5476
+ if (!(target instanceof HTMLDialogElement)) return;
5477
+ const region = $("#toast-region");
5478
+ if (target.open && region.childElementCount) {
5344
5479
  raiseToastRegion();
5480
+ return;
5481
+ }
5482
+ if (!target.open && (region.parentElement !== document.body || region.childElementCount)) {
5483
+ const moved = syncToastRegionHost();
5484
+ if (moved && region.childElementCount && typeof region.showPopover === "function") region.showPopover();
5345
5485
  }
5346
5486
  }, true);
5347
5487
 
@@ -6986,6 +7126,7 @@ async function showSettingsHub() {
6986
7126
  state.dirty = false;
6987
7127
  }
6988
7128
  dismissChapterInsightToast();
7129
+ dismissTokenUsageDetails({ restoreFocus: false });
6989
7130
  dismissDeleteToasts();
6990
7131
  updateDocumentTitle(state.work);
6991
7132
  $("#app").classList.add("shelf-mode");
@@ -7034,6 +7175,7 @@ async function showPlatformAi() {
7034
7175
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入平台 AI 管理将放弃本地修改。是否继续?"))) return false;
7035
7176
  state.dirty = false;
7036
7177
  dismissChapterInsightToast();
7178
+ dismissTokenUsageDetails({ restoreFocus: false });
7037
7179
  dismissDeleteToasts();
7038
7180
  updateDocumentTitle();
7039
7181
  $("#app").classList.add("shelf-mode");
@@ -7057,6 +7199,7 @@ async function showPlatformUsage() {
7057
7199
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入 Token 用量面板将放弃本地修改。是否继续?"))) return false;
7058
7200
  state.dirty = false;
7059
7201
  dismissChapterInsightToast();
7202
+ dismissTokenUsageDetails({ restoreFocus: false });
7060
7203
  dismissDeleteToasts();
7061
7204
  updateDocumentTitle();
7062
7205
  $("#app").classList.add("shelf-mode");
@@ -8530,6 +8673,116 @@ function pencilIconMarkup() {
8530
8673
  return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"></path><path d="m16.5 3.5 1.4-1.4a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4L16.5 3.5Z"></path></svg>';
8531
8674
  }
8532
8675
 
8676
+ function characterFavoriteIconMarkup() {
8677
+ return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"></path></svg>';
8678
+ }
8679
+
8680
+ function characterFavoriteButton(item) {
8681
+ const isFavorite = item.isFavorite === true;
8682
+ const canFavorite = canEditModule("characters");
8683
+ const action = isFavorite ? "取消收藏" : "收藏";
8684
+ const title = canFavorite ? action : "当前账户没有角色模块写入权限";
8685
+ return `<button class="character-favorite-button${isFavorite ? " is-favorite" : ""}" type="button" data-character-favorite="${esc(item.id)}" aria-label="${action}角色“${esc(item.name)}”" aria-pressed="${isFavorite}" title="${title}" ${canFavorite ? "" : "disabled"}>${characterFavoriteIconMarkup()}</button>`;
8686
+ }
8687
+
8688
+ const recordFavoriteConfigs = Object.freeze({
8689
+ character: { module: "characters", resource: "characters", label: "角色", nameField: "name" },
8690
+ draft: { module: "drafts", resource: "drafts", label: "想法", nameField: "title" },
8691
+ setting: { module: "settings", resource: "settings", label: "设定", nameField: "title" },
8692
+ organization: { module: "organizations", resource: "organizations", label: "组织", nameField: "name" }
8693
+ });
8694
+
8695
+ function recordFavoriteButton(type, item, { cardControl = false } = {}) {
8696
+ const config = recordFavoriteConfigs[type];
8697
+ if (!config) return "";
8698
+ const isFavorite = item.isFavorite === true;
8699
+ const canFavorite = canEditModule(config.module);
8700
+ const action = isFavorite ? "取消收藏" : "收藏";
8701
+ const title = canFavorite ? action : `当前账户没有${config.label}模块写入权限`;
8702
+ const name = String(item[config.nameField] ?? "");
8703
+ return `<button class="record-favorite-button${cardControl ? " is-card-control" : ""}${isFavorite ? " is-favorite" : ""}" type="button" data-record-favorite="${esc(item.id)}" data-record-favorite-type="${esc(type)}" aria-label="${action}${config.label}“${esc(name)}”" aria-pressed="${isFavorite}" title="${title}" ${canFavorite ? "" : "disabled"}>${characterFavoriteIconMarkup()}</button>`;
8704
+ }
8705
+
8706
+ async function toggleRecordFavorite(type, item, render) {
8707
+ const config = recordFavoriteConfigs[type];
8708
+ if (!config) return;
8709
+ const updated = await api(`/api/${config.resource}/${encodeURIComponent(item.id)}/favorite`, {
8710
+ method: "PATCH",
8711
+ body: { isFavorite: item.isFavorite !== true }
8712
+ });
8713
+ await render();
8714
+ const favoriteButton = [...$("#module-content").querySelectorAll("[data-record-favorite]")].find((button) => (
8715
+ button.dataset.recordFavoriteType === type && button.dataset.recordFavorite === String(updated.id)
8716
+ ));
8717
+ favoriteButton?.focus({ preventScroll: true });
8718
+ const name = String(updated[config.nameField] ?? "");
8719
+ toast(updated.isFavorite ? `已收藏${config.label}“${name}”` : `已取消收藏${config.label}“${name}”`);
8720
+ }
8721
+
8722
+ function bindRecordFavoriteButtons(type, items, render) {
8723
+ const config = recordFavoriteConfigs[type];
8724
+ if (!config) return;
8725
+ $("#module-content").querySelectorAll(`[data-record-favorite-type="${type}"]`).forEach((button) => button.addEventListener("click", async () => {
8726
+ const item = items.find((candidate) => candidate.id === button.dataset.recordFavorite);
8727
+ if (!item || !canEditModule(config.module)) return;
8728
+ button.disabled = true;
8729
+ try {
8730
+ await toggleRecordFavorite(type, item, render);
8731
+ } catch (error) {
8732
+ button.disabled = false;
8733
+ toast(`${config.label}收藏状态更新失败:${error.message}`, "error");
8734
+ }
8735
+ }));
8736
+ }
8737
+
8738
+ function syncEntityDetailFavoriteButton(button, type, item) {
8739
+ const config = recordFavoriteConfigs[type];
8740
+ const visible = Boolean(config && item);
8741
+ button.classList.toggle("hidden", !visible);
8742
+ button.innerHTML = characterFavoriteIconMarkup();
8743
+ if (!visible) {
8744
+ button.disabled = true;
8745
+ button.classList.remove("is-favorite");
8746
+ button.setAttribute("aria-pressed", "false");
8747
+ button.removeAttribute("aria-label");
8748
+ button.removeAttribute("title");
8749
+ return;
8750
+ }
8751
+ const isFavorite = item.isFavorite === true;
8752
+ const canFavorite = canEditModule(config.module);
8753
+ const action = isFavorite ? "取消收藏" : "收藏";
8754
+ const name = String(item[config.nameField] ?? "");
8755
+ button.classList.toggle("is-favorite", isFavorite);
8756
+ button.setAttribute("aria-pressed", String(isFavorite));
8757
+ button.setAttribute("aria-label", `${action}${config.label}“${name}”`);
8758
+ button.title = canFavorite ? action : `当前账户没有${config.label}模块写入权限`;
8759
+ button.disabled = !canFavorite;
8760
+ }
8761
+
8762
+ function bindEntityDetailFavoriteButton(button, type, getItem, onUpdated) {
8763
+ syncEntityDetailFavoriteButton(button, type, getItem());
8764
+ button.onclick = async () => {
8765
+ const config = recordFavoriteConfigs[type];
8766
+ const item = getItem();
8767
+ if (!config || !item || !canEditModule(config.module)) return;
8768
+ button.disabled = true;
8769
+ try {
8770
+ const updated = await api(`/api/${config.resource}/${encodeURIComponent(item.id)}/favorite`, {
8771
+ method: "PATCH",
8772
+ body: { isFavorite: item.isFavorite !== true }
8773
+ });
8774
+ onUpdated(updated);
8775
+ syncEntityDetailFavoriteButton(button, type, getItem() ?? updated);
8776
+ button.focus({ preventScroll: true });
8777
+ const name = String(updated[config.nameField] ?? "");
8778
+ toast(updated.isFavorite ? `已收藏${config.label}“${name}”` : `已取消收藏${config.label}“${name}”`);
8779
+ } catch (error) {
8780
+ syncEntityDetailFavoriteButton(button, type, getItem());
8781
+ toast(`${config.label}收藏状态更新失败:${error instanceof Error ? error.message : "未知错误"}`, "error");
8782
+ }
8783
+ };
8784
+ }
8785
+
8533
8786
  function recordCardEditButton(attribute, id, label) {
8534
8787
  return `<button class="record-card-edit" type="button" data-${attribute}="${esc(id)}" aria-label="编辑${esc(label)}" title="编辑">${pencilIconMarkup()}</button>`;
8535
8788
  }
@@ -8865,9 +9118,10 @@ function openReviewDetailDialog(item) {
8865
9118
  }
8866
9119
 
8867
9120
  function settingRecordActions(item) {
8868
- return canEditModule("settings")
9121
+ const recordAction = canEditModule("settings")
8869
9122
  ? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
8870
9123
  : recordHistoryButton("setting", item.id, item.title);
9124
+ return `${recordFavoriteButton("setting", item)}${recordAction}`;
8871
9125
  }
8872
9126
 
8873
9127
  function draftTypeLabel(draftType) {
@@ -8921,6 +9175,7 @@ async function deleteDraft(item) {
8921
9175
  }
8922
9176
 
8923
9177
  function openDraftDialog(item = null, { readOnly = false } = {}) {
9178
+ let draftDialogItem = item;
8924
9179
  const viewOnly = readOnly || !canEditModule("drafts");
8925
9180
  const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
8926
9181
  const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
@@ -8978,6 +9233,18 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
8978
9233
  else control.readOnly = true;
8979
9234
  });
8980
9235
  }
9236
+ if (item && viewOnly) {
9237
+ const headerActions = $("#dialog-context-actions");
9238
+ headerActions.innerHTML = `<button id="draft-dialog-favorite" class="entity-detail-favorite-button" type="button" aria-pressed="false"></button>${canEditModule("drafts") ? '<button id="draft-dialog-edit" class="ghost-button" type="button">编辑想法</button>' : ""}`;
9239
+ bindEntityDetailFavoriteButton($("#draft-dialog-favorite"), "draft", () => draftDialogItem, (updated) => {
9240
+ draftDialogItem = updated;
9241
+ void renderDrafts(moduleListPages.drafts).catch((error) => toast(`想法列表刷新失败:${error instanceof Error ? error.message : "未知错误"}`, "error"));
9242
+ });
9243
+ $("#draft-dialog-edit")?.addEventListener("click", () => {
9244
+ $("#form-dialog").close();
9245
+ openDraftDialog(draftDialogItem);
9246
+ });
9247
+ }
8981
9248
  }
8982
9249
 
8983
9250
  async function renderDrafts(page = moduleListPages.drafts) {
@@ -9011,9 +9278,9 @@ async function renderDrafts(page = moduleListPages.drafts) {
9011
9278
  <details class="character-filter-dropdown"><summary><span>按绑定位置筛选</span><strong>${selectedBindingKeys.size ? `已选 ${selectedBindingKeys.size} 项` : "全部位置"}</strong></summary><div id="draft-binding-filter" class="character-filter-options">${bindingOptions.map(([value, label]) => `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedBindingKeys.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`).join("")}</div></details>
9012
9279
  <div class="character-filter-toolbar-actions">${hasDraftFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>` : ""}<button id="clear-draft-filters" class="ghost-button" type="button" ${hasDraftFilters ? "" : "disabled"}>重置筛选</button></div>
9013
9280
  </section>`;
9014
- const actions = (item) => canEditModule("drafts")
9281
+ const actions = (item) => `${recordFavoriteButton("draft", item)}${canEditModule("drafts")
9015
9282
  ? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
9016
- : recordHistoryButton("draft", item.id, item.title);
9283
+ : recordHistoryButton("draft", item.id, item.title)}`;
9017
9284
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
9018
9285
  <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
9019
9286
  <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
@@ -9069,6 +9336,10 @@ async function renderDrafts(page = moduleListPages.drafts) {
9069
9336
  $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
9070
9337
  openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
9071
9338
  }));
9339
+ bindRecordFavoriteButtons("draft", pageResult.items, async () => {
9340
+ moduleListPages.drafts = 1;
9341
+ await renderDrafts(1);
9342
+ });
9072
9343
  bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
9073
9344
  }
9074
9345
 
@@ -9132,6 +9403,10 @@ async function renderSettings(page = moduleListPages.settings) {
9132
9403
  bindModulePagination("settings", renderSettingResults);
9133
9404
  const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
9134
9405
  $("#setting-filter-results").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
9406
+ bindRecordFavoriteButtons("setting", pageResult.items, async () => {
9407
+ moduleListPages.settings = 1;
9408
+ await renderSettings(1);
9409
+ });
9135
9410
  bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
9136
9411
  };
9137
9412
 
@@ -9168,6 +9443,21 @@ async function renderSettings(page = moduleListPages.settings) {
9168
9443
  renderSettingResults(page);
9169
9444
  }
9170
9445
 
9446
+ async function toggleCharacterFavorite(item) {
9447
+ const updated = await api(`/api/characters/${encodeURIComponent(item.id)}/favorite`, {
9448
+ method: "PATCH",
9449
+ body: { isFavorite: item.isFavorite !== true }
9450
+ });
9451
+ if (loadedAiReferencesWorkId === state.work?.id) {
9452
+ state.characters = upsertEntityCollection(state.characters, updated);
9453
+ renderAiRoleplayCharacterSelect();
9454
+ }
9455
+ characterListPage = 1;
9456
+ await renderCharacters(1);
9457
+ $("#module-content").querySelector(`[data-character-favorite="${CSS.escape(String(updated.id))}"]`)?.focus({ preventScroll: true });
9458
+ toast(updated.isFavorite ? `已收藏角色“${updated.name}”` : `已取消收藏角色“${updated.name}”`);
9459
+ }
9460
+
9171
9461
  async function renderCharacters(page = characterListPage) {
9172
9462
  const hasCharacterFilters = characterFilters.raceIds.length > 0
9173
9463
  || characterFilters.organizationIds.length > 0
@@ -9190,14 +9480,14 @@ async function renderCharacters(page = characterListPage) {
9190
9480
  [state.races, state.organizations] = [races, organizations];
9191
9481
  mountModuleCount(characterPage.total);
9192
9482
  const layout = readModuleLayout();
9193
- const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
9483
+ const characterActions = (item) => `${characterFavoriteButton(item)}${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}`;
9194
9484
  const characterLockBadge = (item) => item.lockedFields.length
9195
9485
  ? `<span class="character-lock-badge" aria-label="${item.lockedFields.length} 个锁定字段" title="锁定字段:${esc(item.lockedFields.join("、"))}"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg><span>${item.lockedFields.length}</span></span>`
9196
9486
  : "";
9197
9487
  const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
9198
9488
  const details = normalizeCharacterDetails(item.attributes?.details);
9199
9489
  return `
9200
- <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
9490
+ <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${characterFavoriteButton(item)}${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
9201
9491
  <div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
9202
9492
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
9203
9493
  <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
@@ -9263,6 +9553,17 @@ async function renderCharacters(page = characterListPage) {
9263
9553
  ? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
9264
9554
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
9265
9555
  bindModuleLayoutToggle(renderCharacters);
9556
+ $("#module-content").querySelectorAll("[data-character-favorite]").forEach((button) => button.addEventListener("click", async () => {
9557
+ const item = pageCharacters.find((candidate) => candidate.id === button.dataset.characterFavorite);
9558
+ if (!item || !canEditModule("characters")) return;
9559
+ button.disabled = true;
9560
+ try {
9561
+ await toggleCharacterFavorite(item);
9562
+ } catch (error) {
9563
+ button.disabled = false;
9564
+ toast(`角色收藏状态更新失败:${error.message}`, "error");
9565
+ }
9566
+ }));
9266
9567
  const readSelectedValues = (selector) => [...$(selector).querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
9267
9568
  $("#character-race-filter").addEventListener("change", async () => {
9268
9569
  characterFiltersPanelOpen = true;
@@ -9417,14 +9718,14 @@ async function renderOrganizations(page = moduleListPages.organizations) {
9417
9718
  moduleListPages.organizations = pageResult.page;
9418
9719
  const layout = readModuleLayout();
9419
9720
  const canEditOrganizations = canEditModule("organizations");
9420
- const organizationActions = (item) => canEditOrganizations
9721
+ const organizationActions = (item, { cardControl = false } = {}) => `${recordFavoriteButton("organization", item, { cardControl })}${canEditOrganizations
9421
9722
  ? recordCardEditButton("edit-organization", item.id, `组织“${item.name}”`)
9422
- : recordHistoryButton("organization", item.id, item.name);
9723
+ : recordHistoryButton("organization", item.id, item.name)}`;
9423
9724
  const organizationCardActions = (item) => canEditOrganizations
9424
- ? organizationActions(item)
9725
+ ? organizationActions(item, { cardControl: true })
9425
9726
  : `<div class="card-actions">${organizationActions(item)}</div>`;
9426
9727
  const organizationCards = () => `<div class="card-grid organization-grid">${pageResult.items.map((item) => `
9427
- <article class="record-card organization-card preview-record-card${canEditOrganizations ? " has-card-edit" : ""}" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}"><small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
9728
+ <article class="record-card organization-card preview-record-card${canEditOrganizations ? " has-card-edit has-favorite-control" : ""}" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}"><small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
9428
9729
  <h3>${esc(item.name)}${entityLifecycleBadge(item.isDissolved, "已解散")}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
9429
9730
  <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
9430
9731
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
@@ -9449,6 +9750,10 @@ async function renderOrganizations(page = moduleListPages.organizations) {
9449
9750
  bindModulePagination("organizations", renderOrganizations);
9450
9751
  const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
9451
9752
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => { void openOrganization(button.dataset.editOrganization, false); }));
9753
+ bindRecordFavoriteButtons("organization", pageResult.items, async () => {
9754
+ moduleListPages.organizations = 1;
9755
+ await renderOrganizations(1);
9756
+ });
9452
9757
  bindEntityHistoryButtons(async () => { await renderOrganizations(pageResult.page); await loadAiReferences(); });
9453
9758
  }
9454
9759
 
@@ -11705,6 +12010,138 @@ function scrollUsageCalendarsToLatest(root) {
11705
12010
  });
11706
12011
  }
11707
12012
 
12013
+ let tokenUsageDetailsTrigger = null;
12014
+
12015
+ function tokenUsageDetailCount(value) {
12016
+ return Math.max(0, Math.round(Number(value) || 0)).toLocaleString("zh-CN");
12017
+ }
12018
+
12019
+ function dismissTokenUsageDetails({ restoreFocus = true } = {}) {
12020
+ const region = $("#toast-region");
12021
+ const element = region.querySelector("#token-usage-details-toast");
12022
+ if (!element) return;
12023
+ element.remove();
12024
+ const trigger = tokenUsageDetailsTrigger;
12025
+ tokenUsageDetailsTrigger = null;
12026
+ region.classList.remove("token-usage-details-region");
12027
+ if (trigger) {
12028
+ trigger.setAttribute("aria-expanded", "false");
12029
+ if (restoreFocus && trigger.isConnected) trigger.focus({ preventScroll: true });
12030
+ }
12031
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
12032
+ region.hidePopover();
12033
+ }
12034
+ }
12035
+
12036
+ function appendTokenUsageDetailRow(parent, label, usage, isSummary = false) {
12037
+ const row = document.createElement("tr");
12038
+ if (isSummary) row.className = "is-summary";
12039
+ const model = document.createElement("th");
12040
+ model.scope = "row";
12041
+ model.textContent = label;
12042
+ row.append(model);
12043
+ ["directInputTokens", "cacheWriteInputTokens", "cacheReadInputTokens", "outputTokens", "totalTokens", "requestCount", "estimatedRequestCount"].forEach((key) => {
12044
+ const cell = document.createElement("td");
12045
+ cell.textContent = tokenUsageDetailCount(usage?.[key]);
12046
+ row.append(cell);
12047
+ });
12048
+ const price = document.createElement("td");
12049
+ price.textContent = formatEstimatedCost(usage?.estimatedCost);
12050
+ row.append(price);
12051
+ parent.append(row);
12052
+ }
12053
+
12054
+ function showTokenUsageDetails(usage, title, trigger) {
12055
+ dismissTokenUsageDetails({ restoreFocus: false });
12056
+ const region = $("#toast-region");
12057
+ const element = document.createElement("section");
12058
+ element.id = "token-usage-details-toast";
12059
+ element.className = "toast token-usage-details-toast";
12060
+ element.setAttribute("role", "dialog");
12061
+ element.setAttribute("aria-labelledby", "token-usage-details-title");
12062
+ element.setAttribute("aria-describedby", "token-usage-details-description");
12063
+
12064
+ const header = document.createElement("header");
12065
+ header.className = "token-usage-details-header";
12066
+ const heading = document.createElement("div");
12067
+ heading.className = "token-usage-details-heading";
12068
+ const headingTitle = document.createElement("strong");
12069
+ headingTitle.id = "token-usage-details-title";
12070
+ headingTitle.textContent = "Token 用量详细数据";
12071
+ const headingScope = document.createElement("span");
12072
+ headingScope.textContent = title;
12073
+ heading.append(headingTitle, headingScope);
12074
+ const close = document.createElement("button");
12075
+ close.className = "ghost-button token-usage-details-close";
12076
+ close.type = "button";
12077
+ close.textContent = "关闭";
12078
+ close.setAttribute("aria-label", "关闭 Token 用量详细数据");
12079
+ header.append(heading, close);
12080
+
12081
+ const body = document.createElement("div");
12082
+ body.className = "token-usage-details-body";
12083
+ const description = document.createElement("p");
12084
+ description.id = "token-usage-details-description";
12085
+ description.className = "token-usage-details-note";
12086
+ description.textContent = "Raw Input 为不含缓存的输入 Token;Cache Write 与 Cache Read 也是输入组成部分,并已包含在总计中。估算价格按美元显示,未匹配价格的模型显示为暂无价格。汇总行包含当前范围内全部模型。";
12087
+ body.append(description);
12088
+
12089
+ const tableScroll = document.createElement("div");
12090
+ tableScroll.className = "token-usage-details-table-scroll";
12091
+ const table = document.createElement("table");
12092
+ table.className = "token-usage-details-table";
12093
+ const caption = document.createElement("caption");
12094
+ caption.textContent = `${title}的模型 Token 用量`;
12095
+ table.append(caption);
12096
+ const head = document.createElement("thead");
12097
+ const headRow = document.createElement("tr");
12098
+ ["模型", "Raw Input", "Cache Write", "Cache Read", "Output", "总计", "调用", "估算调用", "估算价格"].forEach((label) => {
12099
+ const cell = document.createElement("th");
12100
+ cell.scope = "col";
12101
+ cell.textContent = label;
12102
+ headRow.append(cell);
12103
+ });
12104
+ head.append(headRow);
12105
+ table.append(head);
12106
+ const bodyRows = document.createElement("tbody");
12107
+ const models = Array.isArray(usage?.models) ? usage.models : [];
12108
+ models.forEach((model) => appendTokenUsageDetailRow(bodyRows, model.modelId || "未指定模型", model));
12109
+ if (!models.length) {
12110
+ const emptyRow = document.createElement("tr");
12111
+ const emptyCell = document.createElement("td");
12112
+ emptyCell.colSpan = 9;
12113
+ emptyCell.textContent = "还没有模型用量记录。";
12114
+ emptyRow.append(emptyCell);
12115
+ bodyRows.append(emptyRow);
12116
+ }
12117
+ table.append(bodyRows);
12118
+ const foot = document.createElement("tfoot");
12119
+ appendTokenUsageDetailRow(foot, "汇总", usage?.summary ?? {}, true);
12120
+ table.append(foot);
12121
+ tableScroll.append(table);
12122
+ body.append(tableScroll);
12123
+ element.append(header, body);
12124
+
12125
+ close.addEventListener("click", () => dismissTokenUsageDetails());
12126
+ element.addEventListener("keydown", (event) => {
12127
+ if (event.key !== "Escape") return;
12128
+ event.preventDefault();
12129
+ dismissTokenUsageDetails();
12130
+ });
12131
+ region.append(element);
12132
+ region.classList.add("token-usage-details-region");
12133
+ tokenUsageDetailsTrigger = trigger;
12134
+ trigger.setAttribute("aria-expanded", "true");
12135
+ raiseToastRegion();
12136
+ close.focus({ preventScroll: true });
12137
+ }
12138
+
12139
+ function bindTokenUsageDetails(host, usage, title) {
12140
+ const button = host.querySelector("[data-token-usage-details]");
12141
+ if (!button) return;
12142
+ button.addEventListener("click", () => showTokenUsageDetails(usage, title, button));
12143
+ }
12144
+
11708
12145
  function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false } = {}) {
11709
12146
  const summary = usage?.summary ?? {};
11710
12147
  const totalTokens = Number(summary.totalTokens) || 0;
@@ -11721,7 +12158,7 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
11721
12158
  ? "供应商尚未返回可计算的缓存明细"
11722
12159
  : `${cachedInputTokens.toLocaleString("zh-CN")} / ${cacheEligibleInputTokens.toLocaleString("zh-CN")} 个可统计输入 Token 命中缓存`;
11723
12160
  const estimatedCost = formatEstimatedCost(summary.estimatedCost);
11724
- const pricingDescription = "根据 LiteLLM 模型 ID 价格表估算,价格单位为美元;未匹配模型不计入。";
12161
+ const pricingDescription = "根据多来源模型价格表估算,价格单位为美元;未匹配模型不计入。";
11725
12162
  const pricingBadge = summary.pricingAvailable === true && summary.estimatedCost !== null && summary.estimatedCost !== undefined
11726
12163
  ? `<span class="usage-cost-bubble" title="${esc(pricingDescription)}">估价 ${esc(estimatedCost)}</span>`
11727
12164
  : "";
@@ -11739,7 +12176,7 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
11739
12176
  <td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
11740
12177
  </tr>`).join("");
11741
12178
  return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
11742
- <div class="config-section-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div></div>
12179
+ <div class="config-section-header usage-overview-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div><button class="ghost-button usage-details-button" type="button" data-token-usage-details aria-haspopup="dialog" aria-expanded="false" aria-controls="token-usage-details-toast">详细数据</button></div>
11743
12180
  <div class="usage-stat-grid">
11744
12181
  <article class="usage-stat is-primary"><div class="usage-stat-label"><span>总消耗</span>${pricingBadge}</div><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
11745
12182
  <article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small title="${esc(inputDescription)}">${esc(inputDescription)}</small></article>
@@ -11765,6 +12202,7 @@ async function renderPlatformTokenUsage() {
11765
12202
  description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
11766
12203
  showWorks: true
11767
12204
  });
12205
+ bindTokenUsageDetails(host, usage, "项目累计用量");
11768
12206
  bindUsageCalendarInteractions(host);
11769
12207
  scrollUsageCalendarsToLatest(host);
11770
12208
  }
@@ -11809,6 +12247,7 @@ async function renderBookAiSettings() {
11809
12247
  title: "本书 Token 用量",
11810
12248
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
11811
12249
  })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
12250
+ bindTokenUsageDetails(host, usage, "本书 Token 用量");
11812
12251
  const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
11813
12252
  dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
11814
12253
  const configureTokenQuotaInput = (input, warningId, threshold) => {
@@ -11843,7 +12282,7 @@ async function renderBookAiSettings() {
11843
12282
  );
11844
12283
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
11845
12284
  "beforeend",
11846
- `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算日期差值,或从起始日期推算目标日期,不读取作品内容。</small></span></label>`
12285
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算两个 YYYY-MM-DD 日期之间的天数差,不读取作品内容。</small></span></label>`
11847
12286
  );
11848
12287
  if (!canEditModule("ai-settings")) {
11849
12288
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -12557,6 +12996,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
12557
12996
  const submitLabel = options.submitLabel ?? "保存";
12558
12997
  let submitting = false;
12559
12998
  let disabledStates = [];
12999
+ $("#dialog-context-actions").replaceChildren();
12560
13000
  $("#dialog-title").textContent = title;
12561
13001
  $("#dialog-title").classList.toggle("hidden", Boolean(options.titleInput));
12562
13002
  titleInput.classList.toggle("hidden", !options.titleInput);
@@ -13026,6 +13466,10 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13026
13466
  const editButton = $("#setting-editor-edit");
13027
13467
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
13028
13468
  editButton.onclick = () => openSettingEditor(settingEditorItem);
13469
+ bindEntityDetailFavoriteButton($("#setting-editor-favorite"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
13470
+ settingEditorItem = updated;
13471
+ state.settings = upsertEntityCollection(state.settings, updated);
13472
+ });
13029
13473
  const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
13030
13474
  syncSettingEditorChrome(viewOnly);
13031
13475
  $("#setting-editor-history").onclick = async () => {
@@ -14052,11 +14496,12 @@ function renderCharacterEditorFields(item) {
14052
14496
  (canReadModule("editor")
14053
14497
  ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
14054
14498
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
14055
- characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
14499
+ characterEditorSection("profile", "人物档案", "记录人物定位、行为动力、公开人设和便于创作时快速理解的简介。",
14056
14500
  field("code", "编号", "text", item?.code) +
14057
14501
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
14058
14502
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
14059
- field("summary", "人物简介", "textarea", item?.profile?.summary)),
14503
+ field("summary", "人物简介", "textarea", item?.profile?.summary) +
14504
+ '<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
14060
14505
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
14061
14506
  field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
14062
14507
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
@@ -14104,7 +14549,8 @@ function collectCharacterBody(form) {
14104
14549
  profile: {
14105
14550
  ...profile,
14106
14551
  motivation: String(form.get("motivation") ?? "").trim(),
14107
- summary: String(form.get("summary") ?? "").trim()
14552
+ summary: String(form.get("summary") ?? "").trim(),
14553
+ personaSummary: String(form.get("personaSummary") ?? "").trim()
14108
14554
  },
14109
14555
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
14110
14556
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
@@ -14256,6 +14702,13 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14256
14702
  const editButton = $("#character-editor-edit");
14257
14703
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
14258
14704
  editButton.onclick = () => void openCharacterEditor(characterEditorItem);
14705
+ bindEntityDetailFavoriteButton($("#character-editor-favorite"), "character", () => viewOnly ? characterEditorItem : null, (updated) => {
14706
+ characterEditorItem = updated;
14707
+ if (loadedAiReferencesWorkId === state.work?.id) {
14708
+ state.characters = upsertEntityCollection(state.characters, updated);
14709
+ renderAiRoleplayCharacterSelect();
14710
+ }
14711
+ });
14259
14712
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
14260
14713
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
14261
14714
  });
@@ -14442,6 +14895,10 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
14442
14895
  editButton.textContent = `编辑${label}`;
14443
14896
  editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
14444
14897
  editButton.onclick = () => void openKnowledgeEditor(kind, knowledgeEditorItem);
14898
+ bindEntityDetailFavoriteButton($("#knowledge-editor-favorite"), "organization", () => !isRace && viewOnly ? knowledgeEditorItem : null, (updated) => {
14899
+ knowledgeEditorItem = updated;
14900
+ state.organizations = upsertEntityCollection(state.organizations, updated);
14901
+ });
14445
14902
  const form = $("#knowledge-editor-form");
14446
14903
  form.onsubmit = async (event) => {
14447
14904
  event.preventDefault();
@@ -15545,10 +16002,25 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15545
16002
  if (aiRequestManager.hasActive(tab.id)) return;
15546
16003
  const composerSnapshot = captureAiPromptComposer();
15547
16004
  const requestComposerSnapshot = retry
15548
- ? { text: retry.prompt, citations: retry.citations ?? [], references: [], images: retry.images ?? [] }
16005
+ ? {
16006
+ text: retry.prompt,
16007
+ citations: retry.citations ?? [],
16008
+ references: [],
16009
+ images: retry.images ?? [],
16010
+ sceneDirection: retry.sceneDirection ?? "",
16011
+ scenePin: captureAiScenePin()
16012
+ }
15549
16013
  : composerSnapshot;
15550
16014
  const instruction = requestComposerSnapshot.text.trim();
15551
- if (!instruction) return toast("请输入指令", "error");
16015
+ const sceneDirection = $("#ai-task").value === "roleplay"
16016
+ ? String(requestComposerSnapshot.sceneDirection ?? "").trim()
16017
+ : "";
16018
+ const scenePin = $("#ai-task").value === "roleplay"
16019
+ ? normalizeRoleplayScenePin(requestComposerSnapshot.scenePin)
16020
+ : emptyRoleplayScenePin();
16021
+ if (!instruction && !sceneDirection) {
16022
+ return toast($("#ai-task").value === "roleplay" ? "请输入台词或场景旁白" : "请输入指令", "error");
16023
+ }
15552
16024
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
15553
16025
  const requestScope = currentAiRequestScope();
15554
16026
  if (!requestScope) return toast("请先选择章节", "error");
@@ -15645,6 +16117,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15645
16117
  if (taskType === "chat") {
15646
16118
  const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
15647
16119
  instruction,
16120
+ ...(sceneDirection ? { sceneDirection } : {}),
16121
+ ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
15648
16122
  scope,
15649
16123
  modelId,
15650
16124
  citations,
@@ -15824,9 +16298,23 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15824
16298
  const message = document.createElement("div");
15825
16299
  message.className = "assistant-message is-streaming";
15826
16300
  message.dataset.testid = "ai-stream-message";
15827
- message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
16301
+ const streamConnectionStartedAt = Date.now();
16302
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流…… <span class="ai-stream-connection-seconds" data-testid="ai-stream-connection-seconds"></span> 秒</div>';
15828
16303
  const content = message.querySelector(".message-body");
15829
16304
  const meta = message.querySelector(".message-meta");
16305
+ const connectionSeconds = message.querySelector(".ai-stream-connection-seconds");
16306
+ const renderStreamConnectionElapsed = () => {
16307
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
16308
+ connectionSeconds.textContent = String(elapsedSeconds);
16309
+ };
16310
+ renderStreamConnectionElapsed();
16311
+ let streamConnectionTimer = window.setInterval(renderStreamConnectionElapsed, 1000);
16312
+ const stopStreamConnectionTimer = () => {
16313
+ if (streamConnectionTimer === null) return;
16314
+ window.clearInterval(streamConnectionTimer);
16315
+ streamConnectionTimer = null;
16316
+ };
16317
+ const streamConnectionEstablishedEvents = new Set(["delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
15830
16318
  const streamSpeedController = createStreamTypewriterSpeedController();
15831
16319
  let messageMounted = false;
15832
16320
  const mountAssistantMessage = () => {
@@ -15903,6 +16391,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15903
16391
  let streamError = null;
15904
16392
  const consume = async (eventName, payload) => {
15905
16393
  assertAiRequestCurrent(requestHolder.snapshot);
16394
+ if (streamConnectionEstablishedEvents.has(eventName)) stopStreamConnectionTimer();
15906
16395
  if (eventName === "context") {
15907
16396
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
15908
16397
  if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
@@ -16014,7 +16503,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16014
16503
  assertAiRequestCurrent(requestHolder.snapshot);
16015
16504
  message.classList.remove("is-streaming");
16016
16505
  content.setAttribute("aria-busy", "false");
16017
- message.querySelector(".message-heading > span").textContent = "助手";
16506
+ message.querySelector(".message-heading > span").textContent = aiAssistantLabel("", tab.roleplayCharacter);
16018
16507
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
16019
16508
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
16020
16509
  const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
@@ -16082,6 +16571,8 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16082
16571
  if (streamedText) attachAssistantCopyAction(message, streamedText);
16083
16572
  scrollAiFeedToBottom(feed);
16084
16573
  throw streamFailure;
16574
+ } finally {
16575
+ stopStreamConnectionTimer();
16085
16576
  }
16086
16577
  }
16087
16578
 
@@ -16118,9 +16609,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16118
16609
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
16119
16610
  const interruptionCode = typeof metadata?.interruptionCode === "string" ? metadata.interruptionCode : "AI_STREAM_FAILED";
16120
16611
  message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure || isInterrupted ? " is-error" : ""}`;
16612
+ const parsedUserTurn = role === "user" ? parseRoleplayUserTurn(text) : null;
16121
16613
  const messageBody = isFailure
16122
16614
  ? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
16123
- : renderMarkdown(text);
16615
+ : renderMarkdown(parsedUserTurn?.hasMarkup ? parsedUserTurn.userMessage : text);
16124
16616
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
16125
16617
  message.querySelector("[data-ai-tool-call-settings-link]")?.addEventListener("click", (event) => {
16126
16618
  event.preventDefault();
@@ -16142,6 +16634,18 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16142
16634
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
16143
16635
  heading.firstElementChild?.append(failureBadge);
16144
16636
  }
16637
+ if (parsedUserTurn?.hasMarkup && parsedUserTurn.sceneDirection) {
16638
+ const scene = document.createElement("div");
16639
+ scene.className = "user-message-scene";
16640
+ const sceneLabel = document.createElement("span");
16641
+ sceneLabel.className = "user-message-scene-label";
16642
+ sceneLabel.textContent = "旁白 / 场景";
16643
+ const sceneBody = document.createElement("div");
16644
+ sceneBody.className = "user-message-scene-body";
16645
+ sceneBody.textContent = parsedUserTurn.sceneDirection;
16646
+ scene.append(sceneLabel, sceneBody);
16647
+ message.querySelector(".message-body")?.before(scene);
16648
+ }
16145
16649
  const mentionGroups = role === "user"
16146
16650
  ? [
16147
16651
  ["角色", metadata?.mentionCharacterIds, state.characters],
@@ -16197,7 +16701,8 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16197
16701
  );
16198
16702
  message.dataset.aiImageAttachmentIds = JSON.stringify(imageAttachments.map((attachment) => attachment.id));
16199
16703
  appendAiMessageImageAttachments(message, imageAttachments);
16200
- attachUserCopyAction(message, text);
16704
+ attachUserCopyAction(message, parsedUserTurn?.hasMarkup ? parsedUserTurn.userMessage : text);
16705
+ if (parsedUserTurn?.hasMarkup) message.dataset.sceneDirection = parsedUserTurn.sceneDirection;
16201
16706
  }
16202
16707
  if (role === "assistant" && !text.startsWith("调用失败:")) {
16203
16708
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
@@ -16861,16 +17366,12 @@ $("#account-settings-button").addEventListener("click", () => {
16861
17366
  $("#profile-display-name").value = state.user?.displayName ?? "";
16862
17367
  renderProfileAvatar();
16863
17368
  $("#password-form").reset();
16864
- $("#api-key-result").classList.add("hidden");
16865
- $("#api-key-value").value = "";
16866
17369
  $("#account-dialog").showModal();
16867
17370
  api("/api/auth/api-key").then((status) => {
16868
- $("#api-key-status").textContent = status.configured
16869
- ? `已配置 ${status.prefix}…${status.lastUsedAt ? `,最近使用:${formatDateTime(status.lastUsedAt)}` : ",尚未使用"}`
16870
- : "尚未生成 API Key。";
16871
- $("#api-key-reset-button").textContent = status.configured ? "重置 API Key" : "生成 API Key";
17371
+ paintApiKeyStatus(status);
16872
17372
  }).catch((error) => {
16873
17373
  $("#api-key-status").textContent = error.message;
17374
+ $("#api-key-copy-button").hidden = true;
16874
17375
  });
16875
17376
  });
16876
17377
  $("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
@@ -17307,6 +17808,17 @@ function validatePasswordChangeConfirmation() {
17307
17808
  }
17308
17809
  $("#password-form input[name='newPassword']").addEventListener("input", validatePasswordChangeConfirmation);
17309
17810
  $("#password-form input[name='passwordConfirmation']").addEventListener("input", validatePasswordChangeConfirmation);
17811
+ function paintApiKeyStatus(status) {
17812
+ const copyButton = $("#api-key-copy-button");
17813
+ copyButton.hidden = !status.configured;
17814
+ if (status.configured) {
17815
+ $("#api-key-status").textContent = `已配置 ${status.prefix}…${status.lastUsedAt ? `,最近使用:${formatDateTime(status.lastUsedAt)}` : ",尚未使用"}`;
17816
+ $("#api-key-reset-button").textContent = "重置 API Key";
17817
+ return;
17818
+ }
17819
+ $("#api-key-status").textContent = "尚未生成 API Key。";
17820
+ $("#api-key-reset-button").textContent = "生成 API Key";
17821
+ }
17310
17822
  $("#api-key-reset-button").addEventListener("click", async () => {
17311
17823
  if ($("#api-key-reset-button").textContent.includes("重置") && !(await confirmToast(
17312
17824
  "重置后,所有使用旧 API Key 的 CLI 会立刻退出登录。确定继续吗?",
@@ -17314,25 +17826,31 @@ $("#api-key-reset-button").addEventListener("click", async () => {
17314
17826
  ))) return;
17315
17827
  try {
17316
17828
  const result = await api("/api/auth/api-key/reset", { method: "POST", body: {} });
17317
- $("#api-key-status").textContent = `已配置 ${result.prefix}…,尚未使用`;
17318
- $("#api-key-reset-button").textContent = "重置 API Key";
17319
- $("#api-key-value").value = result.apiKey;
17320
- $("#api-key-result").classList.remove("hidden");
17321
- $("#api-key-value").focus();
17322
- $("#api-key-value").select();
17323
- toast("新的 API Key 已生成,请立即保存");
17829
+ paintApiKeyStatus({
17830
+ configured: true,
17831
+ prefix: result.prefix,
17832
+ lastUsedAt: null,
17833
+ copyable: result.copyable !== false
17834
+ });
17835
+ toast("新的 API Key 已生成,可点击复制");
17324
17836
  } catch (error) { toast(error.message, "error"); }
17325
17837
  });
17326
17838
  $("#api-key-copy-button").addEventListener("click", async () => {
17327
- const value = $("#api-key-value").value;
17328
- if (!value) return;
17839
+ const button = $("#api-key-copy-button");
17840
+ if (button.hidden || button.disabled) return;
17841
+ button.disabled = true;
17329
17842
  try {
17330
- await navigator.clipboard.writeText(value);
17331
- toast("API Key 已复制");
17332
- } catch {
17333
- $("#api-key-value").focus();
17334
- $("#api-key-value").select();
17335
- toast("无法自动复制,请手动复制", "error");
17843
+ const result = await api("/api/auth/api-key/reveal", { method: "POST", body: {} });
17844
+ try {
17845
+ await navigator.clipboard.writeText(result.apiKey);
17846
+ toast("API Key 已复制");
17847
+ } catch {
17848
+ toast("无法自动复制,请检查浏览器剪贴板权限", "error");
17849
+ }
17850
+ } catch (error) {
17851
+ toast(error.message, "error");
17852
+ } finally {
17853
+ button.disabled = false;
17336
17854
  }
17337
17855
  });
17338
17856
  $("#logout-button").addEventListener("click", async () => {
@@ -17936,6 +18454,15 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
17936
18454
  }
17937
18455
  $("#ai-attachment-input").click();
17938
18456
  });
18457
+ $("#ai-scene-button").addEventListener("click", () => {
18458
+ toggleAiScenePanel();
18459
+ });
18460
+ for (const field of ["#ai-scene-direction", "#ai-scene-location", "#ai-scene-present", "#ai-scene-time"]) {
18461
+ $(field)?.addEventListener("input", () => {
18462
+ syncAiSceneComposer();
18463
+ persistActiveAiChatTab();
18464
+ });
18465
+ }
17939
18466
  $("#ai-attachment-input").addEventListener("change", (event) => {
17940
18467
  const input = event.currentTarget;
17941
18468
  void addAiImageFiles(input.files).finally(() => { input.value = ""; });