@musnows/scriverse 0.6.7 → 0.6.9

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.
@@ -5,7 +5,7 @@ import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
7
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
8
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260731-model-context-guidance-v1";
8
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
9
9
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
10
10
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
11
11
  import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
@@ -36,7 +36,7 @@ import {
36
36
  taskScopeLabel,
37
37
  timelineStatusLabel,
38
38
  characterStateFieldLabel
39
- } from "/display-labels.js?v=20260801-google-vertex";
39
+ } from "/display-labels.js?v=20260804-agent-history-search-v1";
40
40
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
41
41
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
42
42
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
@@ -45,7 +45,7 @@ import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20
45
45
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
46
46
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
47
47
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
48
- import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
48
+ import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-search-v1";
49
49
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
50
50
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
51
51
  import {
@@ -195,6 +195,10 @@ let backgroundTaskCenterWorkId = null;
195
195
  let backgroundTaskCenterTasksInitialized = false;
196
196
  let backgroundTaskCenterTaskSnapshots = new Map();
197
197
  let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
198
+ let productUpdateStatus = null;
199
+ let productUpdateChecking = false;
200
+ let productUpdateTimer = null;
201
+ const fallbackProductUpdatePollInterval = 60 * 60 * 1000;
198
202
  const systemHealthPollInterval = 30_000;
199
203
  let systemHealthTimer = null;
200
204
  let systemHealthSnapshot = { status: "checking", version: "" };
@@ -965,6 +969,7 @@ let knowledgeSectionEditorDirty = false;
965
969
  let characterEditorVersions = [];
966
970
  let characterEditorRelationships = [];
967
971
  let characterEditorRelationshipsLoading = false;
972
+ let characterEditorRelationshipsLoaded = false;
968
973
  let characterEditorSections = [];
969
974
  let characterSectionPendingAttachments = [];
970
975
  let markdownEditorPendingAttachments = [];
@@ -1483,7 +1488,9 @@ const AI_TOOL_DISPLAY_NAMES = {
1483
1488
  search_story_entities: "搜索作品实体",
1484
1489
  read_character_sections: "读取人物 Markdown 章节",
1485
1490
  search_drafts: "搜索想法",
1486
- recall_self: "回忆自身"
1491
+ recall_self: "回忆自身",
1492
+ image: "读取设定图片",
1493
+ recall_relationship: "回忆人物关系"
1487
1494
  };
1488
1495
 
1489
1496
  const AI_TOOL_DESCRIPTIONS = {
@@ -1493,7 +1500,9 @@ const AI_TOOL_DESCRIPTIONS = {
1493
1500
  search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1494
1501
  read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
1495
1502
  search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
1496
- recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。"
1503
+ recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
1504
+ image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
1505
+ recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。"
1497
1506
  };
1498
1507
 
1499
1508
  let aiFeedScrollFrame = null;
@@ -1803,8 +1812,10 @@ async function ensureAiConversationsLoaded() {
1803
1812
  }
1804
1813
  }
1805
1814
 
1806
- async function openAiConversation(conversationId, hideHistory = true) {
1807
- const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1815
+ async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
1816
+ const parameters = new URLSearchParams({ page: "1", limit: "100" });
1817
+ if (focusMessageId) parameters.set("messageId", String(focusMessageId));
1818
+ const conversation = await api(`/api/ai-conversations/${conversationId}?${parameters}`);
1808
1819
  upsertAiConversationSummary(conversation);
1809
1820
  state.aiConversationId = conversation.id;
1810
1821
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
@@ -1830,6 +1841,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
1830
1841
  if (hideHistory) setAiHistoryVisible(false);
1831
1842
  }
1832
1843
 
1844
+ function focusAiConversationMessage(messageId) {
1845
+ const message = [...$("#ai-feed").querySelectorAll("[data-message-id]")]
1846
+ .find((candidate) => candidate.dataset.messageId === String(messageId));
1847
+ if (!message) return;
1848
+ message.scrollIntoView({ behavior: "smooth", block: "center" });
1849
+ message.classList.add("is-search-target");
1850
+ window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
1851
+ }
1852
+
1833
1853
  async function createNewAiConversation(taskType = "chat") {
1834
1854
  if (!state.work) return;
1835
1855
  const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: { taskType } });
@@ -2507,10 +2527,7 @@ async function api(path, options = {}) {
2507
2527
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2508
2528
  const restarted = await checkSystemBoot(true);
2509
2529
  if (!restarted) {
2510
- state.user = null;
2511
- state.csrfToken = null;
2512
- moduleRequestCache.clear();
2513
- showAuth(false);
2530
+ invalidateAuthentication();
2514
2531
  }
2515
2532
  }
2516
2533
  throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
@@ -2650,6 +2667,35 @@ function applyProductHealthMetadata(health) {
2650
2667
  });
2651
2668
  }
2652
2669
 
2670
+ function applyProductUpdateMetadata(update) {
2671
+ if (update && typeof update === "object") productUpdateStatus = update;
2672
+ if (update?.checked !== true) {
2673
+ renderBackgroundTaskCenter();
2674
+ return;
2675
+ }
2676
+ const latestVersion = String(update.latestVersion ?? "").trim();
2677
+ const releaseUrl = String(update.releaseUrl ?? "").trim();
2678
+ const updateAvailable = update.updateAvailable === true && Boolean(latestVersion) && Boolean(releaseUrl);
2679
+ const updateDot = document.querySelector("[data-settings-update-dot]");
2680
+ updateDot?.classList.toggle("hidden", !updateAvailable);
2681
+ const settingsButton = $("#settings-button");
2682
+ settingsButton.setAttribute("aria-label", updateAvailable ? `设置,有新版本 v${latestVersion}` : "设置");
2683
+ settingsButton.title = updateAvailable ? `设置,有新版本 v${latestVersion}` : "设置";
2684
+ document.querySelectorAll("[data-product-footer-update]").forEach((element) => {
2685
+ element.classList.toggle("hidden", !updateAvailable);
2686
+ if (updateAvailable) {
2687
+ element.textContent = `发现新版本 v${latestVersion},查看更新说明`;
2688
+ element.href = releaseUrl;
2689
+ element.setAttribute("aria-label", `查看叙界 v${latestVersion} 更新说明`);
2690
+ } else {
2691
+ element.textContent = "";
2692
+ element.removeAttribute("href");
2693
+ element.removeAttribute("aria-label");
2694
+ }
2695
+ });
2696
+ renderBackgroundTaskCenter();
2697
+ }
2698
+
2653
2699
  function scheduleSystemHealthCheck() {
2654
2700
  if (systemHealthTimer !== null) window.clearTimeout(systemHealthTimer);
2655
2701
  systemHealthTimer = window.setTimeout(() => {
@@ -2669,11 +2715,44 @@ async function refreshSystemHealth() {
2669
2715
  }
2670
2716
  }
2671
2717
 
2718
+ function scheduleProductUpdateCheck() {
2719
+ if (productUpdateTimer !== null) window.clearTimeout(productUpdateTimer);
2720
+ if (productUpdateStatus?.enabled === false) {
2721
+ productUpdateTimer = null;
2722
+ return;
2723
+ }
2724
+ const nextCheckAt = Date.parse(String(productUpdateStatus?.nextCheckAt ?? ""));
2725
+ const delay = Number.isFinite(nextCheckAt)
2726
+ ? Math.max(1000, nextCheckAt - Date.now())
2727
+ : fallbackProductUpdatePollInterval;
2728
+ productUpdateTimer = window.setTimeout(() => {
2729
+ productUpdateTimer = null;
2730
+ void refreshProductUpdate();
2731
+ }, Math.min(delay, 2_147_483_647));
2732
+ }
2733
+
2734
+ async function refreshProductUpdate() {
2735
+ if (productUpdateChecking || productUpdateStatus?.enabled === false) return;
2736
+ productUpdateChecking = true;
2737
+ renderBackgroundTaskCenter();
2738
+ try {
2739
+ applyProductUpdateMetadata(await api("/api/update-check"));
2740
+ } catch {
2741
+ // 更新探测失败时静默保留当前展示状态
2742
+ } finally {
2743
+ productUpdateChecking = false;
2744
+ renderBackgroundTaskCenter();
2745
+ scheduleProductUpdateCheck();
2746
+ }
2747
+ }
2748
+
2672
2749
  async function initializeProductFooters() {
2673
2750
  const year = String(new Date().getFullYear());
2674
2751
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
2675
2752
  applyProductHealthMetadata(null);
2753
+ applyProductUpdateMetadata(null);
2676
2754
  await refreshSystemHealth();
2755
+ void refreshProductUpdate();
2677
2756
  }
2678
2757
 
2679
2758
  function observeSystemBootId(value) {
@@ -2768,8 +2847,28 @@ async function refreshAuthCaptcha(target = "login") {
2768
2847
  if (answerInput) answerInput.value = "";
2769
2848
  }
2770
2849
 
2850
+ function clearAuthenticationOverlays() {
2851
+ const toastRegion = $("#toast-region");
2852
+ toastRegion.replaceChildren();
2853
+ document.querySelectorAll("[popover]").forEach((popover) => {
2854
+ if (typeof popover.hidePopover === "function" && popover.matches(":popover-open")) popover.hidePopover();
2855
+ });
2856
+ document.querySelectorAll("dialog[open]").forEach((dialog) => dialog.close());
2857
+ }
2858
+
2859
+ function invalidateAuthentication() {
2860
+ state.user = null;
2861
+ state.csrfToken = null;
2862
+ moduleRequestCache.clear();
2863
+ showAuth(false);
2864
+ }
2865
+
2771
2866
  function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
2772
2867
  if (state.user) return;
2868
+ document.documentElement.classList.remove("dev-auth-bypass");
2869
+ document.documentElement.classList.add("login-route");
2870
+ window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
2871
+ clearAuthenticationOverlays();
2773
2872
  document.body.classList.add("auth-pending");
2774
2873
  $("#auth-view").classList.remove("hidden");
2775
2874
  const canRegister = registrationOpen === true;
@@ -2897,6 +2996,7 @@ function dismissChapterInsightToast() {
2897
2996
  }
2898
2997
 
2899
2998
  function toast(message, type = "info") {
2999
+ if (systemRestartDetected || (!state.user && document.documentElement.classList.contains("login-route"))) return;
2900
3000
  const region = $("#toast-region");
2901
3001
  const element = document.createElement("div");
2902
3002
  element.className = `toast ${type}`;
@@ -3876,8 +3976,9 @@ function renderSearchResults(results, query) {
3876
3976
  $("#search-results").innerHTML = '<p class="search-results-status">未找到相关内容。</p>';
3877
3977
  return;
3878
3978
  }
3979
+ const orderedResults = prioritizeGlobalSearchResults(results);
3879
3980
  const matchKindLabel = { metadata: "资料命中", exact: "精确命中", phonetic: "拼音命中" };
3880
- $("#search-results").innerHTML = `<p class="search-results-summary">找到 ${results.length} 条结果,按综合相关度排序。</p>${results.map((item) => {
3981
+ $("#search-results").innerHTML = `<p class="search-results-summary">找到 ${orderedResults.length} 条结果,非正文结果优先,正文条目随后展示。</p>${orderedResults.map((item) => {
3881
3982
  const matchKinds = Array.isArray(item.matchKinds) ? item.matchKinds : [];
3882
3983
  const lineRange = Number.isInteger(item.startLine)
3883
3984
  ? `<span class="search-result-chip">${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
@@ -3892,7 +3993,7 @@ function renderSearchResults(results, query) {
3892
3993
  }).join("")}`;
3893
3994
  $("#search-results").querySelectorAll(".search-result").forEach((button, index) => {
3894
3995
  button.addEventListener("click", () => {
3895
- openSearchResult(results[index])
3996
+ openSearchResult(orderedResults[index])
3896
3997
  .catch((error) => toast(error.message, "error"));
3897
3998
  });
3898
3999
  });
@@ -3961,6 +4062,12 @@ async function openSearchResult(result) {
3961
4062
  || !$("#platform-usage-view").classList.contains("hidden")
3962
4063
  || !$("#work-audit-view").classList.contains("hidden");
3963
4064
  if (inSettings) await returnFromSettings();
4065
+ if (target.kind === "agent-history") {
4066
+ ensureAiPanelExpanded();
4067
+ await openAiConversation(target.conversationId, true, target.messageId);
4068
+ if (target.messageId) focusAiConversationMessage(target.messageId);
4069
+ return;
4070
+ }
3964
4071
  if (target.kind === "chapter") {
3965
4072
  await selectChapter(target.id);
3966
4073
  if (state.chapter?.id === target.id && target.startLine) {
@@ -5067,15 +5174,14 @@ function bindModuleContentInteractions() {
5067
5174
  moduleContentInteractionsBound = true;
5068
5175
  const host = $("#module-content");
5069
5176
  const open = async (card) => {
5070
- const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization;
5177
+ const id = card.dataset.openSetting ?? card.dataset.openRace ?? card.dataset.openOrganization;
5071
5178
  if (!id) return;
5072
5179
  if (card.dataset.openSetting) return openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly: true });
5073
- if (card.dataset.openCharacter) return openCharacterEditor(await api(`/api/characters/${encodeURIComponent(id)}`), { readOnly: true });
5074
5180
  if (card.dataset.openRace) return openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly: true });
5075
5181
  if (card.dataset.openOrganization) return openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly: true });
5076
5182
  };
5077
5183
  const findCard = (target) => target instanceof Element
5078
- ? target.closest("[data-open-setting], [data-open-character], [data-open-race], [data-open-organization]")
5184
+ ? target.closest("[data-open-setting], [data-open-race], [data-open-organization]")
5079
5185
  : null;
5080
5186
  host.addEventListener("click", (event) => {
5081
5187
  if (event.target instanceof Element && event.target.closest("button, a, summary")) return;
@@ -5608,10 +5714,7 @@ async function renderRaces() {
5608
5714
  }
5609
5715
 
5610
5716
  async function renderOrganizations(page = moduleListPages.organizations) {
5611
- [state.organizations, state.characters] = await Promise.all([
5612
- moduleApiAllPages("organizations", `/api/works/${state.work.id}/organizations`),
5613
- canReadModule("characters") ? moduleApiAllPages("organizations", `/api/works/${state.work.id}/characters`) : Promise.resolve([])
5614
- ]);
5717
+ state.organizations = await moduleApiAllPages("organizations", `/api/works/${state.work.id}/organizations`);
5615
5718
  mountModuleCount(state.organizations.length);
5616
5719
  const pageResult = paginateModuleItems(state.organizations, page, "organizations");
5617
5720
  moduleListPages.organizations = pageResult.page;
@@ -5916,10 +6019,11 @@ async function renderReviews(page = moduleListPages.reviews) {
5916
6019
  const canResolveReview = canEditModule("reviews");
5917
6020
  const canMergeCharacters = canResolveReview
5918
6021
  && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
5919
- const [reviews, characters] = await Promise.all([
5920
- moduleApiAllPages("reviews", `/api/works/${state.work.id}/reviews`),
5921
- canReadCharacters ? moduleApiAllPages("reviews", `/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
5922
- ]);
6022
+ const reviews = await moduleApiAllPages("reviews", `/api/works/${state.work.id}/reviews`);
6023
+ const hasCharacterDuplicateReviews = reviews.some((item) => item.itemType === "character-duplicate");
6024
+ const characters = canReadCharacters && hasCharacterDuplicateReviews
6025
+ ? await moduleApiAllPages("reviews", `/api/works/${state.work.id}/characters?includeMerged=1`)
6026
+ : [];
5923
6027
  mountModuleCount(reviews.length);
5924
6028
  const pageResult = paginateModuleItems(reviews, page, "reviews");
5925
6029
  moduleListPages.reviews = pageResult.page;
@@ -6755,12 +6859,12 @@ function renderProviderCards(providers, models) {
6755
6859
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
6756
6860
  const modelStatus = !model.enabled
6757
6861
  ? `<span class="model-status-badge is-disabled">模型已停用</span>`
6758
- : provider.status !== "enabled"
6759
- ? `<span class="model-status-badge is-disabled">供应商已停用</span>`
6760
- : provider.connectionStatus !== "success"
6761
- ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
6762
- : "";
6763
- return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
6862
+ : provider.connectionStatus !== "success"
6863
+ ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
6864
+ : "";
6865
+ const capability = model.multimodalEnabled ? " · 多模态" : "";
6866
+ const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
6867
+ return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
6764
6868
  }).join("")}</div>
6765
6869
  <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
6766
6870
  }).join("")}</div>`
@@ -6776,8 +6880,11 @@ function bindPlatformProviderActions(host, providers, models) {
6776
6880
  await renderPlatformAiConfig();
6777
6881
  await loadModels();
6778
6882
  }));
6779
- host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
6780
- host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
6883
+ host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel, null, providers.find((provider) => provider.id === button.dataset.addModel))));
6884
+ host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => {
6885
+ const model = models.find((item) => item.id === button.dataset.editModel);
6886
+ openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId));
6887
+ }));
6781
6888
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
6782
6889
  }
6783
6890
 
@@ -6785,14 +6892,21 @@ function renderTaskDefaults(models, providers, taskDefaults, settings) {
6785
6892
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
6786
6893
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
6787
6894
  const availableModels = models.filter((model) => isSelectableModel(model));
6895
+ const imageModels = availableModels.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol === "openai-chat-completions");
6788
6896
  const availableModelIds = new Set(availableModels.map((model) => model.id));
6789
6897
  const currentDefaultModels = taskDefaults
6790
6898
  .map((item) => item.model)
6791
6899
  .filter((model) => model && !availableModelIds.has(model.id));
6792
6900
  const optionModels = [...availableModels, ...currentDefaultModels];
6793
- return optionModels.length ? `<section class="config-section">
6901
+ return optionModels.length || imageModels.length ? `<section class="config-section">
6794
6902
  <div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
6795
- <table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody><tr><td>创作助手对话标题生成</td><td><select class="default-model-select" data-title-generation-default aria-label="创作助手对话标题生成">
6903
+ <table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody><tr><td>多模态读图工具</td><td><select class="default-model-select" data-image-tool-default aria-label="多模态读图工具默认模型">
6904
+ <option value="" ${settings.imageToolModelId ? "" : "selected"}>跟随平台默认</option>
6905
+ ${imageModels.map((model) => {
6906
+ const provider = providerById.get(model.providerId);
6907
+ return `<option value="${esc(model.id)}" ${model.id === settings.imageToolModelId ? "selected" : ""}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
6908
+ }).join("")}
6909
+ </select></td></tr><tr><td>创作助手对话标题生成</td><td><select class="default-model-select" data-title-generation-default aria-label="创作助手对话标题生成">
6796
6910
  <option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
6797
6911
  ${models.map((model) => {
6798
6912
  const provider = providerById.get(model.providerId);
@@ -6857,8 +6971,7 @@ function relationshipIndexStatusMarkup(status) {
6857
6971
  function updateBackgroundTaskCenterVisibility() {
6858
6972
  const button = $("#background-task-button");
6859
6973
  if (!button) return;
6860
- const visible = Boolean(state.work)
6861
- && (canReadModule("tasks") || canReadModule("ai-settings"));
6974
+ const visible = Boolean(state.user);
6862
6975
  button.classList.toggle("hidden", !visible);
6863
6976
  if (!visible) {
6864
6977
  $("#background-task-count")?.classList.add("hidden");
@@ -6867,7 +6980,7 @@ function updateBackgroundTaskCenterVisibility() {
6867
6980
  const activityCount = backgroundTaskActivityCount(
6868
6981
  backgroundTaskCenterSnapshot.taskPage,
6869
6982
  backgroundTaskCenterSnapshot.relationshipIndex
6870
- );
6983
+ ) + (productUpdateChecking ? 1 : 0);
6871
6984
  const badge = $("#background-task-count");
6872
6985
  badge.textContent = activityCount > 99 ? "99+" : String(activityCount);
6873
6986
  badge.classList.toggle("hidden", activityCount === 0);
@@ -6876,6 +6989,37 @@ function updateBackgroundTaskCenterVisibility() {
6876
6989
  button.setAttribute("title", activityCount > 0 ? `后台任务中心 · ${activityCount} 项进行中` : "后台任务中心");
6877
6990
  }
6878
6991
 
6992
+ function backgroundProductUpdateMarkup() {
6993
+ const status = productUpdateStatus;
6994
+ const updateCheckDisabled = status?.enabled === false;
6995
+ const currentVersion = String(status?.currentVersion ?? "").trim();
6996
+ const latestVersion = String(status?.latestVersion ?? "").trim();
6997
+ const nextCheckAt = formatDateTime(status?.nextCheckAt) || "等待首次探测";
6998
+ const updateAvailable = status?.updateAvailable === true && Boolean(status?.releaseUrl);
6999
+ const badgeClass = productUpdateChecking ? "running" : updateCheckDisabled ? "unknown" : updateAvailable ? "partial" : status?.checked === true ? "completed" : status ? "unknown" : "pending";
7000
+ const badgeLabel = productUpdateChecking ? "探测中" : updateCheckDisabled ? "已关闭" : updateAvailable ? "有新版本" : status?.checked === true ? "已是最新" : status ? "本次未探测" : "等待探测";
7001
+ const detail = productUpdateChecking
7002
+ ? "正在请求 GitHub 最新 Release"
7003
+ : updateCheckDisabled
7004
+ ? "已通过服务端配置关闭版本更新探测"
7005
+ : updateAvailable
7006
+ ? `当前 v${currentVersion || "—"} · 最新 v${latestVersion} · 下次探测 ${nextCheckAt}`
7007
+ : status?.checked === true
7008
+ ? `当前 v${currentVersion || "—"} · 下次探测 ${nextCheckAt}`
7009
+ : status
7010
+ ? `GitHub 暂不可用 · 下次探测 ${nextCheckAt}`
7011
+ : "应用启动后将在后台探测 GitHub Release";
7012
+ return `<section class="background-task-section">
7013
+ <div class="background-task-section-heading"><div><strong>版本更新探测</strong><small>按服务端配置定期检查 GitHub Release</small></div></div>
7014
+ <div class="background-task-list"><article class="background-task-row">
7015
+ <span class="task-status-badge is-${badgeClass}"><span class="task-status-indicator" aria-hidden="true"></span><span>${badgeLabel}</span></span>
7016
+ <div><strong>叙界版本更新</strong><small>${esc(detail)}</small></div>
7017
+ <span class="background-task-progress">${updateAvailable ? `v${esc(latestVersion)}` : "系统"}</span>
7018
+ ${updateAvailable ? `<a class="ghost-button background-update-link" href="${esc(status.releaseUrl)}" target="_blank" rel="noopener noreferrer" aria-label="查看叙界 v${esc(latestVersion)} 更新说明">说明</a>` : '<span class="background-task-action-placeholder" aria-hidden="true"></span>'}
7019
+ </article></div>
7020
+ </section>`;
7021
+ }
7022
+
6879
7023
  function backgroundTaskTransitionMessage(transition) {
6880
7024
  const label = analysisTaskTypeLabel(transition.task.taskType);
6881
7025
  if (transition.status === "partial") return { message: `${label}部分失败,请打开任务详情查看`, type: "error" };
@@ -6934,17 +7078,17 @@ function renderBackgroundTaskCenter() {
6934
7078
  updateBackgroundTaskCenterVisibility();
6935
7079
  const content = $("#background-task-content");
6936
7080
  if (!content) return;
6937
- content.innerHTML = `${backgroundTaskListMarkup(
7081
+ content.innerHTML = `${backgroundProductUpdateMarkup()}${state.work ? `${backgroundTaskListMarkup(
6938
7082
  backgroundTaskCenterSnapshot.taskPage,
6939
7083
  backgroundTaskCenterSnapshot.errors.tasks
6940
7084
  )}${backgroundIndexMarkup(
6941
7085
  backgroundTaskCenterSnapshot.relationshipIndex,
6942
7086
  backgroundTaskCenterSnapshot.errors.index
6943
- )}`;
7087
+ )}` : ""}`;
6944
7088
  $("#background-task-dialog-meta").textContent = state.work
6945
- ? `《${state.work.title}》的分析任务与索引队列`
6946
- : "当前作品的分析任务与索引队列";
6947
- $("#background-task-open-analysis").classList.toggle("hidden", !canReadModule("tasks"));
7089
+ ? `系统更新与《${state.work.title}》的分析任务、索引队列`
7090
+ : "系统更新探测状态";
7091
+ $("#background-task-open-analysis").classList.toggle("hidden", !state.work || !canReadModule("tasks"));
6948
7092
  content.querySelectorAll("[data-background-task-detail]").forEach((button) => button.addEventListener("click", async () => {
6949
7093
  button.disabled = true;
6950
7094
  try {
@@ -7063,8 +7207,7 @@ function stopBackgroundTaskCenter() {
7063
7207
  backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
7064
7208
  const dialog = $("#background-task-dialog");
7065
7209
  if (dialog?.open) dialog.close();
7066
- $("#background-task-button")?.classList.add("hidden");
7067
- $("#background-task-count")?.classList.add("hidden");
7210
+ updateBackgroundTaskCenterVisibility();
7068
7211
  }
7069
7212
 
7070
7213
  function startBackgroundTaskCenter(workId) {
@@ -7081,7 +7224,14 @@ async function renderPlatformAiConfig() {
7081
7224
  api("/api/platform/ai/settings")
7082
7225
  ]);
7083
7226
  const host = $("#platform-ai-content");
7084
- host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section>${renderProviderCards(providers, models)}`;
7227
+ const providerById = new Map(providers.map((provider) => [provider.id, provider]));
7228
+ const imageModels = models.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol === "openai-chat-completions");
7229
+ const imageModelOptions = imageModels.map((model) => {
7230
+ const provider = providerById.get(model.providerId);
7231
+ const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
7232
+ return `<option value="${esc(model.id)}" ${model.id === settings.imageToolModelId ? "selected" : ""} ${available || model.id === settings.imageToolModelId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
7233
+ }).join("");
7234
+ host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section><section class="config-section platform-image-tool-section"><div class="config-section-header"><div><h2>多模态读图默认模型</h2><p>Agent 的 image 工具使用这里配置的模型读取设定库图片;作品可以在自己的 AI 设置中覆盖此选择。</p></div></div><div class="platform-image-tool-panel"><label class="platform-image-tool-field"><span>当前平台默认模型</span><select id="platform-image-tool-model" aria-label="平台多模态读图默认模型"><option value="">未配置</option>${imageModelOptions}</select></label><button id="save-platform-image-tool-model" class="ghost-button config-save-button" type="button">保存默认模型</button></div></section><section class="config-section platform-providers-section"><div class="config-section-header"><div><h2>模型供应商配置</h2><p>管理供应商连接、模型列表和连接状态;模型的多模态能力在对应模型配置中设置。</p></div></div>${renderProviderCards(providers, models)}</section>`;
7085
7235
  $("#save-platform-system-prompt").addEventListener("click", async () => {
7086
7236
  const button = $("#save-platform-system-prompt");
7087
7237
  button.disabled = true;
@@ -7094,6 +7244,19 @@ async function renderPlatformAiConfig() {
7094
7244
  button.disabled = false;
7095
7245
  }
7096
7246
  });
7247
+ $("#save-platform-image-tool-model").addEventListener("click", async () => {
7248
+ const button = $("#save-platform-image-tool-model");
7249
+ button.disabled = true;
7250
+ try {
7251
+ await api("/api/platform/ai/settings", { method: "PATCH", body: { imageToolModelId: $("#platform-image-tool-model").value || null } });
7252
+ toast("平台多模态读图默认模型已更新");
7253
+ await renderPlatformAiConfig();
7254
+ } catch (error) {
7255
+ toast(error.message, "error");
7256
+ } finally {
7257
+ button.disabled = false;
7258
+ }
7259
+ });
7097
7260
  bindPlatformProviderActions(host, providers, models);
7098
7261
  }
7099
7262
 
@@ -7253,7 +7416,7 @@ async function renderBookAiSettings() {
7253
7416
  ]);
7254
7417
  const host = $("#module-content");
7255
7418
  const workId = String(state.work.id);
7256
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
7419
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"]);
7257
7420
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
7258
7421
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
7259
7422
  const quotaRemainingTokens = usage?.quota?.remainingTokens === null
@@ -7280,7 +7443,7 @@ async function renderBookAiSettings() {
7280
7443
  );
7281
7444
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
7282
7445
  "beforeend",
7283
- `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label>`
7446
+ `<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>`
7284
7447
  );
7285
7448
  if (!canEditModule("ai-settings")) {
7286
7449
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -7485,6 +7648,18 @@ async function renderBookAiSettings() {
7485
7648
  await renderBookAiSettings();
7486
7649
  await loadModels();
7487
7650
  });
7651
+ host.querySelector("[data-image-tool-default]")?.addEventListener("change", async (event) => {
7652
+ const select = event.currentTarget;
7653
+ select.disabled = true;
7654
+ try {
7655
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { imageToolModelId: select.value || null } });
7656
+ toast("本书多模态读图模型已更新");
7657
+ } catch (error) {
7658
+ toast(error.message, "error");
7659
+ }
7660
+ await renderBookAiSettings();
7661
+ await loadModels();
7662
+ });
7488
7663
  host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
7489
7664
  select.disabled = true;
7490
7665
  try {
@@ -7692,7 +7867,7 @@ async function ensureAiReferencesLoaded() {
7692
7867
 
7693
7868
  function field(name, label, type = "text", value = "", options = []) {
7694
7869
  if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
7695
- if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-attachment-module="${esc(options.attachmentModule ?? "settings")}" data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
7870
+ if (type === "markdown") return `<div class="form-field markdown-editor-field${options.readOnly ? " is-read-only" : ""}" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-attachment-module="${esc(options.attachmentModule ?? "settings")}" data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"${options.readOnly ? ' aria-readonly="true"' : ""}></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
7696
7871
  if (type === "item-list") {
7697
7872
  const values = Array.isArray(value) && value.length ? value : [""];
7698
7873
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
@@ -8295,6 +8470,15 @@ function activateCharacterEditorTab(key) {
8295
8470
  button.tabIndex = active ? 0 : -1;
8296
8471
  });
8297
8472
  document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
8473
+ if (
8474
+ key === "relationships"
8475
+ && characterEditorItem?.id
8476
+ && canReadModule("relationships")
8477
+ && !characterEditorRelationshipsLoaded
8478
+ && !characterEditorRelationshipsLoading
8479
+ ) {
8480
+ void loadCharacterEditorRelationships(characterEditorItem.id);
8481
+ }
8298
8482
  }
8299
8483
 
8300
8484
  function setCharacterHistoryVisible(visible) {
@@ -8316,6 +8500,10 @@ function renderCharacterEditorRelationships() {
8316
8500
  host.innerHTML = '<p class="character-relationship-status">正在读取人物关系……</p>';
8317
8501
  return;
8318
8502
  }
8503
+ if (!characterEditorRelationshipsLoaded) {
8504
+ host.innerHTML = '<p class="character-relationship-status">打开人物关系分区后载入关系。</p>';
8505
+ return;
8506
+ }
8319
8507
  const characterId = String(characterEditorItem.id);
8320
8508
  const nameOf = (id) => state.characters.find((character) => character.id === id)?.name ?? "未知角色";
8321
8509
  const rows = characterEditorRelationships.map((relationship) => {
@@ -8338,20 +8526,23 @@ function renderCharacterEditorRelationships() {
8338
8526
  host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
8339
8527
  }
8340
8528
 
8341
- async function loadCharacterEditorRelationships(characterId) {
8529
+ async function loadCharacterEditorRelationships(characterId, { refresh = false } = {}) {
8342
8530
  const workId = state.work?.id;
8343
8531
  if (!workId || characterEditorItem?.id !== characterId) return;
8532
+ if (!refresh && (characterEditorRelationshipsLoaded || characterEditorRelationshipsLoading)) return;
8344
8533
  characterEditorRelationshipsLoading = true;
8534
+ characterEditorRelationshipsLoaded = false;
8345
8535
  renderCharacterEditorRelationships();
8346
8536
  let loaded = false;
8347
8537
  try {
8348
8538
  const [characters, relationships] = await Promise.all([
8349
- apiAllPages(`/api/works/${workId}/characters`),
8350
- apiAllPages(`/api/works/${workId}/relationships`)
8539
+ moduleApiAllPages("characters", `/api/works/${workId}/characters`),
8540
+ moduleApiAllPages("relationships", `/api/works/${workId}/relationships`)
8351
8541
  ]);
8352
8542
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
8353
8543
  state.characters = characters;
8354
8544
  characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
8545
+ characterEditorRelationshipsLoaded = true;
8355
8546
  loaded = true;
8356
8547
  } catch (error) {
8357
8548
  if (state.work?.id === workId && characterEditorItem?.id === characterId) {
@@ -8369,7 +8560,7 @@ async function refreshRelationshipSurfaces(characterId = null) {
8369
8560
  const tasks = [];
8370
8561
  if (state.module === "relationships") tasks.push(renderRelationships());
8371
8562
  if (characterId && entityEditorType === "character" && !$("#entity-editor-view").classList.contains("hidden") && characterEditorItem?.id === characterId) {
8372
- tasks.push(loadCharacterEditorRelationships(characterId));
8563
+ tasks.push(loadCharacterEditorRelationships(characterId, { refresh: true }));
8373
8564
  }
8374
8565
  await Promise.all(tasks);
8375
8566
  }
@@ -9140,15 +9331,15 @@ async function showCharacterHistory() {
9140
9331
 
9141
9332
  async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9142
9333
  entityEditorReadOnly = readOnly;
9143
- [state.races, state.organizations, state.characters] = await Promise.all([
9334
+ [state.races, state.organizations] = await Promise.all([
9144
9335
  canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
9145
- canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([]),
9146
- canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
9336
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
9147
9337
  ]);
9148
9338
  characterEditorItem = item ?? null;
9149
9339
  characterEditorVersions = [];
9150
9340
  characterEditorRelationships = [];
9151
- characterEditorRelationshipsLoading = Boolean(item);
9341
+ characterEditorRelationshipsLoading = false;
9342
+ characterEditorRelationshipsLoaded = false;
9152
9343
  characterEditorSections = [];
9153
9344
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
9154
9345
  $("#character-editor-title").textContent = item?.name || "新建角色";
@@ -9160,20 +9351,30 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9160
9351
  const characterMergeButton = $("#character-merge-button");
9161
9352
  const characterDeleteButton = $("#character-delete-button");
9162
9353
  const canManageCharacter = Boolean(item && !readOnly && canEditModule("characters"));
9163
- characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
9354
+ characterMergeButton.classList.toggle("hidden", !canManageCharacter);
9164
9355
  characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
9165
9356
  characterMergeButton.onclick = async () => {
9166
9357
  if (!item) return;
9167
- await closeEntityEditor({ force: true });
9168
- openEntityMergeDialog({
9169
- typeLabel: "角色",
9170
- source: item,
9171
- candidates: state.characters,
9172
- endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
9173
- body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
9174
- refresh: renderCharacters,
9175
- impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
9176
- });
9358
+ const workId = state.work?.id;
9359
+ if (!workId) return;
9360
+ try {
9361
+ const candidates = await moduleApiAllPages("characters", `/api/works/${workId}/characters`);
9362
+ if (state.work?.id !== workId || characterEditorItem?.id !== item.id) return;
9363
+ state.characters = candidates;
9364
+ if (candidates.length < 2) return toast("至少需要两个角色才能合并", "error");
9365
+ await closeEntityEditor({ force: true });
9366
+ openEntityMergeDialog({
9367
+ typeLabel: "角色",
9368
+ source: item,
9369
+ candidates,
9370
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
9371
+ body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
9372
+ refresh: renderCharacters,
9373
+ impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
9374
+ });
9375
+ } catch (error) {
9376
+ toast(error.message, "error");
9377
+ }
9177
9378
  };
9178
9379
  characterDeleteButton.onclick = () => {
9179
9380
  if (!item) return;
@@ -9231,7 +9432,6 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9231
9432
  };
9232
9433
  showEntityEditorPage("character", { readOnly });
9233
9434
  if (item) {
9234
- if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
9235
9435
  void loadCharacterMarkdownSections(item.id);
9236
9436
  }
9237
9437
  }
@@ -9263,7 +9463,7 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
9263
9463
  + field("description", `${label}简介`, "textarea", item?.description, []);
9264
9464
  const memberField = memberOptions.length
9265
9465
  ? field("memberIds", isRace ? "属于该种族的角色(可多选)" : "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions)
9266
- : `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>当前还没有可绑定的角色。</span></div>`;
9466
+ : `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>${readOnly ? "该档案尚未绑定角色。" : "当前还没有可绑定的角色。"}</span></div>`;
9267
9467
  $("#knowledge-editor-fields").innerHTML = knowledgeEditorSection("basic", "基础资料", isRace ? "先定义名称、层级和简介,再补充共同设定。" : "先定义组织名称和简介,再补充完整的组织设定。", basicFields)
9268
9468
  + knowledgeEditorSection("settings", title, "", '<div id="knowledge-markdown-sections" class="knowledge-markdown-sections"></div>')
9269
9469
  + knowledgeEditorSection("members", isRace ? "种族成员" : "组织成员", "成员关系会同步到角色档案中。", memberField);
@@ -9278,8 +9478,13 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
9278
9478
  entityEditorReadOnly = readOnly;
9279
9479
  await discardPendingMarkdownAttachments();
9280
9480
  if (kind === "race" && !(await ensureCompleteRaceList())) return;
9281
- state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
9282
- const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
9481
+ const memberCharacters = readOnly
9482
+ ? (Array.isArray(item?.members) ? item.members : []).map((member) => ({ id: member.characterId, name: member.name, aliases: [] }))
9483
+ : canReadModule("characters")
9484
+ ? await moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`)
9485
+ : [];
9486
+ if (!readOnly) state.characters = memberCharacters;
9487
+ const memberOptions = memberCharacters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
9283
9488
  const isRace = kind === "race";
9284
9489
  const module = isRace ? "races" : "organizations";
9285
9490
  const label = isRace ? "种族" : "组织";
@@ -9588,7 +9793,7 @@ function openTimelineSplitDialog(item) {
9588
9793
 
9589
9794
  async function openRelationshipDialog(item, options = {}) {
9590
9795
  if (!canReadModule("characters")) return toast("配置人物关系前需要角色模块读取权限", "error");
9591
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
9796
+ state.characters = await moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`);
9592
9797
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
9593
9798
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
9594
9799
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
@@ -10016,18 +10221,23 @@ function openProviderDialog(item) {
10016
10221
  protocolSelect.addEventListener("change", syncProviderCredentialField);
10017
10222
  }
10018
10223
 
10019
- function openModelDialog(providerId, item = null) {
10224
+ function openModelDialog(providerId, item = null, provider = null) {
10020
10225
  const values = modelFormValues(item);
10226
+ const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol);
10227
+ const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>平台默认模型只能由 Chat Completions 协议提供。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
10021
10228
  const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
10022
10229
  const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
10230
+ const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
10231
+ ? "使用当前已保存的模型标识符和供应商凭据,并发送一张测试图片验证图片请求。"
10232
+ : "使用当前已保存的模型标识符和供应商凭据发起最小请求。";
10023
10233
  const connectionTest = item && item.providerStatus === "enabled"
10024
10234
  ? `<section class="model-connection-test">
10025
- <div><strong>模型连接测试</strong><p>使用当前已保存的模型标识符和供应商凭据发起最小请求。</p></div>
10235
+ <div><strong>模型连接测试</strong><p>${connectionTestDescription}</p></div>
10026
10236
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
10027
10237
  </section>`
10028
10238
  : "";
10029
- openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
10030
- const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
10239
+ openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
10240
+ const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
10031
10241
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
10032
10242
  await renderPlatformAiConfig();
10033
10243
  await loadModels();
@@ -10037,6 +10247,16 @@ function openModelDialog(providerId, item = null) {
10037
10247
  const contextWindowHint = $("#model-context-window-hint");
10038
10248
  const temperatureInput = $("#dialog-fields input[name='temperature']");
10039
10249
  const temperatureHint = $("#model-temperature-hint");
10250
+ const multimodalInput = $("#model-multimodal-enabled");
10251
+ const imageDefaultField = $("#model-image-tool-default-field");
10252
+ const imageDefaultInput = $("#model-image-tool-default");
10253
+ const syncMultimodalFields = () => {
10254
+ if (!multimodalInput || !imageDefaultField || !imageDefaultInput) return;
10255
+ const hideImageDefault = !multimodalInput.checked || !imageDefaultSupported;
10256
+ imageDefaultField.hidden = hideImageDefault;
10257
+ imageDefaultField.classList.toggle("hidden", hideImageDefault);
10258
+ if (!multimodalInput.checked) imageDefaultInput.checked = false;
10259
+ };
10040
10260
  const syncModelContextWindowGuidance = () => {
10041
10261
  const guidance = modelContextWindowGuidance(contextWindowInput.value);
10042
10262
  contextWindowInput.setCustomValidity(guidance.belowMinimum ? "模型上下文不能低于 32K(32768 Token)。" : "");
@@ -10051,13 +10271,14 @@ function openModelDialog(providerId, item = null) {
10051
10271
  syncKimiTemperature();
10052
10272
  });
10053
10273
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
10274
+ multimodalInput?.addEventListener("change", syncMultimodalFields);
10054
10275
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
10055
10276
  const button = event.currentTarget;
10056
10277
  button.disabled = true;
10057
10278
  button.textContent = "测试中";
10058
10279
  try {
10059
10280
  const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
10060
- toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
10281
+ toast(result.ok ? (result.multimodalTested ? "模型连接测试成功,图片请求已验证" : "模型连接测试成功") : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
10061
10282
  await renderPlatformAiConfig();
10062
10283
  await loadModels();
10063
10284
  } catch (error) {
@@ -10069,6 +10290,7 @@ function openModelDialog(providerId, item = null) {
10069
10290
  });
10070
10291
  syncModelContextWindowGuidance();
10071
10292
  syncKimiTemperature();
10293
+ syncMultimodalFields();
10072
10294
  }
10073
10295
 
10074
10296
  async function sendAi() {
@@ -10773,17 +10995,7 @@ function hasUnsavedEditorChanges() {
10773
10995
  }
10774
10996
 
10775
10997
  function redirectToLoginAfterSystemRestart() {
10776
- state.user = null;
10777
- state.csrfToken = null;
10778
- moduleRequestCache.clear();
10779
- document.documentElement.classList.remove("dev-auth-bypass");
10780
- document.documentElement.classList.add("login-route");
10781
- window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
10782
- const toastRegion = $("#toast-region");
10783
- toastRegion.replaceChildren();
10784
- if (typeof toastRegion.hidePopover === "function" && toastRegion.matches(":popover-open")) toastRegion.hidePopover();
10785
- $("#system-restart-dialog").close();
10786
- showAuth(false);
10998
+ invalidateAuthentication();
10787
10999
  }
10788
11000
 
10789
11001
  $("#system-restart-confirm").addEventListener("click", redirectToLoginAfterSystemRestart);
@@ -11967,13 +12179,14 @@ $("#background-task-button").addEventListener("click", () => {
11967
12179
  const dialog = $("#background-task-dialog");
11968
12180
  if (!dialog.open) dialog.showModal();
11969
12181
  void refreshBackgroundTaskCenter();
12182
+ void refreshProductUpdate();
11970
12183
  });
11971
12184
  $("#background-task-dialog").addEventListener("close", scheduleBackgroundTaskCenterRefresh);
11972
12185
  $("#background-task-refresh").addEventListener("click", async () => {
11973
12186
  const button = $("#background-task-refresh");
11974
12187
  button.disabled = true;
11975
12188
  try {
11976
- await refreshBackgroundTaskCenter();
12189
+ await Promise.all([refreshBackgroundTaskCenter(), refreshProductUpdate()]);
11977
12190
  } finally {
11978
12191
  button.disabled = false;
11979
12192
  }