@musnows/scriverse 0.8.7 → 0.9.0

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";
@@ -17,7 +25,7 @@ import {
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
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";
20
- import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-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,
@@ -112,6 +121,10 @@ import {
112
121
  resizeCropRect
113
122
  } from "/avatar-crop.js?v=20260725-avatar-crop";
114
123
 
124
+ const DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS = 300;
125
+ const MIN_AI_ANALYSIS_TIMEOUT_SECONDS = 30;
126
+ const MAX_AI_ANALYSIS_TIMEOUT_SECONDS = 3_600;
127
+
115
128
  const defaultPageSizes = Object.freeze({
116
129
  drafts: 30,
117
130
  settings: 30,
@@ -461,6 +474,10 @@ function applyWorkAccessMode() {
461
474
  $(".ai-panel").classList.toggle("permission-hidden", aiHidden);
462
475
  $("#ai-prompt").readOnly = aiReadOnly;
463
476
  $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
477
+ $("#ai-scene-direction").readOnly = aiReadOnly;
478
+ $("#ai-scene-location").readOnly = aiReadOnly;
479
+ $("#ai-scene-present").readOnly = aiReadOnly;
480
+ $("#ai-scene-time").readOnly = aiReadOnly;
464
481
  $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
465
482
  renderAiRoleplayCharacterSelect();
466
483
  updateBackgroundTaskCenterVisibility();
@@ -2082,7 +2099,7 @@ function createAiChatTabState(input = {}) {
2082
2099
  roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2083
2100
  citations: input.citations ?? [],
2084
2101
  references: input.references ?? [],
2085
- composer: input.composer ?? { text: "", citations: [], references: [], images: [] },
2102
+ composer: input.composer ?? { text: "", citations: [], references: [], images: [], sceneDirection: "", scenePin: emptyRoleplayScenePin() },
2086
2103
  contextUsage: input.contextUsage ?? null,
2087
2104
  contextWarning: input.contextWarning === true,
2088
2105
  lastMessageAt: input.lastMessageAt ?? null,
@@ -2124,12 +2141,21 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
2124
2141
  text: snapshot.text,
2125
2142
  citations: tab.citations.map((citation) => ({ ...citation })),
2126
2143
  references: tab.references.map((reference) => ({ ...reference })),
2127
- images: normalizeAiChatImageAttachments(snapshot.images)
2144
+ images: normalizeAiChatImageAttachments(snapshot.images),
2145
+ sceneDirection: String(snapshot.sceneDirection ?? ""),
2146
+ scenePin: normalizeRoleplayScenePin(snapshot.scenePin)
2128
2147
  };
2129
2148
  }
2130
2149
 
2131
2150
  function clearAiChatTabComposer(tab) {
2132
- setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [], images: [] });
2151
+ setAiChatTabComposerSnapshot(tab, {
2152
+ text: "",
2153
+ citations: [],
2154
+ references: [],
2155
+ images: [],
2156
+ sceneDirection: "",
2157
+ scenePin: normalizeRoleplayScenePin(tab.composer?.scenePin)
2158
+ });
2133
2159
  }
2134
2160
 
2135
2161
  function applyAiChatTabState(tab) {
@@ -2149,6 +2175,7 @@ function applyAiChatTabState(tab) {
2149
2175
  if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
2150
2176
  syncAiModelPicker();
2151
2177
  setAiPromptText(tab.composer.text);
2178
+ restoreAiSceneComposer(tab.composer);
2152
2179
  renderAiCitations();
2153
2180
  renderAiReferences();
2154
2181
  renderAiImageAttachments();
@@ -2170,7 +2197,9 @@ function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
2170
2197
  && !(tab.composer?.text || "").trim()
2171
2198
  && !(tab.composer?.citations?.length)
2172
2199
  && !(tab.composer?.references?.length)
2173
- && !(tab.composer?.images?.length));
2200
+ && !(tab.composer?.images?.length)
2201
+ && !(tab.composer?.sceneDirection || "").trim()
2202
+ && !roleplayScenePinHasContent(tab.composer?.scenePin));
2174
2203
  }
2175
2204
 
2176
2205
  function aiChatTabOpeningSlot() {
@@ -2443,7 +2472,7 @@ function resetAiFeed(
2443
2472
  const roleplayName = roleplayCharacter?.name;
2444
2473
  const roleplayUserName = roleplayUserCharacter?.name;
2445
2474
  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>`
2475
+ ? `<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
2476
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2448
2477
  }
2449
2478
 
@@ -2521,8 +2550,9 @@ async function retryAiMessage(message) {
2521
2550
  const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2522
2551
  const userMessage = findAiRetryUserMessage(message);
2523
2552
  const prompt = userMessage?.dataset.copyText ?? "";
2553
+ const sceneDirection = userMessage?.dataset.sceneDirection ?? "";
2524
2554
  const userMessageId = userMessage?.dataset.messageId ?? "";
2525
- if (!sourceTab?.conversationId || !userMessageId || !prompt.trim()) {
2555
+ if (!sourceTab?.conversationId || !userMessageId || !(prompt.trim() || sceneDirection.trim())) {
2526
2556
  toast("找不到需要重试的用户指令", "error");
2527
2557
  return;
2528
2558
  }
@@ -2535,6 +2565,7 @@ async function retryAiMessage(message) {
2535
2565
  retry: {
2536
2566
  message,
2537
2567
  prompt,
2568
+ sceneDirection,
2538
2569
  citations: aiMessageCitations(userMessage),
2539
2570
  images: aiMessageImageAttachments(userMessage),
2540
2571
  userMessageId
@@ -2670,6 +2701,8 @@ const AI_TOOL_DISPLAY_NAMES = {
2670
2701
  recall_self: "回忆自身",
2671
2702
  image: "读取设定图片",
2672
2703
  recall_relationship: "回忆人物关系",
2704
+ recall_other: "回忆相识角色",
2705
+ recall_known: "回忆知情设定",
2673
2706
  recall_story: "回忆故事",
2674
2707
  calculate_time: "计算日期"
2675
2708
  };
@@ -2683,9 +2716,11 @@ const AI_TOOL_DESCRIPTIONS = {
2683
2716
  search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
2684
2717
  recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
2685
2718
  image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
2686
- recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2687
- recall_story: "查询当前作品已保存正文中的关键词,返回匹配段落及章节信息。",
2688
- calculate_time: "计算日期差值,或从起始日期推算目标日期。"
2719
+ recall_relationship: "不传角色列表时读取有关系的角色公开摘要;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2720
+ recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
2721
+ recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
2722
+ recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
2723
+ calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
2689
2724
  };
2690
2725
 
2691
2726
  const aiFeedScrollFrames = new WeakMap();
@@ -3183,7 +3218,7 @@ function renderAiConversationHistory() {
3183
3218
  }
3184
3219
 
3185
3220
  function defaultAiConversationTitle(prompt) {
3186
- const normalized = String(prompt ?? "").replace(/\s+/gu, " ").trim();
3221
+ const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
3187
3222
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
3188
3223
  }
3189
3224
 
@@ -3191,7 +3226,7 @@ function upsertAiConversationSummary(conversation) {
3191
3226
  if (!conversation?.id) return;
3192
3227
  const current = state.aiConversations.find((item) => item.id === conversation.id);
3193
3228
  const lastMessage = Array.isArray(conversation.messages) ? conversation.messages.at(-1) : null;
3194
- const summary = { ...current, ...conversation, ...(lastMessage ? { preview: lastMessage.content } : {}) };
3229
+ const summary = { ...current, ...conversation, ...(lastMessage ? { preview: roleplayUserTurnDisplayText(lastMessage.content) } : {}) };
3195
3230
  delete summary.messages;
3196
3231
  delete summary.messagesPage;
3197
3232
  if (!current && loadedAiConversationsWorkId === state.work?.id) {
@@ -3214,7 +3249,7 @@ function updateAiConversationSummaryFromMessage(message) {
3214
3249
  ...current,
3215
3250
  title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
3216
3251
  messageCount: Number(current.messageCount ?? 0) + 1,
3217
- preview: message.content,
3252
+ preview: roleplayUserTurnDisplayText(message.content),
3218
3253
  ...(aiConversationMessageHasImages(message) ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
3219
3254
  updatedAt: message.createdAt ?? current.updatedAt
3220
3255
  });
@@ -3349,7 +3384,14 @@ function applyConversationToAiChatTab(tab, conversation) {
3349
3384
  tab.roleplayUserCharacter = conversation.roleplayUserCharacter ?? null;
3350
3385
  tab.citations = [];
3351
3386
  tab.references = [];
3352
- tab.composer = { text: "", citations: [], references: [], images: [] };
3387
+ tab.composer = {
3388
+ text: "",
3389
+ citations: [],
3390
+ references: [],
3391
+ images: [],
3392
+ sceneDirection: "",
3393
+ scenePin: normalizeRoleplayScenePin(conversation.scenePin)
3394
+ };
3353
3395
  tab.contextUsage = null;
3354
3396
  tab.contextWarning = conversation.contextWarningPending === true;
3355
3397
  resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
@@ -3400,16 +3442,30 @@ async function createNewAiConversation(taskType = "chat") {
3400
3442
  }
3401
3443
  }
3402
3444
 
3445
+ function favoriteCharactersFirst(characters) {
3446
+ const items = Array.isArray(characters) ? characters : [];
3447
+ return [
3448
+ ...items.filter((character) => character.isFavorite === true),
3449
+ ...items.filter((character) => character.isFavorite !== true)
3450
+ ];
3451
+ }
3452
+
3453
+ function roleplayCharacterOptionLabel(character) {
3454
+ const favoriteLabel = character?.isFavorite === true ? "[已收藏] " : "";
3455
+ const deathLabel = character?.isDead ? "(已死亡)" : "";
3456
+ return `${favoriteLabel}${String(character?.name ?? "")}${deathLabel}`;
3457
+ }
3458
+
3403
3459
  function renderAiRoleplayCharacterSelect() {
3404
3460
  const select = $("#ai-roleplay-character");
3405
3461
  const selectedId = String(state.aiRoleplayCharacter?.id ?? "");
3406
- const availableCharacters = state.characters.filter((character) => !character.mergedIntoCharacterId);
3462
+ const availableCharacters = favoriteCharactersFirst(state.characters.filter((character) => !character.mergedIntoCharacterId));
3407
3463
  const options = [{ id: "", name: "选择角色卡" }, ...availableCharacters.map((character) => ({
3408
3464
  id: String(character.id),
3409
- name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
3465
+ name: roleplayCharacterOptionLabel(character)
3410
3466
  }))];
3411
3467
  if (selectedId && !options.some((option) => option.id === selectedId)) {
3412
- options.push({ id: selectedId, name: String(state.aiRoleplayCharacter.name) });
3468
+ options.push({ id: selectedId, name: roleplayCharacterOptionLabel(state.aiRoleplayCharacter) });
3413
3469
  }
3414
3470
  select.replaceChildren(...options.map((option) => {
3415
3471
  const element = document.createElement("option");
@@ -3425,7 +3481,7 @@ function renderAiRoleplayCharacterSelect() {
3425
3481
  select.title = canSelectCharacter
3426
3482
  ? state.aiPromptSent
3427
3483
  ? aiConversationOptionLockedMessage
3428
- : "为当前对话选择角色卡;角色扮演时 Agent 可以查询角色记忆、人物关系和故事正文"
3484
+ : "为当前对话选择角色卡;角色扮演时 Agent 可以查询角色记忆、相识角色、知情设定、故事正文和设定图片"
3429
3485
  : "当前账户没有角色模块读取权限";
3430
3486
  renderAiRoleplayUserCharacterSelect();
3431
3487
  }
@@ -3434,15 +3490,15 @@ function renderAiRoleplayUserCharacterSelect() {
3434
3490
  const select = $("#ai-roleplay-user-character");
3435
3491
  const selectedId = String(state.aiRoleplayUserCharacter?.id ?? "");
3436
3492
  const aiCharacterId = String(state.aiRoleplayCharacter?.id ?? "");
3437
- const availableCharacters = state.characters.filter((character) => (
3493
+ const availableCharacters = favoriteCharactersFirst(state.characters.filter((character) => (
3438
3494
  !character.mergedIntoCharacterId && String(character.id) !== aiCharacterId
3439
- ));
3495
+ )));
3440
3496
  const options = [{ id: "", name: "选择我的角色(可选)" }, ...availableCharacters.map((character) => ({
3441
3497
  id: String(character.id),
3442
- name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
3498
+ name: roleplayCharacterOptionLabel(character)
3443
3499
  }))];
3444
3500
  if (selectedId && !options.some((option) => option.id === selectedId)) {
3445
- options.push({ id: selectedId, name: String(state.aiRoleplayUserCharacter.name) });
3501
+ options.push({ id: selectedId, name: roleplayCharacterOptionLabel(state.aiRoleplayUserCharacter) });
3446
3502
  }
3447
3503
  select.replaceChildren(...options.map((option) => {
3448
3504
  const element = document.createElement("option");
@@ -3461,7 +3517,7 @@ function renderAiRoleplayUserCharacterSelect() {
3461
3517
  ? "请先选择 AI 扮演的角色"
3462
3518
  : state.aiPromptSent
3463
3519
  ? aiConversationOptionLockedMessage
3464
- : "选择后,AI 会将每条用户消息视为该角色的发言或行动";
3520
+ : "选择后,主输入是该角色的台词或行动;旁白请写在场景框";
3465
3521
  }
3466
3522
 
3467
3523
  const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
@@ -3600,8 +3656,9 @@ function syncAiTaskOptions() {
3600
3656
  $("#ai-scope").disabled = interactionBusy || roleplaySelected;
3601
3657
  $("#ai-scope").title = state.aiPromptSent
3602
3658
  ? aiConversationOptionLockedMessage
3603
- : roleplaySelected ? "角色扮演模式可以查询角色记忆、人物关系和故事正文" : "";
3659
+ : roleplaySelected ? "角色扮演模式可以查询角色记忆、相识角色、知情设定、故事正文和设定图片" : "";
3604
3660
  syncAiModelPicker();
3661
+ syncAiSceneComposer();
3605
3662
  }
3606
3663
 
3607
3664
  function applyAiConversationTaskType(taskType) {
@@ -3638,8 +3695,8 @@ function applyAiRoleplayCharacter(character, userCharacter = null) {
3638
3695
  $(".ai-panel").classList.toggle("is-roleplaying", active);
3639
3696
  $("#ai-prompt").dataset.placeholder = active
3640
3697
  ? state.aiRoleplayUserCharacter
3641
- ? `以 ${String(state.aiRoleplayUserCharacter.name)} 的身份与 ${String(state.aiRoleplayCharacter.name)} 对话……`
3642
- : `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
3698
+ ? `以 ${String(state.aiRoleplayUserCharacter.name)} 的身份输入台词或行动……`
3699
+ : `输入对 ${String(state.aiRoleplayCharacter.name)} 说的台词或行动……`
3643
3700
  : "告诉 AI 你想讨论或修改什么……";
3644
3701
  renderAiRoleplayCharacterSelect();
3645
3702
  syncAiTaskOptions();
@@ -3946,9 +4003,11 @@ function clearAiPromptComposer() {
3946
4003
  state.aiReferences = [];
3947
4004
  state.aiImageAttachments = [];
3948
4005
  setAiPromptText("");
4006
+ setAiSceneDirection("");
3949
4007
  renderAiCitations();
3950
4008
  renderAiImageAttachments();
3951
4009
  hideAiMentionMenu();
4010
+ syncAiSceneComposer();
3952
4011
  }
3953
4012
 
3954
4013
  function captureAiPromptComposer() {
@@ -3956,7 +4015,9 @@ function captureAiPromptComposer() {
3956
4015
  text: aiPromptText(),
3957
4016
  citations: state.aiCitations.map((citation) => ({ ...citation })),
3958
4017
  references: state.aiReferences.map((reference) => ({ ...reference })),
3959
- images: normalizeAiChatImageAttachments(state.aiImageAttachments)
4018
+ images: normalizeAiChatImageAttachments(state.aiImageAttachments),
4019
+ sceneDirection: aiSceneDirectionText(),
4020
+ scenePin: captureAiScenePin()
3960
4021
  };
3961
4022
  }
3962
4023
 
@@ -3965,11 +4026,77 @@ function restoreAiPromptComposer(snapshot) {
3965
4026
  state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
3966
4027
  state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
3967
4028
  setAiPromptText(snapshot.text);
4029
+ restoreAiSceneComposer(snapshot);
3968
4030
  renderAiCitations();
3969
4031
  renderAiImageAttachments();
3970
4032
  hideAiMentionMenu();
3971
4033
  }
3972
4034
 
4035
+ function aiSceneDirectionText() {
4036
+ return String($("#ai-scene-direction")?.value ?? "");
4037
+ }
4038
+
4039
+ function setAiSceneDirection(value) {
4040
+ const input = $("#ai-scene-direction");
4041
+ if (input) input.value = String(value ?? "");
4042
+ }
4043
+
4044
+ function captureAiScenePin() {
4045
+ return normalizeRoleplayScenePin({
4046
+ location: $("#ai-scene-location")?.value ?? "",
4047
+ present: $("#ai-scene-present")?.value ?? "",
4048
+ timeLabel: $("#ai-scene-time")?.value ?? ""
4049
+ });
4050
+ }
4051
+
4052
+ function setAiScenePin(pin) {
4053
+ const normalized = normalizeRoleplayScenePin(pin);
4054
+ const location = $("#ai-scene-location");
4055
+ const present = $("#ai-scene-present");
4056
+ const timeLabel = $("#ai-scene-time");
4057
+ if (location) location.value = normalized.location;
4058
+ if (present) present.value = normalized.present;
4059
+ if (timeLabel) timeLabel.value = normalized.timeLabel;
4060
+ }
4061
+
4062
+ function restoreAiSceneComposer(snapshot = {}) {
4063
+ setAiSceneDirection(snapshot.sceneDirection ?? "");
4064
+ setAiScenePin(snapshot.scenePin);
4065
+ syncAiSceneComposer();
4066
+ }
4067
+
4068
+ function roleplaySceneComposerVisible() {
4069
+ return $("#ai-task").value === "roleplay" && Boolean(state.aiRoleplayCharacter);
4070
+ }
4071
+
4072
+ function syncAiSceneComposer() {
4073
+ const button = $("#ai-scene-button");
4074
+ const panel = $("#ai-scene-panel");
4075
+ if (!button || !panel) return;
4076
+ const visible = roleplaySceneComposerVisible();
4077
+ const busy = aiInteractionBusy();
4078
+ const readOnly = Boolean(state.work) && !canWritePermissionModule(state.work, "ai-chat");
4079
+ button.classList.toggle("hidden", !visible);
4080
+ button.disabled = !visible || busy || readOnly;
4081
+ button.setAttribute("aria-hidden", String(!visible));
4082
+ if (!visible) {
4083
+ panel.classList.add("hidden");
4084
+ button.setAttribute("aria-expanded", "false");
4085
+ }
4086
+ const hasContent = Boolean(aiSceneDirectionText().trim()) || roleplayScenePinHasContent(captureAiScenePin());
4087
+ button.classList.toggle("is-active", hasContent);
4088
+ }
4089
+
4090
+ function toggleAiScenePanel() {
4091
+ const button = $("#ai-scene-button");
4092
+ const panel = $("#ai-scene-panel");
4093
+ if (!button || !panel || button.classList.contains("hidden")) return;
4094
+ const willOpen = panel.classList.contains("hidden");
4095
+ panel.classList.toggle("hidden", !willOpen);
4096
+ button.setAttribute("aria-expanded", String(willOpen));
4097
+ if (willOpen) $("#ai-scene-direction")?.focus();
4098
+ }
4099
+
3973
4100
  function aiPromptTextBeforeCursor() {
3974
4101
  const prompt = $("#ai-prompt");
3975
4102
  const selection = window.getSelection();
@@ -5020,11 +5147,17 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
5020
5147
  function applyAuthenticatedUser(session) {
5021
5148
  state.user = session.user;
5022
5149
  state.csrfToken = session.csrfToken;
5150
+ const isSystemAdmin = session.user.isSystemAdmin === true;
5151
+ const accountButton = $("#account-button");
5023
5152
  $("#account-name").textContent = session.user.displayName;
5024
5153
  renderUserAvatar($("#account-avatar"), session.user);
5154
+ accountButton.setAttribute("aria-label", isSystemAdmin
5155
+ ? `账户:${session.user.displayName},系统管理员`
5156
+ : `账户:${session.user.displayName}`);
5157
+ $("#account-admin-mark").classList.toggle("hidden", !isSystemAdmin);
5025
5158
  $("#account-menu-display-name").textContent = session.user.displayName;
5026
5159
  $("#account-menu-username").textContent = `@${session.user.username}`;
5027
- $("#account-menu-role").textContent = session.user.role === "admin" ? "系统管理员" : "普通用户";
5160
+ $("#account-menu-role").textContent = isSystemAdmin ? "系统管理员" : "普通用户";
5028
5161
  $("#auth-view").classList.add("hidden");
5029
5162
  document.documentElement.classList.remove("login-route");
5030
5163
  if (!session.csrfToken) document.body.classList.remove("auth-pending");
@@ -5109,8 +5242,18 @@ async function initializeAuthentication() {
5109
5242
  return true;
5110
5243
  }
5111
5244
 
5245
+ function syncToastRegionHost() {
5246
+ const region = $("#toast-region");
5247
+ const host = resolveToastRegionHost([...document.querySelectorAll("dialog[open]")], document.body);
5248
+ if (!host || region.parentElement === host) return false;
5249
+ if (typeof region.hidePopover === "function" && region.matches(":popover-open")) region.hidePopover();
5250
+ host.append(region);
5251
+ return true;
5252
+ }
5253
+
5112
5254
  function raiseToastRegion() {
5113
5255
  const region = $("#toast-region");
5256
+ syncToastRegionHost();
5114
5257
  if (typeof region.showPopover !== "function") return;
5115
5258
  if (region.matches(":popover-open")) region.hidePopover();
5116
5259
  region.showPopover();
@@ -5263,9 +5406,6 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
5263
5406
  confirm.textContent = confirmLabel;
5264
5407
  actions.append(cancel, confirm);
5265
5408
  element.append(heading, description, actions);
5266
- region.append(element);
5267
- raiseToastRegion();
5268
- cancel.focus();
5269
5409
  return new Promise((resolve) => {
5270
5410
  const finish = (confirmed) => {
5271
5411
  element.remove();
@@ -5280,6 +5420,9 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
5280
5420
  event.preventDefault();
5281
5421
  finish(false);
5282
5422
  });
5423
+ region.append(element);
5424
+ raiseToastRegion();
5425
+ cancel.focus();
5283
5426
  });
5284
5427
  }
5285
5428
 
@@ -5340,8 +5483,15 @@ function inputToast(message, { title = "请输入", inputLabel = title, value =
5340
5483
 
5341
5484
  document.addEventListener("toggle", (event) => {
5342
5485
  const target = event.target;
5343
- if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
5486
+ if (!(target instanceof HTMLDialogElement)) return;
5487
+ const region = $("#toast-region");
5488
+ if (target.open && region.childElementCount) {
5344
5489
  raiseToastRegion();
5490
+ return;
5491
+ }
5492
+ if (!target.open && (region.parentElement !== document.body || region.childElementCount)) {
5493
+ const moved = syncToastRegionHost();
5494
+ if (moved && region.childElementCount && typeof region.showPopover === "function") region.showPopover();
5345
5495
  }
5346
5496
  }, true);
5347
5497
 
@@ -8533,6 +8683,116 @@ function pencilIconMarkup() {
8533
8683
  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>';
8534
8684
  }
8535
8685
 
8686
+ function characterFavoriteIconMarkup() {
8687
+ 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>';
8688
+ }
8689
+
8690
+ function characterFavoriteButton(item) {
8691
+ const isFavorite = item.isFavorite === true;
8692
+ const canFavorite = canEditModule("characters");
8693
+ const action = isFavorite ? "取消收藏" : "收藏";
8694
+ const title = canFavorite ? action : "当前账户没有角色模块写入权限";
8695
+ 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>`;
8696
+ }
8697
+
8698
+ const recordFavoriteConfigs = Object.freeze({
8699
+ character: { module: "characters", resource: "characters", label: "角色", nameField: "name" },
8700
+ draft: { module: "drafts", resource: "drafts", label: "想法", nameField: "title" },
8701
+ setting: { module: "settings", resource: "settings", label: "设定", nameField: "title" },
8702
+ organization: { module: "organizations", resource: "organizations", label: "组织", nameField: "name" }
8703
+ });
8704
+
8705
+ function recordFavoriteButton(type, item, { cardControl = false } = {}) {
8706
+ const config = recordFavoriteConfigs[type];
8707
+ if (!config) return "";
8708
+ const isFavorite = item.isFavorite === true;
8709
+ const canFavorite = canEditModule(config.module);
8710
+ const action = isFavorite ? "取消收藏" : "收藏";
8711
+ const title = canFavorite ? action : `当前账户没有${config.label}模块写入权限`;
8712
+ const name = String(item[config.nameField] ?? "");
8713
+ 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>`;
8714
+ }
8715
+
8716
+ async function toggleRecordFavorite(type, item, render) {
8717
+ const config = recordFavoriteConfigs[type];
8718
+ if (!config) return;
8719
+ const updated = await api(`/api/${config.resource}/${encodeURIComponent(item.id)}/favorite`, {
8720
+ method: "PATCH",
8721
+ body: { isFavorite: item.isFavorite !== true }
8722
+ });
8723
+ await render();
8724
+ const favoriteButton = [...$("#module-content").querySelectorAll("[data-record-favorite]")].find((button) => (
8725
+ button.dataset.recordFavoriteType === type && button.dataset.recordFavorite === String(updated.id)
8726
+ ));
8727
+ favoriteButton?.focus({ preventScroll: true });
8728
+ const name = String(updated[config.nameField] ?? "");
8729
+ toast(updated.isFavorite ? `已收藏${config.label}“${name}”` : `已取消收藏${config.label}“${name}”`);
8730
+ }
8731
+
8732
+ function bindRecordFavoriteButtons(type, items, render) {
8733
+ const config = recordFavoriteConfigs[type];
8734
+ if (!config) return;
8735
+ $("#module-content").querySelectorAll(`[data-record-favorite-type="${type}"]`).forEach((button) => button.addEventListener("click", async () => {
8736
+ const item = items.find((candidate) => candidate.id === button.dataset.recordFavorite);
8737
+ if (!item || !canEditModule(config.module)) return;
8738
+ button.disabled = true;
8739
+ try {
8740
+ await toggleRecordFavorite(type, item, render);
8741
+ } catch (error) {
8742
+ button.disabled = false;
8743
+ toast(`${config.label}收藏状态更新失败:${error.message}`, "error");
8744
+ }
8745
+ }));
8746
+ }
8747
+
8748
+ function syncEntityDetailFavoriteButton(button, type, item) {
8749
+ const config = recordFavoriteConfigs[type];
8750
+ const visible = Boolean(config && item);
8751
+ button.classList.toggle("hidden", !visible);
8752
+ button.innerHTML = characterFavoriteIconMarkup();
8753
+ if (!visible) {
8754
+ button.disabled = true;
8755
+ button.classList.remove("is-favorite");
8756
+ button.setAttribute("aria-pressed", "false");
8757
+ button.removeAttribute("aria-label");
8758
+ button.removeAttribute("title");
8759
+ return;
8760
+ }
8761
+ const isFavorite = item.isFavorite === true;
8762
+ const canFavorite = canEditModule(config.module);
8763
+ const action = isFavorite ? "取消收藏" : "收藏";
8764
+ const name = String(item[config.nameField] ?? "");
8765
+ button.classList.toggle("is-favorite", isFavorite);
8766
+ button.setAttribute("aria-pressed", String(isFavorite));
8767
+ button.setAttribute("aria-label", `${action}${config.label}“${name}”`);
8768
+ button.title = canFavorite ? action : `当前账户没有${config.label}模块写入权限`;
8769
+ button.disabled = !canFavorite;
8770
+ }
8771
+
8772
+ function bindEntityDetailFavoriteButton(button, type, getItem, onUpdated) {
8773
+ syncEntityDetailFavoriteButton(button, type, getItem());
8774
+ button.onclick = async () => {
8775
+ const config = recordFavoriteConfigs[type];
8776
+ const item = getItem();
8777
+ if (!config || !item || !canEditModule(config.module)) return;
8778
+ button.disabled = true;
8779
+ try {
8780
+ const updated = await api(`/api/${config.resource}/${encodeURIComponent(item.id)}/favorite`, {
8781
+ method: "PATCH",
8782
+ body: { isFavorite: item.isFavorite !== true }
8783
+ });
8784
+ onUpdated(updated);
8785
+ syncEntityDetailFavoriteButton(button, type, getItem() ?? updated);
8786
+ button.focus({ preventScroll: true });
8787
+ const name = String(updated[config.nameField] ?? "");
8788
+ toast(updated.isFavorite ? `已收藏${config.label}“${name}”` : `已取消收藏${config.label}“${name}”`);
8789
+ } catch (error) {
8790
+ syncEntityDetailFavoriteButton(button, type, getItem());
8791
+ toast(`${config.label}收藏状态更新失败:${error instanceof Error ? error.message : "未知错误"}`, "error");
8792
+ }
8793
+ };
8794
+ }
8795
+
8536
8796
  function recordCardEditButton(attribute, id, label) {
8537
8797
  return `<button class="record-card-edit" type="button" data-${attribute}="${esc(id)}" aria-label="编辑${esc(label)}" title="编辑">${pencilIconMarkup()}</button>`;
8538
8798
  }
@@ -8868,9 +9128,10 @@ function openReviewDetailDialog(item) {
8868
9128
  }
8869
9129
 
8870
9130
  function settingRecordActions(item) {
8871
- return canEditModule("settings")
9131
+ const recordAction = canEditModule("settings")
8872
9132
  ? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
8873
9133
  : recordHistoryButton("setting", item.id, item.title);
9134
+ return `${recordFavoriteButton("setting", item)}${recordAction}`;
8874
9135
  }
8875
9136
 
8876
9137
  function draftTypeLabel(draftType) {
@@ -8924,6 +9185,7 @@ async function deleteDraft(item) {
8924
9185
  }
8925
9186
 
8926
9187
  function openDraftDialog(item = null, { readOnly = false } = {}) {
9188
+ let draftDialogItem = item;
8927
9189
  const viewOnly = readOnly || !canEditModule("drafts");
8928
9190
  const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
8929
9191
  const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
@@ -8981,6 +9243,18 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
8981
9243
  else control.readOnly = true;
8982
9244
  });
8983
9245
  }
9246
+ if (item && viewOnly) {
9247
+ const headerActions = $("#dialog-context-actions");
9248
+ 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>' : ""}`;
9249
+ bindEntityDetailFavoriteButton($("#draft-dialog-favorite"), "draft", () => draftDialogItem, (updated) => {
9250
+ draftDialogItem = updated;
9251
+ void renderDrafts(moduleListPages.drafts).catch((error) => toast(`想法列表刷新失败:${error instanceof Error ? error.message : "未知错误"}`, "error"));
9252
+ });
9253
+ $("#draft-dialog-edit")?.addEventListener("click", () => {
9254
+ $("#form-dialog").close();
9255
+ openDraftDialog(draftDialogItem);
9256
+ });
9257
+ }
8984
9258
  }
8985
9259
 
8986
9260
  async function renderDrafts(page = moduleListPages.drafts) {
@@ -9014,9 +9288,9 @@ async function renderDrafts(page = moduleListPages.drafts) {
9014
9288
  <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>
9015
9289
  <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>
9016
9290
  </section>`;
9017
- const actions = (item) => canEditModule("drafts")
9291
+ const actions = (item) => `${recordFavoriteButton("draft", item)}${canEditModule("drafts")
9018
9292
  ? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
9019
- : recordHistoryButton("draft", item.id, item.title);
9293
+ : recordHistoryButton("draft", item.id, item.title)}`;
9020
9294
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
9021
9295
  <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
9022
9296
  <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
@@ -9072,6 +9346,10 @@ async function renderDrafts(page = moduleListPages.drafts) {
9072
9346
  $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
9073
9347
  openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
9074
9348
  }));
9349
+ bindRecordFavoriteButtons("draft", pageResult.items, async () => {
9350
+ moduleListPages.drafts = 1;
9351
+ await renderDrafts(1);
9352
+ });
9075
9353
  bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
9076
9354
  }
9077
9355
 
@@ -9135,6 +9413,10 @@ async function renderSettings(page = moduleListPages.settings) {
9135
9413
  bindModulePagination("settings", renderSettingResults);
9136
9414
  const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
9137
9415
  $("#setting-filter-results").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
9416
+ bindRecordFavoriteButtons("setting", pageResult.items, async () => {
9417
+ moduleListPages.settings = 1;
9418
+ await renderSettings(1);
9419
+ });
9138
9420
  bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
9139
9421
  };
9140
9422
 
@@ -9171,6 +9453,21 @@ async function renderSettings(page = moduleListPages.settings) {
9171
9453
  renderSettingResults(page);
9172
9454
  }
9173
9455
 
9456
+ async function toggleCharacterFavorite(item) {
9457
+ const updated = await api(`/api/characters/${encodeURIComponent(item.id)}/favorite`, {
9458
+ method: "PATCH",
9459
+ body: { isFavorite: item.isFavorite !== true }
9460
+ });
9461
+ if (loadedAiReferencesWorkId === state.work?.id) {
9462
+ state.characters = upsertEntityCollection(state.characters, updated);
9463
+ renderAiRoleplayCharacterSelect();
9464
+ }
9465
+ characterListPage = 1;
9466
+ await renderCharacters(1);
9467
+ $("#module-content").querySelector(`[data-character-favorite="${CSS.escape(String(updated.id))}"]`)?.focus({ preventScroll: true });
9468
+ toast(updated.isFavorite ? `已收藏角色“${updated.name}”` : `已取消收藏角色“${updated.name}”`);
9469
+ }
9470
+
9174
9471
  async function renderCharacters(page = characterListPage) {
9175
9472
  const hasCharacterFilters = characterFilters.raceIds.length > 0
9176
9473
  || characterFilters.organizationIds.length > 0
@@ -9193,14 +9490,14 @@ async function renderCharacters(page = characterListPage) {
9193
9490
  [state.races, state.organizations] = [races, organizations];
9194
9491
  mountModuleCount(characterPage.total);
9195
9492
  const layout = readModuleLayout();
9196
- const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
9493
+ const characterActions = (item) => `${characterFavoriteButton(item)}${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}`;
9197
9494
  const characterLockBadge = (item) => item.lockedFields.length
9198
9495
  ? `<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>`
9199
9496
  : "";
9200
9497
  const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
9201
9498
  const details = normalizeCharacterDetails(item.attributes?.details);
9202
9499
  return `
9203
- <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}”`)}
9500
+ <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}”`)}
9204
9501
  <div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
9205
9502
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
9206
9503
  <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
@@ -9266,6 +9563,17 @@ async function renderCharacters(page = characterListPage) {
9266
9563
  ? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
9267
9564
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
9268
9565
  bindModuleLayoutToggle(renderCharacters);
9566
+ $("#module-content").querySelectorAll("[data-character-favorite]").forEach((button) => button.addEventListener("click", async () => {
9567
+ const item = pageCharacters.find((candidate) => candidate.id === button.dataset.characterFavorite);
9568
+ if (!item || !canEditModule("characters")) return;
9569
+ button.disabled = true;
9570
+ try {
9571
+ await toggleCharacterFavorite(item);
9572
+ } catch (error) {
9573
+ button.disabled = false;
9574
+ toast(`角色收藏状态更新失败:${error.message}`, "error");
9575
+ }
9576
+ }));
9269
9577
  const readSelectedValues = (selector) => [...$(selector).querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
9270
9578
  $("#character-race-filter").addEventListener("change", async () => {
9271
9579
  characterFiltersPanelOpen = true;
@@ -9420,14 +9728,14 @@ async function renderOrganizations(page = moduleListPages.organizations) {
9420
9728
  moduleListPages.organizations = pageResult.page;
9421
9729
  const layout = readModuleLayout();
9422
9730
  const canEditOrganizations = canEditModule("organizations");
9423
- const organizationActions = (item) => canEditOrganizations
9731
+ const organizationActions = (item, { cardControl = false } = {}) => `${recordFavoriteButton("organization", item, { cardControl })}${canEditOrganizations
9424
9732
  ? recordCardEditButton("edit-organization", item.id, `组织“${item.name}”`)
9425
- : recordHistoryButton("organization", item.id, item.name);
9733
+ : recordHistoryButton("organization", item.id, item.name)}`;
9426
9734
  const organizationCardActions = (item) => canEditOrganizations
9427
- ? organizationActions(item)
9735
+ ? organizationActions(item, { cardControl: true })
9428
9736
  : `<div class="card-actions">${organizationActions(item)}</div>`;
9429
9737
  const organizationCards = () => `<div class="card-grid organization-grid">${pageResult.items.map((item) => `
9430
- <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>
9738
+ <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>
9431
9739
  <h3>${esc(item.name)}${entityLifecycleBadge(item.isDissolved, "已解散")}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
9432
9740
  <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
9433
9741
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
@@ -9452,6 +9760,10 @@ async function renderOrganizations(page = moduleListPages.organizations) {
9452
9760
  bindModulePagination("organizations", renderOrganizations);
9453
9761
  const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
9454
9762
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => { void openOrganization(button.dataset.editOrganization, false); }));
9763
+ bindRecordFavoriteButtons("organization", pageResult.items, async () => {
9764
+ moduleListPages.organizations = 1;
9765
+ await renderOrganizations(1);
9766
+ });
9455
9767
  bindEntityHistoryButtons(async () => { await renderOrganizations(pageResult.page); await loadAiReferences(); });
9456
9768
  }
9457
9769
 
@@ -11073,7 +11385,7 @@ function openTaskDetailDialog(task, trace) {
11073
11385
  <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
11074
11386
  <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
11075
11387
  <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
11076
- <div><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
11388
+ <div class="task-detail-failures"><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
11077
11389
  <div><strong>结果摘要</strong>${resultPreview}</div>
11078
11390
  ${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><button class="ghost-button" type="button" data-rerun-task-model="${esc(task.id)}">换模型重试</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
11079
11391
  </section>
@@ -11112,7 +11424,7 @@ function renderProviderCards(providers, models, protocolOptions) {
11112
11424
  : "";
11113
11425
  return `
11114
11426
  <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
11115
- ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
11427
+ ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n分析请求超时:${Number(provider.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS).toLocaleString("zh-CN")} 秒\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
11116
11428
  <div class="provider-models">${providerModels.map((model) => {
11117
11429
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
11118
11430
  const modelStatus = !model.enabled
@@ -11743,6 +12055,9 @@ function appendTokenUsageDetailRow(parent, label, usage, isSummary = false) {
11743
12055
  cell.textContent = tokenUsageDetailCount(usage?.[key]);
11744
12056
  row.append(cell);
11745
12057
  });
12058
+ const price = document.createElement("td");
12059
+ price.textContent = formatEstimatedCost(usage?.estimatedCost);
12060
+ row.append(price);
11746
12061
  parent.append(row);
11747
12062
  }
11748
12063
 
@@ -11778,7 +12093,7 @@ function showTokenUsageDetails(usage, title, trigger) {
11778
12093
  const description = document.createElement("p");
11779
12094
  description.id = "token-usage-details-description";
11780
12095
  description.className = "token-usage-details-note";
11781
- description.textContent = "Raw Input 为不含缓存的输入 Token;Cache Write 与 Cache Read 也是输入组成部分,并已包含在总计中。汇总行包含当前范围内全部模型。";
12096
+ description.textContent = "Raw Input 为不含缓存的输入 Token;Cache Write 与 Cache Read 也是输入组成部分,并已包含在总计中。估算价格按美元显示,未匹配价格的模型显示为暂无价格。汇总行包含当前范围内全部模型。";
11782
12097
  body.append(description);
11783
12098
 
11784
12099
  const tableScroll = document.createElement("div");
@@ -11790,7 +12105,7 @@ function showTokenUsageDetails(usage, title, trigger) {
11790
12105
  table.append(caption);
11791
12106
  const head = document.createElement("thead");
11792
12107
  const headRow = document.createElement("tr");
11793
- ["模型", "Raw Input", "Cache Write", "Cache Read", "Output", "总计", "调用", "估算调用"].forEach((label) => {
12108
+ ["模型", "Raw Input", "Cache Write", "Cache Read", "Output", "总计", "调用", "估算调用", "估算价格"].forEach((label) => {
11794
12109
  const cell = document.createElement("th");
11795
12110
  cell.scope = "col";
11796
12111
  cell.textContent = label;
@@ -11804,7 +12119,7 @@ function showTokenUsageDetails(usage, title, trigger) {
11804
12119
  if (!models.length) {
11805
12120
  const emptyRow = document.createElement("tr");
11806
12121
  const emptyCell = document.createElement("td");
11807
- emptyCell.colSpan = 8;
12122
+ emptyCell.colSpan = 9;
11808
12123
  emptyCell.textContent = "还没有模型用量记录。";
11809
12124
  emptyRow.append(emptyCell);
11810
12125
  bodyRows.append(emptyRow);
@@ -11977,7 +12292,7 @@ async function renderBookAiSettings() {
11977
12292
  );
11978
12293
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
11979
12294
  "beforeend",
11980
- `<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>`
12295
+ `<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>`
11981
12296
  );
11982
12297
  if (!canEditModule("ai-settings")) {
11983
12298
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -12691,6 +13006,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
12691
13006
  const submitLabel = options.submitLabel ?? "保存";
12692
13007
  let submitting = false;
12693
13008
  let disabledStates = [];
13009
+ $("#dialog-context-actions").replaceChildren();
12694
13010
  $("#dialog-title").textContent = title;
12695
13011
  $("#dialog-title").classList.toggle("hidden", Boolean(options.titleInput));
12696
13012
  titleInput.classList.toggle("hidden", !options.titleInput);
@@ -13160,6 +13476,10 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13160
13476
  const editButton = $("#setting-editor-edit");
13161
13477
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
13162
13478
  editButton.onclick = () => openSettingEditor(settingEditorItem);
13479
+ bindEntityDetailFavoriteButton($("#setting-editor-favorite"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
13480
+ settingEditorItem = updated;
13481
+ state.settings = upsertEntityCollection(state.settings, updated);
13482
+ });
13163
13483
  const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
13164
13484
  syncSettingEditorChrome(viewOnly);
13165
13485
  $("#setting-editor-history").onclick = async () => {
@@ -14186,11 +14506,12 @@ function renderCharacterEditorFields(item) {
14186
14506
  (canReadModule("editor")
14187
14507
  ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
14188
14508
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
14189
- characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
14509
+ characterEditorSection("profile", "人物档案", "记录人物定位、行为动力、公开人设和便于创作时快速理解的简介。",
14190
14510
  field("code", "编号", "text", item?.code) +
14191
14511
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
14192
14512
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
14193
- field("summary", "人物简介", "textarea", item?.profile?.summary)),
14513
+ field("summary", "人物简介", "textarea", item?.profile?.summary) +
14514
+ '<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
14194
14515
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
14195
14516
  field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
14196
14517
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
@@ -14238,7 +14559,8 @@ function collectCharacterBody(form) {
14238
14559
  profile: {
14239
14560
  ...profile,
14240
14561
  motivation: String(form.get("motivation") ?? "").trim(),
14241
- summary: String(form.get("summary") ?? "").trim()
14562
+ summary: String(form.get("summary") ?? "").trim(),
14563
+ personaSummary: String(form.get("personaSummary") ?? "").trim()
14242
14564
  },
14243
14565
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
14244
14566
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
@@ -14390,6 +14712,13 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14390
14712
  const editButton = $("#character-editor-edit");
14391
14713
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
14392
14714
  editButton.onclick = () => void openCharacterEditor(characterEditorItem);
14715
+ bindEntityDetailFavoriteButton($("#character-editor-favorite"), "character", () => viewOnly ? characterEditorItem : null, (updated) => {
14716
+ characterEditorItem = updated;
14717
+ if (loadedAiReferencesWorkId === state.work?.id) {
14718
+ state.characters = upsertEntityCollection(state.characters, updated);
14719
+ renderAiRoleplayCharacterSelect();
14720
+ }
14721
+ });
14393
14722
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
14394
14723
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
14395
14724
  });
@@ -14576,6 +14905,10 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
14576
14905
  editButton.textContent = `编辑${label}`;
14577
14906
  editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
14578
14907
  editButton.onclick = () => void openKnowledgeEditor(kind, knowledgeEditorItem);
14908
+ bindEntityDetailFavoriteButton($("#knowledge-editor-favorite"), "organization", () => !isRace && viewOnly ? knowledgeEditorItem : null, (updated) => {
14909
+ knowledgeEditorItem = updated;
14910
+ state.organizations = upsertEntityCollection(state.organizations, updated);
14911
+ });
14579
14912
  const form = $("#knowledge-editor-form");
14580
14913
  form.onsubmit = async (event) => {
14581
14914
  event.preventDefault();
@@ -15487,6 +15820,7 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
15487
15820
  const monthlyTokenQuota = item?.monthlyTokenQuota ?? null;
15488
15821
  const providerTokenQuotaFields = `<div class="form-field provider-token-quota-fields" data-provider-token-quota-fields><span>供应商 Token 额度</span><small>按服务器部署时区统计该供应商跨所有小说的输入与输出 Token;与单个小说额度独立。额度必须设置为大于 0;低于每日 10,000 或每月 1,000,000 时仅提示风险。</small><div class="provider-token-quota-row"><label class="checkbox-field provider-token-quota-toggle"><input name="dailyTokenQuotaEnabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}><span>启用每日额度</span></label><label class="provider-token-quota-input">每日额度<input name="dailyTokenQuota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="供应商每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label></div><div class="provider-token-quota-row"><label class="checkbox-field provider-token-quota-toggle"><input name="monthlyTokenQuotaEnabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}><span>启用每月额度</span></label><label class="provider-token-quota-input">每月额度<input name="monthlyTokenQuota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="供应商每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label></div></div>`;
15489
15822
  const thinkingTypeField = `<div class="form-field provider-thinking-type-field" data-provider-thinking-type-field><span>思考类型(开启时)</span><label class="checkbox-field"><input name="useAdaptiveThinking" type="checkbox" ${useAdaptiveThinking ? "checked" : ""} aria-describedby="provider-thinking-type-hint"><span>使用 adaptive(关闭时发送 enabled)</span></label><small id="provider-thinking-type-hint">关闭模型思考时仍发送 disabled;请只在供应商支持 adaptive 时开启。</small></div>`;
15823
+ const analysisTimeoutField = `<div class="form-field provider-analysis-timeout-field" data-provider-analysis-timeout-field><span>分析请求超时</span><label>单次请求(秒)<input name="analysisTimeoutSeconds" type="number" min="${MIN_AI_ANALYSIS_TIMEOUT_SECONDS}" max="${MAX_AI_ANALYSIS_TIMEOUT_SECONDS}" step="1" value="${esc(String(item?.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS))}" aria-describedby="provider-analysis-timeout-hint"></label><small id="provider-analysis-timeout-hint">用于全书分析和关系分析的单次非流式请求;默认 300 秒,可设置 30–3600 秒。</small></div>`;
15490
15824
  openDialog(
15491
15825
  item ? "编辑 AI 供应商" : "新建 AI 供应商",
15492
15826
  field("name", "显示名称", "text", item?.name)
@@ -15497,6 +15831,7 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
15497
15831
  + thinkingTypeField
15498
15832
  + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
15499
15833
  + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
15834
+ + analysisTimeoutField
15500
15835
  + providerTokenQuotaFields
15501
15836
  + field("note", "用途备注", "textarea", item?.note)
15502
15837
  + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
@@ -15509,11 +15844,17 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
15509
15844
  thinkingType: form.get("useAdaptiveThinking") === "on" ? "adaptive" : "enabled",
15510
15845
  concurrencyLimit: Number(form.get("concurrencyLimit")),
15511
15846
  rpmLimit: Number(form.get("rpmLimit")),
15847
+ analysisTimeoutSeconds: Number(form.get("analysisTimeoutSeconds")),
15512
15848
  dailyTokenQuota: form.get("dailyTokenQuotaEnabled") === "on" ? Number(form.get("dailyTokenQuota")) : null,
15513
15849
  monthlyTokenQuota: form.get("monthlyTokenQuotaEnabled") === "on" ? Number(form.get("monthlyTokenQuota")) : null,
15514
15850
  note: form.get("note"),
15515
15851
  status: form.get("enabled") === "on" ? "enabled" : "disabled"
15516
15852
  };
15853
+ if (
15854
+ !Number.isInteger(body.analysisTimeoutSeconds)
15855
+ || body.analysisTimeoutSeconds < MIN_AI_ANALYSIS_TIMEOUT_SECONDS
15856
+ || body.analysisTimeoutSeconds > MAX_AI_ANALYSIS_TIMEOUT_SECONDS
15857
+ ) throw new Error("分析请求超时必须设置为 30–3600 秒的整数");
15517
15858
  if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
15518
15859
  for (const [period, label] of [["daily", "每日"], ["monthly", "每月"]]) {
15519
15860
  const value = body[`${period}TokenQuota`];
@@ -15526,7 +15867,7 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
15526
15867
  await loadModels();
15527
15868
  if (item) toast(connectivityConfigurationSavedToast("provider"));
15528
15869
  },
15529
- item ? "协议、限流与凭据" : "供应商协议、限流与凭据", {
15870
+ item ? "协议、超时、限流与凭据" : "供应商协议、超时、限流与凭据", {
15530
15871
  dangerAction: item ? { label: "删除供应商", onClick: () => deletePlatformProvider(item) } : null
15531
15872
  }
15532
15873
  );
@@ -15679,10 +16020,25 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15679
16020
  if (aiRequestManager.hasActive(tab.id)) return;
15680
16021
  const composerSnapshot = captureAiPromptComposer();
15681
16022
  const requestComposerSnapshot = retry
15682
- ? { text: retry.prompt, citations: retry.citations ?? [], references: [], images: retry.images ?? [] }
16023
+ ? {
16024
+ text: retry.prompt,
16025
+ citations: retry.citations ?? [],
16026
+ references: [],
16027
+ images: retry.images ?? [],
16028
+ sceneDirection: retry.sceneDirection ?? "",
16029
+ scenePin: captureAiScenePin()
16030
+ }
15683
16031
  : composerSnapshot;
15684
16032
  const instruction = requestComposerSnapshot.text.trim();
15685
- if (!instruction) return toast("请输入指令", "error");
16033
+ const sceneDirection = $("#ai-task").value === "roleplay"
16034
+ ? String(requestComposerSnapshot.sceneDirection ?? "").trim()
16035
+ : "";
16036
+ const scenePin = $("#ai-task").value === "roleplay"
16037
+ ? normalizeRoleplayScenePin(requestComposerSnapshot.scenePin)
16038
+ : emptyRoleplayScenePin();
16039
+ if (!instruction && !sceneDirection) {
16040
+ return toast($("#ai-task").value === "roleplay" ? "请输入台词或场景旁白" : "请输入指令", "error");
16041
+ }
15686
16042
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
15687
16043
  const requestScope = currentAiRequestScope();
15688
16044
  if (!requestScope) return toast("请先选择章节", "error");
@@ -15779,6 +16135,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15779
16135
  if (taskType === "chat") {
15780
16136
  const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
15781
16137
  instruction,
16138
+ ...(sceneDirection ? { sceneDirection } : {}),
16139
+ ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
15782
16140
  scope,
15783
16141
  modelId,
15784
16142
  citations,
@@ -16269,9 +16627,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16269
16627
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
16270
16628
  const interruptionCode = typeof metadata?.interruptionCode === "string" ? metadata.interruptionCode : "AI_STREAM_FAILED";
16271
16629
  message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure || isInterrupted ? " is-error" : ""}`;
16630
+ const parsedUserTurn = role === "user" ? parseRoleplayUserTurn(text) : null;
16272
16631
  const messageBody = isFailure
16273
16632
  ? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
16274
- : renderMarkdown(text);
16633
+ : renderMarkdown(parsedUserTurn?.hasMarkup ? parsedUserTurn.userMessage : text);
16275
16634
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
16276
16635
  message.querySelector("[data-ai-tool-call-settings-link]")?.addEventListener("click", (event) => {
16277
16636
  event.preventDefault();
@@ -16293,6 +16652,18 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16293
16652
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
16294
16653
  heading.firstElementChild?.append(failureBadge);
16295
16654
  }
16655
+ if (parsedUserTurn?.hasMarkup && parsedUserTurn.sceneDirection) {
16656
+ const scene = document.createElement("div");
16657
+ scene.className = "user-message-scene";
16658
+ const sceneLabel = document.createElement("span");
16659
+ sceneLabel.className = "user-message-scene-label";
16660
+ sceneLabel.textContent = "旁白 / 场景";
16661
+ const sceneBody = document.createElement("div");
16662
+ sceneBody.className = "user-message-scene-body";
16663
+ sceneBody.textContent = parsedUserTurn.sceneDirection;
16664
+ scene.append(sceneLabel, sceneBody);
16665
+ message.querySelector(".message-body")?.before(scene);
16666
+ }
16296
16667
  const mentionGroups = role === "user"
16297
16668
  ? [
16298
16669
  ["角色", metadata?.mentionCharacterIds, state.characters],
@@ -16348,7 +16719,8 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16348
16719
  );
16349
16720
  message.dataset.aiImageAttachmentIds = JSON.stringify(imageAttachments.map((attachment) => attachment.id));
16350
16721
  appendAiMessageImageAttachments(message, imageAttachments);
16351
- attachUserCopyAction(message, text);
16722
+ attachUserCopyAction(message, parsedUserTurn?.hasMarkup ? parsedUserTurn.userMessage : text);
16723
+ if (parsedUserTurn?.hasMarkup) message.dataset.sceneDirection = parsedUserTurn.sceneDirection;
16352
16724
  }
16353
16725
  if (role === "assistant" && !text.startsWith("调用失败:")) {
16354
16726
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
@@ -17012,16 +17384,12 @@ $("#account-settings-button").addEventListener("click", () => {
17012
17384
  $("#profile-display-name").value = state.user?.displayName ?? "";
17013
17385
  renderProfileAvatar();
17014
17386
  $("#password-form").reset();
17015
- $("#api-key-result").classList.add("hidden");
17016
- $("#api-key-value").value = "";
17017
17387
  $("#account-dialog").showModal();
17018
17388
  api("/api/auth/api-key").then((status) => {
17019
- $("#api-key-status").textContent = status.configured
17020
- ? `已配置 ${status.prefix}…${status.lastUsedAt ? `,最近使用:${formatDateTime(status.lastUsedAt)}` : ",尚未使用"}`
17021
- : "尚未生成 API Key。";
17022
- $("#api-key-reset-button").textContent = status.configured ? "重置 API Key" : "生成 API Key";
17389
+ paintApiKeyStatus(status);
17023
17390
  }).catch((error) => {
17024
17391
  $("#api-key-status").textContent = error.message;
17392
+ $("#api-key-copy-button").hidden = true;
17025
17393
  });
17026
17394
  });
17027
17395
  $("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
@@ -17458,6 +17826,17 @@ function validatePasswordChangeConfirmation() {
17458
17826
  }
17459
17827
  $("#password-form input[name='newPassword']").addEventListener("input", validatePasswordChangeConfirmation);
17460
17828
  $("#password-form input[name='passwordConfirmation']").addEventListener("input", validatePasswordChangeConfirmation);
17829
+ function paintApiKeyStatus(status) {
17830
+ const copyButton = $("#api-key-copy-button");
17831
+ copyButton.hidden = !status.configured;
17832
+ if (status.configured) {
17833
+ $("#api-key-status").textContent = `已配置 ${status.prefix}…${status.lastUsedAt ? `,最近使用:${formatDateTime(status.lastUsedAt)}` : ",尚未使用"}`;
17834
+ $("#api-key-reset-button").textContent = "重置 API Key";
17835
+ return;
17836
+ }
17837
+ $("#api-key-status").textContent = "尚未生成 API Key。";
17838
+ $("#api-key-reset-button").textContent = "生成 API Key";
17839
+ }
17461
17840
  $("#api-key-reset-button").addEventListener("click", async () => {
17462
17841
  if ($("#api-key-reset-button").textContent.includes("重置") && !(await confirmToast(
17463
17842
  "重置后,所有使用旧 API Key 的 CLI 会立刻退出登录。确定继续吗?",
@@ -17465,25 +17844,31 @@ $("#api-key-reset-button").addEventListener("click", async () => {
17465
17844
  ))) return;
17466
17845
  try {
17467
17846
  const result = await api("/api/auth/api-key/reset", { method: "POST", body: {} });
17468
- $("#api-key-status").textContent = `已配置 ${result.prefix}…,尚未使用`;
17469
- $("#api-key-reset-button").textContent = "重置 API Key";
17470
- $("#api-key-value").value = result.apiKey;
17471
- $("#api-key-result").classList.remove("hidden");
17472
- $("#api-key-value").focus();
17473
- $("#api-key-value").select();
17474
- toast("新的 API Key 已生成,请立即保存");
17847
+ paintApiKeyStatus({
17848
+ configured: true,
17849
+ prefix: result.prefix,
17850
+ lastUsedAt: null,
17851
+ copyable: result.copyable !== false
17852
+ });
17853
+ toast("新的 API Key 已生成,可点击复制");
17475
17854
  } catch (error) { toast(error.message, "error"); }
17476
17855
  });
17477
17856
  $("#api-key-copy-button").addEventListener("click", async () => {
17478
- const value = $("#api-key-value").value;
17479
- if (!value) return;
17857
+ const button = $("#api-key-copy-button");
17858
+ if (button.hidden || button.disabled) return;
17859
+ button.disabled = true;
17480
17860
  try {
17481
- await navigator.clipboard.writeText(value);
17482
- toast("API Key 已复制");
17483
- } catch {
17484
- $("#api-key-value").focus();
17485
- $("#api-key-value").select();
17486
- toast("无法自动复制,请手动复制", "error");
17861
+ const result = await api("/api/auth/api-key/reveal", { method: "POST", body: {} });
17862
+ try {
17863
+ await navigator.clipboard.writeText(result.apiKey);
17864
+ toast("API Key 已复制");
17865
+ } catch {
17866
+ toast("无法自动复制,请检查浏览器剪贴板权限", "error");
17867
+ }
17868
+ } catch (error) {
17869
+ toast(error.message, "error");
17870
+ } finally {
17871
+ button.disabled = false;
17487
17872
  }
17488
17873
  });
17489
17874
  $("#logout-button").addEventListener("click", async () => {
@@ -18087,6 +18472,15 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
18087
18472
  }
18088
18473
  $("#ai-attachment-input").click();
18089
18474
  });
18475
+ $("#ai-scene-button").addEventListener("click", () => {
18476
+ toggleAiScenePanel();
18477
+ });
18478
+ for (const field of ["#ai-scene-direction", "#ai-scene-location", "#ai-scene-present", "#ai-scene-time"]) {
18479
+ $(field)?.addEventListener("input", () => {
18480
+ syncAiSceneComposer();
18481
+ persistActiveAiChatTab();
18482
+ });
18483
+ }
18090
18484
  $("#ai-attachment-input").addEventListener("change", (event) => {
18091
18485
  const input = event.currentTarget;
18092
18486
  void addAiImageFiles(input.files).finally(() => { input.value = ""; });