@musnows/scriverse 0.6.7 → 0.6.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.
@@ -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";
@@ -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;
@@ -2507,10 +2516,7 @@ async function api(path, options = {}) {
2507
2516
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2508
2517
  const restarted = await checkSystemBoot(true);
2509
2518
  if (!restarted) {
2510
- state.user = null;
2511
- state.csrfToken = null;
2512
- moduleRequestCache.clear();
2513
- showAuth(false);
2519
+ invalidateAuthentication();
2514
2520
  }
2515
2521
  }
2516
2522
  throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
@@ -2650,6 +2656,35 @@ function applyProductHealthMetadata(health) {
2650
2656
  });
2651
2657
  }
2652
2658
 
2659
+ function applyProductUpdateMetadata(update) {
2660
+ if (update && typeof update === "object") productUpdateStatus = update;
2661
+ if (update?.checked !== true) {
2662
+ renderBackgroundTaskCenter();
2663
+ return;
2664
+ }
2665
+ const latestVersion = String(update.latestVersion ?? "").trim();
2666
+ const releaseUrl = String(update.releaseUrl ?? "").trim();
2667
+ const updateAvailable = update.updateAvailable === true && Boolean(latestVersion) && Boolean(releaseUrl);
2668
+ const updateDot = document.querySelector("[data-settings-update-dot]");
2669
+ updateDot?.classList.toggle("hidden", !updateAvailable);
2670
+ const settingsButton = $("#settings-button");
2671
+ settingsButton.setAttribute("aria-label", updateAvailable ? `设置,有新版本 v${latestVersion}` : "设置");
2672
+ settingsButton.title = updateAvailable ? `设置,有新版本 v${latestVersion}` : "设置";
2673
+ document.querySelectorAll("[data-product-footer-update]").forEach((element) => {
2674
+ element.classList.toggle("hidden", !updateAvailable);
2675
+ if (updateAvailable) {
2676
+ element.textContent = `发现新版本 v${latestVersion},查看更新说明`;
2677
+ element.href = releaseUrl;
2678
+ element.setAttribute("aria-label", `查看叙界 v${latestVersion} 更新说明`);
2679
+ } else {
2680
+ element.textContent = "";
2681
+ element.removeAttribute("href");
2682
+ element.removeAttribute("aria-label");
2683
+ }
2684
+ });
2685
+ renderBackgroundTaskCenter();
2686
+ }
2687
+
2653
2688
  function scheduleSystemHealthCheck() {
2654
2689
  if (systemHealthTimer !== null) window.clearTimeout(systemHealthTimer);
2655
2690
  systemHealthTimer = window.setTimeout(() => {
@@ -2669,11 +2704,44 @@ async function refreshSystemHealth() {
2669
2704
  }
2670
2705
  }
2671
2706
 
2707
+ function scheduleProductUpdateCheck() {
2708
+ if (productUpdateTimer !== null) window.clearTimeout(productUpdateTimer);
2709
+ if (productUpdateStatus?.enabled === false) {
2710
+ productUpdateTimer = null;
2711
+ return;
2712
+ }
2713
+ const nextCheckAt = Date.parse(String(productUpdateStatus?.nextCheckAt ?? ""));
2714
+ const delay = Number.isFinite(nextCheckAt)
2715
+ ? Math.max(1000, nextCheckAt - Date.now())
2716
+ : fallbackProductUpdatePollInterval;
2717
+ productUpdateTimer = window.setTimeout(() => {
2718
+ productUpdateTimer = null;
2719
+ void refreshProductUpdate();
2720
+ }, Math.min(delay, 2_147_483_647));
2721
+ }
2722
+
2723
+ async function refreshProductUpdate() {
2724
+ if (productUpdateChecking || productUpdateStatus?.enabled === false) return;
2725
+ productUpdateChecking = true;
2726
+ renderBackgroundTaskCenter();
2727
+ try {
2728
+ applyProductUpdateMetadata(await api("/api/update-check"));
2729
+ } catch {
2730
+ // 更新探测失败时静默保留当前展示状态
2731
+ } finally {
2732
+ productUpdateChecking = false;
2733
+ renderBackgroundTaskCenter();
2734
+ scheduleProductUpdateCheck();
2735
+ }
2736
+ }
2737
+
2672
2738
  async function initializeProductFooters() {
2673
2739
  const year = String(new Date().getFullYear());
2674
2740
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
2675
2741
  applyProductHealthMetadata(null);
2742
+ applyProductUpdateMetadata(null);
2676
2743
  await refreshSystemHealth();
2744
+ void refreshProductUpdate();
2677
2745
  }
2678
2746
 
2679
2747
  function observeSystemBootId(value) {
@@ -2768,8 +2836,28 @@ async function refreshAuthCaptcha(target = "login") {
2768
2836
  if (answerInput) answerInput.value = "";
2769
2837
  }
2770
2838
 
2839
+ function clearAuthenticationOverlays() {
2840
+ const toastRegion = $("#toast-region");
2841
+ toastRegion.replaceChildren();
2842
+ document.querySelectorAll("[popover]").forEach((popover) => {
2843
+ if (typeof popover.hidePopover === "function" && popover.matches(":popover-open")) popover.hidePopover();
2844
+ });
2845
+ document.querySelectorAll("dialog[open]").forEach((dialog) => dialog.close());
2846
+ }
2847
+
2848
+ function invalidateAuthentication() {
2849
+ state.user = null;
2850
+ state.csrfToken = null;
2851
+ moduleRequestCache.clear();
2852
+ showAuth(false);
2853
+ }
2854
+
2771
2855
  function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
2772
2856
  if (state.user) return;
2857
+ document.documentElement.classList.remove("dev-auth-bypass");
2858
+ document.documentElement.classList.add("login-route");
2859
+ window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
2860
+ clearAuthenticationOverlays();
2773
2861
  document.body.classList.add("auth-pending");
2774
2862
  $("#auth-view").classList.remove("hidden");
2775
2863
  const canRegister = registrationOpen === true;
@@ -2897,6 +2985,7 @@ function dismissChapterInsightToast() {
2897
2985
  }
2898
2986
 
2899
2987
  function toast(message, type = "info") {
2988
+ if (systemRestartDetected || (!state.user && document.documentElement.classList.contains("login-route"))) return;
2900
2989
  const region = $("#toast-region");
2901
2990
  const element = document.createElement("div");
2902
2991
  element.className = `toast ${type}`;
@@ -5067,15 +5156,14 @@ function bindModuleContentInteractions() {
5067
5156
  moduleContentInteractionsBound = true;
5068
5157
  const host = $("#module-content");
5069
5158
  const open = async (card) => {
5070
- const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization;
5159
+ const id = card.dataset.openSetting ?? card.dataset.openRace ?? card.dataset.openOrganization;
5071
5160
  if (!id) return;
5072
5161
  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
5162
  if (card.dataset.openRace) return openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly: true });
5075
5163
  if (card.dataset.openOrganization) return openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly: true });
5076
5164
  };
5077
5165
  const findCard = (target) => target instanceof Element
5078
- ? target.closest("[data-open-setting], [data-open-character], [data-open-race], [data-open-organization]")
5166
+ ? target.closest("[data-open-setting], [data-open-race], [data-open-organization]")
5079
5167
  : null;
5080
5168
  host.addEventListener("click", (event) => {
5081
5169
  if (event.target instanceof Element && event.target.closest("button, a, summary")) return;
@@ -5608,10 +5696,7 @@ async function renderRaces() {
5608
5696
  }
5609
5697
 
5610
5698
  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
- ]);
5699
+ state.organizations = await moduleApiAllPages("organizations", `/api/works/${state.work.id}/organizations`);
5615
5700
  mountModuleCount(state.organizations.length);
5616
5701
  const pageResult = paginateModuleItems(state.organizations, page, "organizations");
5617
5702
  moduleListPages.organizations = pageResult.page;
@@ -5916,10 +6001,11 @@ async function renderReviews(page = moduleListPages.reviews) {
5916
6001
  const canResolveReview = canEditModule("reviews");
5917
6002
  const canMergeCharacters = canResolveReview
5918
6003
  && ["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
- ]);
6004
+ const reviews = await moduleApiAllPages("reviews", `/api/works/${state.work.id}/reviews`);
6005
+ const hasCharacterDuplicateReviews = reviews.some((item) => item.itemType === "character-duplicate");
6006
+ const characters = canReadCharacters && hasCharacterDuplicateReviews
6007
+ ? await moduleApiAllPages("reviews", `/api/works/${state.work.id}/characters?includeMerged=1`)
6008
+ : [];
5923
6009
  mountModuleCount(reviews.length);
5924
6010
  const pageResult = paginateModuleItems(reviews, page, "reviews");
5925
6011
  moduleListPages.reviews = pageResult.page;
@@ -6760,7 +6846,9 @@ function renderProviderCards(providers, models) {
6760
6846
  : provider.connectionStatus !== "success"
6761
6847
  ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
6762
6848
  : "";
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>`;
6849
+ const capability = model.multimodalEnabled ? " · 多模态" : "";
6850
+ const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
6851
+ 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
6852
  }).join("")}</div>
6765
6853
  <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
6854
  }).join("")}</div>`
@@ -6776,8 +6864,11 @@ function bindPlatformProviderActions(host, providers, models) {
6776
6864
  await renderPlatformAiConfig();
6777
6865
  await loadModels();
6778
6866
  }));
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))));
6867
+ host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel, null, providers.find((provider) => provider.id === button.dataset.addModel))));
6868
+ host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => {
6869
+ const model = models.find((item) => item.id === button.dataset.editModel);
6870
+ openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId));
6871
+ }));
6781
6872
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
6782
6873
  }
6783
6874
 
@@ -6785,14 +6876,21 @@ function renderTaskDefaults(models, providers, taskDefaults, settings) {
6785
6876
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
6786
6877
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
6787
6878
  const availableModels = models.filter((model) => isSelectableModel(model));
6879
+ const imageModels = availableModels.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol === "openai-chat-completions");
6788
6880
  const availableModelIds = new Set(availableModels.map((model) => model.id));
6789
6881
  const currentDefaultModels = taskDefaults
6790
6882
  .map((item) => item.model)
6791
6883
  .filter((model) => model && !availableModelIds.has(model.id));
6792
6884
  const optionModels = [...availableModels, ...currentDefaultModels];
6793
- return optionModels.length ? `<section class="config-section">
6885
+ return optionModels.length || imageModels.length ? `<section class="config-section">
6794
6886
  <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="创作助手对话标题生成">
6887
+ <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="多模态读图工具默认模型">
6888
+ <option value="" ${settings.imageToolModelId ? "" : "selected"}>跟随平台默认</option>
6889
+ ${imageModels.map((model) => {
6890
+ const provider = providerById.get(model.providerId);
6891
+ return `<option value="${esc(model.id)}" ${model.id === settings.imageToolModelId ? "selected" : ""}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
6892
+ }).join("")}
6893
+ </select></td></tr><tr><td>创作助手对话标题生成</td><td><select class="default-model-select" data-title-generation-default aria-label="创作助手对话标题生成">
6796
6894
  <option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
6797
6895
  ${models.map((model) => {
6798
6896
  const provider = providerById.get(model.providerId);
@@ -6857,8 +6955,7 @@ function relationshipIndexStatusMarkup(status) {
6857
6955
  function updateBackgroundTaskCenterVisibility() {
6858
6956
  const button = $("#background-task-button");
6859
6957
  if (!button) return;
6860
- const visible = Boolean(state.work)
6861
- && (canReadModule("tasks") || canReadModule("ai-settings"));
6958
+ const visible = Boolean(state.user);
6862
6959
  button.classList.toggle("hidden", !visible);
6863
6960
  if (!visible) {
6864
6961
  $("#background-task-count")?.classList.add("hidden");
@@ -6867,7 +6964,7 @@ function updateBackgroundTaskCenterVisibility() {
6867
6964
  const activityCount = backgroundTaskActivityCount(
6868
6965
  backgroundTaskCenterSnapshot.taskPage,
6869
6966
  backgroundTaskCenterSnapshot.relationshipIndex
6870
- );
6967
+ ) + (productUpdateChecking ? 1 : 0);
6871
6968
  const badge = $("#background-task-count");
6872
6969
  badge.textContent = activityCount > 99 ? "99+" : String(activityCount);
6873
6970
  badge.classList.toggle("hidden", activityCount === 0);
@@ -6876,6 +6973,37 @@ function updateBackgroundTaskCenterVisibility() {
6876
6973
  button.setAttribute("title", activityCount > 0 ? `后台任务中心 · ${activityCount} 项进行中` : "后台任务中心");
6877
6974
  }
6878
6975
 
6976
+ function backgroundProductUpdateMarkup() {
6977
+ const status = productUpdateStatus;
6978
+ const updateCheckDisabled = status?.enabled === false;
6979
+ const currentVersion = String(status?.currentVersion ?? "").trim();
6980
+ const latestVersion = String(status?.latestVersion ?? "").trim();
6981
+ const nextCheckAt = formatDateTime(status?.nextCheckAt) || "等待首次探测";
6982
+ const updateAvailable = status?.updateAvailable === true && Boolean(status?.releaseUrl);
6983
+ const badgeClass = productUpdateChecking ? "running" : updateCheckDisabled ? "unknown" : updateAvailable ? "partial" : status?.checked === true ? "completed" : status ? "unknown" : "pending";
6984
+ const badgeLabel = productUpdateChecking ? "探测中" : updateCheckDisabled ? "已关闭" : updateAvailable ? "有新版本" : status?.checked === true ? "已是最新" : status ? "本次未探测" : "等待探测";
6985
+ const detail = productUpdateChecking
6986
+ ? "正在请求 GitHub 最新 Release"
6987
+ : updateCheckDisabled
6988
+ ? "已通过服务端配置关闭版本更新探测"
6989
+ : updateAvailable
6990
+ ? `当前 v${currentVersion || "—"} · 最新 v${latestVersion} · 下次探测 ${nextCheckAt}`
6991
+ : status?.checked === true
6992
+ ? `当前 v${currentVersion || "—"} · 下次探测 ${nextCheckAt}`
6993
+ : status
6994
+ ? `GitHub 暂不可用 · 下次探测 ${nextCheckAt}`
6995
+ : "应用启动后将在后台探测 GitHub Release";
6996
+ return `<section class="background-task-section">
6997
+ <div class="background-task-section-heading"><div><strong>版本更新探测</strong><small>按服务端配置定期检查 GitHub Release</small></div></div>
6998
+ <div class="background-task-list"><article class="background-task-row">
6999
+ <span class="task-status-badge is-${badgeClass}"><span class="task-status-indicator" aria-hidden="true"></span><span>${badgeLabel}</span></span>
7000
+ <div><strong>叙界版本更新</strong><small>${esc(detail)}</small></div>
7001
+ <span class="background-task-progress">${updateAvailable ? `v${esc(latestVersion)}` : "系统"}</span>
7002
+ ${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>'}
7003
+ </article></div>
7004
+ </section>`;
7005
+ }
7006
+
6879
7007
  function backgroundTaskTransitionMessage(transition) {
6880
7008
  const label = analysisTaskTypeLabel(transition.task.taskType);
6881
7009
  if (transition.status === "partial") return { message: `${label}部分失败,请打开任务详情查看`, type: "error" };
@@ -6934,17 +7062,17 @@ function renderBackgroundTaskCenter() {
6934
7062
  updateBackgroundTaskCenterVisibility();
6935
7063
  const content = $("#background-task-content");
6936
7064
  if (!content) return;
6937
- content.innerHTML = `${backgroundTaskListMarkup(
7065
+ content.innerHTML = `${backgroundProductUpdateMarkup()}${state.work ? `${backgroundTaskListMarkup(
6938
7066
  backgroundTaskCenterSnapshot.taskPage,
6939
7067
  backgroundTaskCenterSnapshot.errors.tasks
6940
7068
  )}${backgroundIndexMarkup(
6941
7069
  backgroundTaskCenterSnapshot.relationshipIndex,
6942
7070
  backgroundTaskCenterSnapshot.errors.index
6943
- )}`;
7071
+ )}` : ""}`;
6944
7072
  $("#background-task-dialog-meta").textContent = state.work
6945
- ? `《${state.work.title}》的分析任务与索引队列`
6946
- : "当前作品的分析任务与索引队列";
6947
- $("#background-task-open-analysis").classList.toggle("hidden", !canReadModule("tasks"));
7073
+ ? `系统更新与《${state.work.title}》的分析任务、索引队列`
7074
+ : "系统更新探测状态";
7075
+ $("#background-task-open-analysis").classList.toggle("hidden", !state.work || !canReadModule("tasks"));
6948
7076
  content.querySelectorAll("[data-background-task-detail]").forEach((button) => button.addEventListener("click", async () => {
6949
7077
  button.disabled = true;
6950
7078
  try {
@@ -7063,8 +7191,7 @@ function stopBackgroundTaskCenter() {
7063
7191
  backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
7064
7192
  const dialog = $("#background-task-dialog");
7065
7193
  if (dialog?.open) dialog.close();
7066
- $("#background-task-button")?.classList.add("hidden");
7067
- $("#background-task-count")?.classList.add("hidden");
7194
+ updateBackgroundTaskCenterVisibility();
7068
7195
  }
7069
7196
 
7070
7197
  function startBackgroundTaskCenter(workId) {
@@ -7081,7 +7208,14 @@ async function renderPlatformAiConfig() {
7081
7208
  api("/api/platform/ai/settings")
7082
7209
  ]);
7083
7210
  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)}`;
7211
+ const providerById = new Map(providers.map((provider) => [provider.id, provider]));
7212
+ const imageModels = models.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol === "openai-chat-completions");
7213
+ const imageModelOptions = imageModels.map((model) => {
7214
+ const provider = providerById.get(model.providerId);
7215
+ const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
7216
+ 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>`;
7217
+ }).join("");
7218
+ 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
7219
  $("#save-platform-system-prompt").addEventListener("click", async () => {
7086
7220
  const button = $("#save-platform-system-prompt");
7087
7221
  button.disabled = true;
@@ -7094,6 +7228,19 @@ async function renderPlatformAiConfig() {
7094
7228
  button.disabled = false;
7095
7229
  }
7096
7230
  });
7231
+ $("#save-platform-image-tool-model").addEventListener("click", async () => {
7232
+ const button = $("#save-platform-image-tool-model");
7233
+ button.disabled = true;
7234
+ try {
7235
+ await api("/api/platform/ai/settings", { method: "PATCH", body: { imageToolModelId: $("#platform-image-tool-model").value || null } });
7236
+ toast("平台多模态读图默认模型已更新");
7237
+ await renderPlatformAiConfig();
7238
+ } catch (error) {
7239
+ toast(error.message, "error");
7240
+ } finally {
7241
+ button.disabled = false;
7242
+ }
7243
+ });
7097
7244
  bindPlatformProviderActions(host, providers, models);
7098
7245
  }
7099
7246
 
@@ -7253,7 +7400,7 @@ async function renderBookAiSettings() {
7253
7400
  ]);
7254
7401
  const host = $("#module-content");
7255
7402
  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"]);
7403
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"]);
7257
7404
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
7258
7405
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
7259
7406
  const quotaRemainingTokens = usage?.quota?.remainingTokens === null
@@ -7280,7 +7427,7 @@ async function renderBookAiSettings() {
7280
7427
  );
7281
7428
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
7282
7429
  "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>`
7430
+ `<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
7431
  );
7285
7432
  if (!canEditModule("ai-settings")) {
7286
7433
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -7485,6 +7632,18 @@ async function renderBookAiSettings() {
7485
7632
  await renderBookAiSettings();
7486
7633
  await loadModels();
7487
7634
  });
7635
+ host.querySelector("[data-image-tool-default]")?.addEventListener("change", async (event) => {
7636
+ const select = event.currentTarget;
7637
+ select.disabled = true;
7638
+ try {
7639
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { imageToolModelId: select.value || null } });
7640
+ toast("本书多模态读图模型已更新");
7641
+ } catch (error) {
7642
+ toast(error.message, "error");
7643
+ }
7644
+ await renderBookAiSettings();
7645
+ await loadModels();
7646
+ });
7488
7647
  host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
7489
7648
  select.disabled = true;
7490
7649
  try {
@@ -7692,7 +7851,7 @@ async function ensureAiReferencesLoaded() {
7692
7851
 
7693
7852
  function field(name, label, type = "text", value = "", options = []) {
7694
7853
  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>`;
7854
+ 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
7855
  if (type === "item-list") {
7697
7856
  const values = Array.isArray(value) && value.length ? value : [""];
7698
7857
  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 +8454,15 @@ function activateCharacterEditorTab(key) {
8295
8454
  button.tabIndex = active ? 0 : -1;
8296
8455
  });
8297
8456
  document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
8457
+ if (
8458
+ key === "relationships"
8459
+ && characterEditorItem?.id
8460
+ && canReadModule("relationships")
8461
+ && !characterEditorRelationshipsLoaded
8462
+ && !characterEditorRelationshipsLoading
8463
+ ) {
8464
+ void loadCharacterEditorRelationships(characterEditorItem.id);
8465
+ }
8298
8466
  }
8299
8467
 
8300
8468
  function setCharacterHistoryVisible(visible) {
@@ -8316,6 +8484,10 @@ function renderCharacterEditorRelationships() {
8316
8484
  host.innerHTML = '<p class="character-relationship-status">正在读取人物关系……</p>';
8317
8485
  return;
8318
8486
  }
8487
+ if (!characterEditorRelationshipsLoaded) {
8488
+ host.innerHTML = '<p class="character-relationship-status">打开人物关系分区后载入关系。</p>';
8489
+ return;
8490
+ }
8319
8491
  const characterId = String(characterEditorItem.id);
8320
8492
  const nameOf = (id) => state.characters.find((character) => character.id === id)?.name ?? "未知角色";
8321
8493
  const rows = characterEditorRelationships.map((relationship) => {
@@ -8338,20 +8510,23 @@ function renderCharacterEditorRelationships() {
8338
8510
  host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
8339
8511
  }
8340
8512
 
8341
- async function loadCharacterEditorRelationships(characterId) {
8513
+ async function loadCharacterEditorRelationships(characterId, { refresh = false } = {}) {
8342
8514
  const workId = state.work?.id;
8343
8515
  if (!workId || characterEditorItem?.id !== characterId) return;
8516
+ if (!refresh && (characterEditorRelationshipsLoaded || characterEditorRelationshipsLoading)) return;
8344
8517
  characterEditorRelationshipsLoading = true;
8518
+ characterEditorRelationshipsLoaded = false;
8345
8519
  renderCharacterEditorRelationships();
8346
8520
  let loaded = false;
8347
8521
  try {
8348
8522
  const [characters, relationships] = await Promise.all([
8349
- apiAllPages(`/api/works/${workId}/characters`),
8350
- apiAllPages(`/api/works/${workId}/relationships`)
8523
+ moduleApiAllPages("characters", `/api/works/${workId}/characters`),
8524
+ moduleApiAllPages("relationships", `/api/works/${workId}/relationships`)
8351
8525
  ]);
8352
8526
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
8353
8527
  state.characters = characters;
8354
8528
  characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
8529
+ characterEditorRelationshipsLoaded = true;
8355
8530
  loaded = true;
8356
8531
  } catch (error) {
8357
8532
  if (state.work?.id === workId && characterEditorItem?.id === characterId) {
@@ -8369,7 +8544,7 @@ async function refreshRelationshipSurfaces(characterId = null) {
8369
8544
  const tasks = [];
8370
8545
  if (state.module === "relationships") tasks.push(renderRelationships());
8371
8546
  if (characterId && entityEditorType === "character" && !$("#entity-editor-view").classList.contains("hidden") && characterEditorItem?.id === characterId) {
8372
- tasks.push(loadCharacterEditorRelationships(characterId));
8547
+ tasks.push(loadCharacterEditorRelationships(characterId, { refresh: true }));
8373
8548
  }
8374
8549
  await Promise.all(tasks);
8375
8550
  }
@@ -9140,15 +9315,15 @@ async function showCharacterHistory() {
9140
9315
 
9141
9316
  async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9142
9317
  entityEditorReadOnly = readOnly;
9143
- [state.races, state.organizations, state.characters] = await Promise.all([
9318
+ [state.races, state.organizations] = await Promise.all([
9144
9319
  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([])
9320
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
9147
9321
  ]);
9148
9322
  characterEditorItem = item ?? null;
9149
9323
  characterEditorVersions = [];
9150
9324
  characterEditorRelationships = [];
9151
- characterEditorRelationshipsLoading = Boolean(item);
9325
+ characterEditorRelationshipsLoading = false;
9326
+ characterEditorRelationshipsLoaded = false;
9152
9327
  characterEditorSections = [];
9153
9328
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
9154
9329
  $("#character-editor-title").textContent = item?.name || "新建角色";
@@ -9160,20 +9335,30 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9160
9335
  const characterMergeButton = $("#character-merge-button");
9161
9336
  const characterDeleteButton = $("#character-delete-button");
9162
9337
  const canManageCharacter = Boolean(item && !readOnly && canEditModule("characters"));
9163
- characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
9338
+ characterMergeButton.classList.toggle("hidden", !canManageCharacter);
9164
9339
  characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
9165
9340
  characterMergeButton.onclick = async () => {
9166
9341
  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
- });
9342
+ const workId = state.work?.id;
9343
+ if (!workId) return;
9344
+ try {
9345
+ const candidates = await moduleApiAllPages("characters", `/api/works/${workId}/characters`);
9346
+ if (state.work?.id !== workId || characterEditorItem?.id !== item.id) return;
9347
+ state.characters = candidates;
9348
+ if (candidates.length < 2) return toast("至少需要两个角色才能合并", "error");
9349
+ await closeEntityEditor({ force: true });
9350
+ openEntityMergeDialog({
9351
+ typeLabel: "角色",
9352
+ source: item,
9353
+ candidates,
9354
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
9355
+ body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
9356
+ refresh: renderCharacters,
9357
+ impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
9358
+ });
9359
+ } catch (error) {
9360
+ toast(error.message, "error");
9361
+ }
9177
9362
  };
9178
9363
  characterDeleteButton.onclick = () => {
9179
9364
  if (!item) return;
@@ -9231,7 +9416,6 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9231
9416
  };
9232
9417
  showEntityEditorPage("character", { readOnly });
9233
9418
  if (item) {
9234
- if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
9235
9419
  void loadCharacterMarkdownSections(item.id);
9236
9420
  }
9237
9421
  }
@@ -9263,7 +9447,7 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
9263
9447
  + field("description", `${label}简介`, "textarea", item?.description, []);
9264
9448
  const memberField = memberOptions.length
9265
9449
  ? field("memberIds", isRace ? "属于该种族的角色(可多选)" : "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions)
9266
- : `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>当前还没有可绑定的角色。</span></div>`;
9450
+ : `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>${readOnly ? "该档案尚未绑定角色。" : "当前还没有可绑定的角色。"}</span></div>`;
9267
9451
  $("#knowledge-editor-fields").innerHTML = knowledgeEditorSection("basic", "基础资料", isRace ? "先定义名称、层级和简介,再补充共同设定。" : "先定义组织名称和简介,再补充完整的组织设定。", basicFields)
9268
9452
  + knowledgeEditorSection("settings", title, "", '<div id="knowledge-markdown-sections" class="knowledge-markdown-sections"></div>')
9269
9453
  + knowledgeEditorSection("members", isRace ? "种族成员" : "组织成员", "成员关系会同步到角色档案中。", memberField);
@@ -9278,8 +9462,13 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
9278
9462
  entityEditorReadOnly = readOnly;
9279
9463
  await discardPendingMarkdownAttachments();
9280
9464
  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("、")})` : ""}`]);
9465
+ const memberCharacters = readOnly
9466
+ ? (Array.isArray(item?.members) ? item.members : []).map((member) => ({ id: member.characterId, name: member.name, aliases: [] }))
9467
+ : canReadModule("characters")
9468
+ ? await moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`)
9469
+ : [];
9470
+ if (!readOnly) state.characters = memberCharacters;
9471
+ const memberOptions = memberCharacters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
9283
9472
  const isRace = kind === "race";
9284
9473
  const module = isRace ? "races" : "organizations";
9285
9474
  const label = isRace ? "种族" : "组织";
@@ -9588,7 +9777,7 @@ function openTimelineSplitDialog(item) {
9588
9777
 
9589
9778
  async function openRelationshipDialog(item, options = {}) {
9590
9779
  if (!canReadModule("characters")) return toast("配置人物关系前需要角色模块读取权限", "error");
9591
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
9780
+ state.characters = await moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`);
9592
9781
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
9593
9782
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
9594
9783
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
@@ -10016,18 +10205,23 @@ function openProviderDialog(item) {
10016
10205
  protocolSelect.addEventListener("change", syncProviderCredentialField);
10017
10206
  }
10018
10207
 
10019
- function openModelDialog(providerId, item = null) {
10208
+ function openModelDialog(providerId, item = null, provider = null) {
10020
10209
  const values = modelFormValues(item);
10210
+ const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol);
10211
+ 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
10212
  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
10213
  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>`;
10214
+ const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
10215
+ ? "使用当前已保存的模型标识符和供应商凭据,并发送一张测试图片验证图片请求。"
10216
+ : "使用当前已保存的模型标识符和供应商凭据发起最小请求。";
10023
10217
  const connectionTest = item && item.providerStatus === "enabled"
10024
10218
  ? `<section class="model-connection-test">
10025
- <div><strong>模型连接测试</strong><p>使用当前已保存的模型标识符和供应商凭据发起最小请求。</p></div>
10219
+ <div><strong>模型连接测试</strong><p>${connectionTestDescription}</p></div>
10026
10220
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
10027
10221
  </section>`
10028
10222
  : "";
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);
10223
+ 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) => {
10224
+ 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
10225
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
10032
10226
  await renderPlatformAiConfig();
10033
10227
  await loadModels();
@@ -10037,6 +10231,16 @@ function openModelDialog(providerId, item = null) {
10037
10231
  const contextWindowHint = $("#model-context-window-hint");
10038
10232
  const temperatureInput = $("#dialog-fields input[name='temperature']");
10039
10233
  const temperatureHint = $("#model-temperature-hint");
10234
+ const multimodalInput = $("#model-multimodal-enabled");
10235
+ const imageDefaultField = $("#model-image-tool-default-field");
10236
+ const imageDefaultInput = $("#model-image-tool-default");
10237
+ const syncMultimodalFields = () => {
10238
+ if (!multimodalInput || !imageDefaultField || !imageDefaultInput) return;
10239
+ const hideImageDefault = !multimodalInput.checked || !imageDefaultSupported;
10240
+ imageDefaultField.hidden = hideImageDefault;
10241
+ imageDefaultField.classList.toggle("hidden", hideImageDefault);
10242
+ if (!multimodalInput.checked) imageDefaultInput.checked = false;
10243
+ };
10040
10244
  const syncModelContextWindowGuidance = () => {
10041
10245
  const guidance = modelContextWindowGuidance(contextWindowInput.value);
10042
10246
  contextWindowInput.setCustomValidity(guidance.belowMinimum ? "模型上下文不能低于 32K(32768 Token)。" : "");
@@ -10051,13 +10255,14 @@ function openModelDialog(providerId, item = null) {
10051
10255
  syncKimiTemperature();
10052
10256
  });
10053
10257
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
10258
+ multimodalInput?.addEventListener("change", syncMultimodalFields);
10054
10259
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
10055
10260
  const button = event.currentTarget;
10056
10261
  button.disabled = true;
10057
10262
  button.textContent = "测试中";
10058
10263
  try {
10059
10264
  const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
10060
- toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
10265
+ toast(result.ok ? (result.multimodalTested ? "模型连接测试成功,图片请求已验证" : "模型连接测试成功") : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
10061
10266
  await renderPlatformAiConfig();
10062
10267
  await loadModels();
10063
10268
  } catch (error) {
@@ -10069,6 +10274,7 @@ function openModelDialog(providerId, item = null) {
10069
10274
  });
10070
10275
  syncModelContextWindowGuidance();
10071
10276
  syncKimiTemperature();
10277
+ syncMultimodalFields();
10072
10278
  }
10073
10279
 
10074
10280
  async function sendAi() {
@@ -10773,17 +10979,7 @@ function hasUnsavedEditorChanges() {
10773
10979
  }
10774
10980
 
10775
10981
  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);
10982
+ invalidateAuthentication();
10787
10983
  }
10788
10984
 
10789
10985
  $("#system-restart-confirm").addEventListener("click", redirectToLoginAfterSystemRestart);
@@ -11967,13 +12163,14 @@ $("#background-task-button").addEventListener("click", () => {
11967
12163
  const dialog = $("#background-task-dialog");
11968
12164
  if (!dialog.open) dialog.showModal();
11969
12165
  void refreshBackgroundTaskCenter();
12166
+ void refreshProductUpdate();
11970
12167
  });
11971
12168
  $("#background-task-dialog").addEventListener("close", scheduleBackgroundTaskCenterRefresh);
11972
12169
  $("#background-task-refresh").addEventListener("click", async () => {
11973
12170
  const button = $("#background-task-refresh");
11974
12171
  button.disabled = true;
11975
12172
  try {
11976
- await refreshBackgroundTaskCenter();
12173
+ await Promise.all([refreshBackgroundTaskCenter(), refreshProductUpdate()]);
11977
12174
  } finally {
11978
12175
  button.disabled = false;
11979
12176
  }