@musnows/scriverse 0.8.7 → 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";
@@ -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,
@@ -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
 
@@ -8533,6 +8673,116 @@ function pencilIconMarkup() {
8533
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>';
8534
8674
  }
8535
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
+
8536
8786
  function recordCardEditButton(attribute, id, label) {
8537
8787
  return `<button class="record-card-edit" type="button" data-${attribute}="${esc(id)}" aria-label="编辑${esc(label)}" title="编辑">${pencilIconMarkup()}</button>`;
8538
8788
  }
@@ -8868,9 +9118,10 @@ function openReviewDetailDialog(item) {
8868
9118
  }
8869
9119
 
8870
9120
  function settingRecordActions(item) {
8871
- return canEditModule("settings")
9121
+ const recordAction = canEditModule("settings")
8872
9122
  ? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
8873
9123
  : recordHistoryButton("setting", item.id, item.title);
9124
+ return `${recordFavoriteButton("setting", item)}${recordAction}`;
8874
9125
  }
8875
9126
 
8876
9127
  function draftTypeLabel(draftType) {
@@ -8924,6 +9175,7 @@ async function deleteDraft(item) {
8924
9175
  }
8925
9176
 
8926
9177
  function openDraftDialog(item = null, { readOnly = false } = {}) {
9178
+ let draftDialogItem = item;
8927
9179
  const viewOnly = readOnly || !canEditModule("drafts");
8928
9180
  const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
8929
9181
  const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
@@ -8981,6 +9233,18 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
8981
9233
  else control.readOnly = true;
8982
9234
  });
8983
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
+ }
8984
9248
  }
8985
9249
 
8986
9250
  async function renderDrafts(page = moduleListPages.drafts) {
@@ -9014,9 +9278,9 @@ async function renderDrafts(page = moduleListPages.drafts) {
9014
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>
9015
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>
9016
9280
  </section>`;
9017
- const actions = (item) => canEditModule("drafts")
9281
+ const actions = (item) => `${recordFavoriteButton("draft", item)}${canEditModule("drafts")
9018
9282
  ? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
9019
- : recordHistoryButton("draft", item.id, item.title);
9283
+ : recordHistoryButton("draft", item.id, item.title)}`;
9020
9284
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
9021
9285
  <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
9022
9286
  <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
@@ -9072,6 +9336,10 @@ async function renderDrafts(page = moduleListPages.drafts) {
9072
9336
  $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
9073
9337
  openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
9074
9338
  }));
9339
+ bindRecordFavoriteButtons("draft", pageResult.items, async () => {
9340
+ moduleListPages.drafts = 1;
9341
+ await renderDrafts(1);
9342
+ });
9075
9343
  bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
9076
9344
  }
9077
9345
 
@@ -9135,6 +9403,10 @@ async function renderSettings(page = moduleListPages.settings) {
9135
9403
  bindModulePagination("settings", renderSettingResults);
9136
9404
  const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
9137
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
+ });
9138
9410
  bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
9139
9411
  };
9140
9412
 
@@ -9171,6 +9443,21 @@ async function renderSettings(page = moduleListPages.settings) {
9171
9443
  renderSettingResults(page);
9172
9444
  }
9173
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
+
9174
9461
  async function renderCharacters(page = characterListPage) {
9175
9462
  const hasCharacterFilters = characterFilters.raceIds.length > 0
9176
9463
  || characterFilters.organizationIds.length > 0
@@ -9193,14 +9480,14 @@ async function renderCharacters(page = characterListPage) {
9193
9480
  [state.races, state.organizations] = [races, organizations];
9194
9481
  mountModuleCount(characterPage.total);
9195
9482
  const layout = readModuleLayout();
9196
- const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
9483
+ const characterActions = (item) => `${characterFavoriteButton(item)}${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}`;
9197
9484
  const characterLockBadge = (item) => item.lockedFields.length
9198
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>`
9199
9486
  : "";
9200
9487
  const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
9201
9488
  const details = normalizeCharacterDetails(item.attributes?.details);
9202
9489
  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}”`)}
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}”`)}
9204
9491
  <div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
9205
9492
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
9206
9493
  <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
@@ -9266,6 +9553,17 @@ async function renderCharacters(page = characterListPage) {
9266
9553
  ? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
9267
9554
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
9268
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
+ }));
9269
9567
  const readSelectedValues = (selector) => [...$(selector).querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
9270
9568
  $("#character-race-filter").addEventListener("change", async () => {
9271
9569
  characterFiltersPanelOpen = true;
@@ -9420,14 +9718,14 @@ async function renderOrganizations(page = moduleListPages.organizations) {
9420
9718
  moduleListPages.organizations = pageResult.page;
9421
9719
  const layout = readModuleLayout();
9422
9720
  const canEditOrganizations = canEditModule("organizations");
9423
- const organizationActions = (item) => canEditOrganizations
9721
+ const organizationActions = (item, { cardControl = false } = {}) => `${recordFavoriteButton("organization", item, { cardControl })}${canEditOrganizations
9424
9722
  ? recordCardEditButton("edit-organization", item.id, `组织“${item.name}”`)
9425
- : recordHistoryButton("organization", item.id, item.name);
9723
+ : recordHistoryButton("organization", item.id, item.name)}`;
9426
9724
  const organizationCardActions = (item) => canEditOrganizations
9427
- ? organizationActions(item)
9725
+ ? organizationActions(item, { cardControl: true })
9428
9726
  : `<div class="card-actions">${organizationActions(item)}</div>`;
9429
9727
  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>
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>
9431
9729
  <h3>${esc(item.name)}${entityLifecycleBadge(item.isDissolved, "已解散")}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
9432
9730
  <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
9433
9731
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
@@ -9452,6 +9750,10 @@ async function renderOrganizations(page = moduleListPages.organizations) {
9452
9750
  bindModulePagination("organizations", renderOrganizations);
9453
9751
  const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
9454
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
+ });
9455
9757
  bindEntityHistoryButtons(async () => { await renderOrganizations(pageResult.page); await loadAiReferences(); });
9456
9758
  }
9457
9759
 
@@ -11743,6 +12045,9 @@ function appendTokenUsageDetailRow(parent, label, usage, isSummary = false) {
11743
12045
  cell.textContent = tokenUsageDetailCount(usage?.[key]);
11744
12046
  row.append(cell);
11745
12047
  });
12048
+ const price = document.createElement("td");
12049
+ price.textContent = formatEstimatedCost(usage?.estimatedCost);
12050
+ row.append(price);
11746
12051
  parent.append(row);
11747
12052
  }
11748
12053
 
@@ -11778,7 +12083,7 @@ function showTokenUsageDetails(usage, title, trigger) {
11778
12083
  const description = document.createElement("p");
11779
12084
  description.id = "token-usage-details-description";
11780
12085
  description.className = "token-usage-details-note";
11781
- description.textContent = "Raw Input 为不含缓存的输入 Token;Cache Write 与 Cache Read 也是输入组成部分,并已包含在总计中。汇总行包含当前范围内全部模型。";
12086
+ description.textContent = "Raw Input 为不含缓存的输入 Token;Cache Write 与 Cache Read 也是输入组成部分,并已包含在总计中。估算价格按美元显示,未匹配价格的模型显示为暂无价格。汇总行包含当前范围内全部模型。";
11782
12087
  body.append(description);
11783
12088
 
11784
12089
  const tableScroll = document.createElement("div");
@@ -11790,7 +12095,7 @@ function showTokenUsageDetails(usage, title, trigger) {
11790
12095
  table.append(caption);
11791
12096
  const head = document.createElement("thead");
11792
12097
  const headRow = document.createElement("tr");
11793
- ["模型", "Raw Input", "Cache Write", "Cache Read", "Output", "总计", "调用", "估算调用"].forEach((label) => {
12098
+ ["模型", "Raw Input", "Cache Write", "Cache Read", "Output", "总计", "调用", "估算调用", "估算价格"].forEach((label) => {
11794
12099
  const cell = document.createElement("th");
11795
12100
  cell.scope = "col";
11796
12101
  cell.textContent = label;
@@ -11804,7 +12109,7 @@ function showTokenUsageDetails(usage, title, trigger) {
11804
12109
  if (!models.length) {
11805
12110
  const emptyRow = document.createElement("tr");
11806
12111
  const emptyCell = document.createElement("td");
11807
- emptyCell.colSpan = 8;
12112
+ emptyCell.colSpan = 9;
11808
12113
  emptyCell.textContent = "还没有模型用量记录。";
11809
12114
  emptyRow.append(emptyCell);
11810
12115
  bodyRows.append(emptyRow);
@@ -11977,7 +12282,7 @@ async function renderBookAiSettings() {
11977
12282
  );
11978
12283
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
11979
12284
  "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>`
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>`
11981
12286
  );
11982
12287
  if (!canEditModule("ai-settings")) {
11983
12288
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -12691,6 +12996,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
12691
12996
  const submitLabel = options.submitLabel ?? "保存";
12692
12997
  let submitting = false;
12693
12998
  let disabledStates = [];
12999
+ $("#dialog-context-actions").replaceChildren();
12694
13000
  $("#dialog-title").textContent = title;
12695
13001
  $("#dialog-title").classList.toggle("hidden", Boolean(options.titleInput));
12696
13002
  titleInput.classList.toggle("hidden", !options.titleInput);
@@ -13160,6 +13466,10 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13160
13466
  const editButton = $("#setting-editor-edit");
13161
13467
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
13162
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
+ });
13163
13473
  const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
13164
13474
  syncSettingEditorChrome(viewOnly);
13165
13475
  $("#setting-editor-history").onclick = async () => {
@@ -14186,11 +14496,12 @@ function renderCharacterEditorFields(item) {
14186
14496
  (canReadModule("editor")
14187
14497
  ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
14188
14498
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
14189
- characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
14499
+ characterEditorSection("profile", "人物档案", "记录人物定位、行为动力、公开人设和便于创作时快速理解的简介。",
14190
14500
  field("code", "编号", "text", item?.code) +
14191
14501
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
14192
14502
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
14193
- 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>"),
14194
14505
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
14195
14506
  field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
14196
14507
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
@@ -14238,7 +14549,8 @@ function collectCharacterBody(form) {
14238
14549
  profile: {
14239
14550
  ...profile,
14240
14551
  motivation: String(form.get("motivation") ?? "").trim(),
14241
- summary: String(form.get("summary") ?? "").trim()
14552
+ summary: String(form.get("summary") ?? "").trim(),
14553
+ personaSummary: String(form.get("personaSummary") ?? "").trim()
14242
14554
  },
14243
14555
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
14244
14556
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
@@ -14390,6 +14702,13 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14390
14702
  const editButton = $("#character-editor-edit");
14391
14703
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
14392
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
+ });
14393
14712
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
14394
14713
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
14395
14714
  });
@@ -14576,6 +14895,10 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
14576
14895
  editButton.textContent = `编辑${label}`;
14577
14896
  editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
14578
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
+ });
14579
14902
  const form = $("#knowledge-editor-form");
14580
14903
  form.onsubmit = async (event) => {
14581
14904
  event.preventDefault();
@@ -15679,10 +16002,25 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15679
16002
  if (aiRequestManager.hasActive(tab.id)) return;
15680
16003
  const composerSnapshot = captureAiPromptComposer();
15681
16004
  const requestComposerSnapshot = retry
15682
- ? { 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
+ }
15683
16013
  : composerSnapshot;
15684
16014
  const instruction = requestComposerSnapshot.text.trim();
15685
- 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
+ }
15686
16024
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
15687
16025
  const requestScope = currentAiRequestScope();
15688
16026
  if (!requestScope) return toast("请先选择章节", "error");
@@ -15779,6 +16117,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15779
16117
  if (taskType === "chat") {
15780
16118
  const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
15781
16119
  instruction,
16120
+ ...(sceneDirection ? { sceneDirection } : {}),
16121
+ ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
15782
16122
  scope,
15783
16123
  modelId,
15784
16124
  citations,
@@ -16269,9 +16609,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16269
16609
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
16270
16610
  const interruptionCode = typeof metadata?.interruptionCode === "string" ? metadata.interruptionCode : "AI_STREAM_FAILED";
16271
16611
  message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure || isInterrupted ? " is-error" : ""}`;
16612
+ const parsedUserTurn = role === "user" ? parseRoleplayUserTurn(text) : null;
16272
16613
  const messageBody = isFailure
16273
16614
  ? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
16274
- : renderMarkdown(text);
16615
+ : renderMarkdown(parsedUserTurn?.hasMarkup ? parsedUserTurn.userMessage : text);
16275
16616
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
16276
16617
  message.querySelector("[data-ai-tool-call-settings-link]")?.addEventListener("click", (event) => {
16277
16618
  event.preventDefault();
@@ -16293,6 +16634,18 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16293
16634
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
16294
16635
  heading.firstElementChild?.append(failureBadge);
16295
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
+ }
16296
16649
  const mentionGroups = role === "user"
16297
16650
  ? [
16298
16651
  ["角色", metadata?.mentionCharacterIds, state.characters],
@@ -16348,7 +16701,8 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16348
16701
  );
16349
16702
  message.dataset.aiImageAttachmentIds = JSON.stringify(imageAttachments.map((attachment) => attachment.id));
16350
16703
  appendAiMessageImageAttachments(message, imageAttachments);
16351
- attachUserCopyAction(message, text);
16704
+ attachUserCopyAction(message, parsedUserTurn?.hasMarkup ? parsedUserTurn.userMessage : text);
16705
+ if (parsedUserTurn?.hasMarkup) message.dataset.sceneDirection = parsedUserTurn.sceneDirection;
16352
16706
  }
16353
16707
  if (role === "assistant" && !text.startsWith("调用失败:")) {
16354
16708
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
@@ -17012,16 +17366,12 @@ $("#account-settings-button").addEventListener("click", () => {
17012
17366
  $("#profile-display-name").value = state.user?.displayName ?? "";
17013
17367
  renderProfileAvatar();
17014
17368
  $("#password-form").reset();
17015
- $("#api-key-result").classList.add("hidden");
17016
- $("#api-key-value").value = "";
17017
17369
  $("#account-dialog").showModal();
17018
17370
  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";
17371
+ paintApiKeyStatus(status);
17023
17372
  }).catch((error) => {
17024
17373
  $("#api-key-status").textContent = error.message;
17374
+ $("#api-key-copy-button").hidden = true;
17025
17375
  });
17026
17376
  });
17027
17377
  $("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
@@ -17458,6 +17808,17 @@ function validatePasswordChangeConfirmation() {
17458
17808
  }
17459
17809
  $("#password-form input[name='newPassword']").addEventListener("input", validatePasswordChangeConfirmation);
17460
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
+ }
17461
17822
  $("#api-key-reset-button").addEventListener("click", async () => {
17462
17823
  if ($("#api-key-reset-button").textContent.includes("重置") && !(await confirmToast(
17463
17824
  "重置后,所有使用旧 API Key 的 CLI 会立刻退出登录。确定继续吗?",
@@ -17465,25 +17826,31 @@ $("#api-key-reset-button").addEventListener("click", async () => {
17465
17826
  ))) return;
17466
17827
  try {
17467
17828
  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 已生成,请立即保存");
17829
+ paintApiKeyStatus({
17830
+ configured: true,
17831
+ prefix: result.prefix,
17832
+ lastUsedAt: null,
17833
+ copyable: result.copyable !== false
17834
+ });
17835
+ toast("新的 API Key 已生成,可点击复制");
17475
17836
  } catch (error) { toast(error.message, "error"); }
17476
17837
  });
17477
17838
  $("#api-key-copy-button").addEventListener("click", async () => {
17478
- const value = $("#api-key-value").value;
17479
- if (!value) return;
17839
+ const button = $("#api-key-copy-button");
17840
+ if (button.hidden || button.disabled) return;
17841
+ button.disabled = true;
17480
17842
  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");
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;
17487
17854
  }
17488
17855
  });
17489
17856
  $("#logout-button").addEventListener("click", async () => {
@@ -18087,6 +18454,15 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
18087
18454
  }
18088
18455
  $("#ai-attachment-input").click();
18089
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
+ }
18090
18466
  $("#ai-attachment-input").addEventListener("change", (event) => {
18091
18467
  const input = event.currentTarget;
18092
18468
  void addAiImageFiles(input.files).finally(() => { input.value = ""; });