@musnows/scriverse 0.5.11 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,10 +8,10 @@ import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260
8
8
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
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
- import { createStreamTypewriter } from "/stream-typewriter.js?v=20260729-ai-stream-typewriter-v1";
11
+ import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
12
12
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
13
13
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
14
- import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
14
+ import { formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260730-token-distribution-v2";
15
15
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
16
16
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
17
17
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
@@ -48,6 +48,7 @@ import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-s
48
48
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
49
49
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
50
50
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
51
+ import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
51
52
  import {
52
53
  clampCropRect,
53
54
  containImageRect,
@@ -85,6 +86,12 @@ function normalizePageSizes(value) {
85
86
  ]));
86
87
  }
87
88
 
89
+ function isSelectableModel(model) {
90
+ return Boolean(model?.enabled)
91
+ && model?.providerStatus === "enabled"
92
+ && model?.providerConnectionStatus === "success";
93
+ }
94
+
88
95
  const state = {
89
96
  user: null,
90
97
  csrfToken: null,
@@ -117,6 +124,21 @@ const state = {
117
124
  contextChapterId: null
118
125
  };
119
126
 
127
+ const moduleRequestCache = createModuleRequestCache();
128
+ const cachedWorkModules = new Set([
129
+ "drafts",
130
+ "settings",
131
+ "characters",
132
+ "races",
133
+ "organizations",
134
+ "timeline",
135
+ "outlines",
136
+ "relationships",
137
+ "reviews",
138
+ "tasks",
139
+ "ai-settings"
140
+ ]);
141
+
120
142
  function createPresenceClientId() {
121
143
  if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
122
144
  const bytes = crypto.getRandomValues(new Uint8Array(16));
@@ -371,6 +393,9 @@ let aiReferencesLoadWorkId = null;
371
393
  let aiConversationsLoadPromise = null;
372
394
  let aiConversationsLoadWorkId = null;
373
395
  let workScopedUiGeneration = 0;
396
+ const loadedVolumeChapterIds = new Set();
397
+ const volumeChapterLoadingIds = new Set();
398
+ const volumeChapterRequests = new Map();
374
399
  let raceHierarchyLoadPromise = null;
375
400
  let raceHierarchyLoadWorkId = null;
376
401
  let loadedRaceHierarchyWorkId = null;
@@ -874,6 +899,7 @@ let chapterEditorReadOnly = true;
874
899
  let characterListPage = 1;
875
900
  let taskListPage = 1;
876
901
  let draftTypeFilter = "all";
902
+ let draftFiltersPanelOpen = false;
877
903
  const moduleListPages = {
878
904
  drafts: 1,
879
905
  settings: 1,
@@ -1592,6 +1618,14 @@ function renderAiConversationHistory() {
1592
1618
  }
1593
1619
  }
1594
1620
 
1621
+ function applyAiConversationTitle(title) {
1622
+ if (!title || !state.aiConversationId) return;
1623
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1624
+ if (current) current.title = title;
1625
+ $("#ai-conversation-title").textContent = title;
1626
+ renderAiConversationHistory();
1627
+ }
1628
+
1595
1629
  async function loadAiConversations(openLatest = true) {
1596
1630
  const workId = state.work?.id;
1597
1631
  if (!workId) return;
@@ -2126,17 +2160,46 @@ async function api(path, options = {}) {
2126
2160
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2127
2161
  state.user = null;
2128
2162
  state.csrfToken = null;
2163
+ moduleRequestCache.clear();
2129
2164
  showAuth(false);
2130
2165
  }
2131
- const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
2132
- error.code = payload.error?.code;
2133
- throw error;
2166
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2167
+ }
2168
+ if (response.status === 204) {
2169
+ invalidateModuleRequestsAfterMutation(path, method);
2170
+ return null;
2134
2171
  }
2135
- if (response.status === 204) return null;
2136
2172
  const payload = await response.json();
2173
+ invalidateModuleRequestsAfterMutation(path, method);
2137
2174
  return payload.data;
2138
2175
  }
2139
2176
 
2177
+ function createClientError(payload, fallbackMessage, fallbackStatus = null) {
2178
+ const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
2179
+ const error = new Error(typeof source.message === "string" ? source.message : fallbackMessage);
2180
+ error.code = typeof source.code === "string" ? source.code : undefined;
2181
+ error.status = Number.isInteger(source.status) ? source.status : fallbackStatus;
2182
+ error.details = source.details;
2183
+ error.failure = typeof source.failure === "string" ? source.failure : undefined;
2184
+ error.callId = typeof source.callId === "string" ? source.callId : undefined;
2185
+ return error;
2186
+ }
2187
+
2188
+ function formatAiFailureMessage(error) {
2189
+ const message = error instanceof Error ? error.message : String(error ?? "未知错误");
2190
+ const lines = [`调用失败:${message}`];
2191
+ const code = typeof error?.code === "string" ? error.code : "";
2192
+ const status = Number.isInteger(error?.status) ? error.status : null;
2193
+ const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
2194
+ const failure = typeof error?.failure === "string" ? error.failure : typeof details.failure === "string" ? details.failure : "";
2195
+ const callId = typeof error?.callId === "string" ? error.callId : typeof details.callId === "string" ? details.callId : "";
2196
+ if (code) lines.push(`错误码:${code}`);
2197
+ if (status) lines.push(`服务端状态:HTTP ${status}`);
2198
+ if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2199
+ if (callId) lines.push(`调用 ID:${callId}`);
2200
+ return lines.join("\n\n");
2201
+ }
2202
+
2140
2203
  async function apiPage(path, page = 1, limit = 30) {
2141
2204
  const separator = path.includes("?") ? "&" : "?";
2142
2205
  const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
@@ -2155,6 +2218,63 @@ async function apiAllPages(path, limit = 100) {
2155
2218
  }
2156
2219
  }
2157
2220
 
2221
+ function cachedModuleRequest(module, requestKey, loader, options = {}) {
2222
+ const workId = state.work?.id;
2223
+ if (!workId) return Promise.resolve().then(loader);
2224
+ return moduleRequestCache.request(workId, module, requestKey, loader, options);
2225
+ }
2226
+
2227
+ function moduleApi(module, path, options = {}) {
2228
+ return cachedModuleRequest(module, `api:${path}`, () => api(path), options);
2229
+ }
2230
+
2231
+ function moduleApiPage(module, path, page = 1, limit = 30, options = {}) {
2232
+ return cachedModuleRequest(module, `page:${path}:${page}:${limit}`, () => apiPage(path, page, limit), options);
2233
+ }
2234
+
2235
+ function moduleApiAllPages(module, path, limit = 100, options = {}) {
2236
+ return cachedModuleRequest(module, `all:${path}:${limit}`, () => apiAllPages(path, limit), options);
2237
+ }
2238
+
2239
+ function invalidateModuleRequestsAfterMutation(path, method) {
2240
+ if (["GET", "HEAD", "OPTIONS"].includes(method) || !state.work) return;
2241
+ if (
2242
+ path.startsWith("/api/auth/")
2243
+ || path.includes("/presence")
2244
+ || path.includes("/context/prepare")
2245
+ || path.includes("/ai-context-usage")
2246
+ || path.includes("/chat/stream")
2247
+ ) return;
2248
+
2249
+ const affected = new Set();
2250
+ if (path.includes("/ai-settings") || path.includes("/task-defaults") || path.includes("/providers") || path.includes("/models")) {
2251
+ affected.add("ai-settings");
2252
+ }
2253
+ if (path.includes("/tasks")) affected.add("tasks");
2254
+ if (path.includes("/drafts")) affected.add("drafts");
2255
+ if (path.includes("/settings") && !path.includes("/ai-settings")) affected.add("settings");
2256
+ if (path.includes("/characters") || path.includes("/character-sections")) affected.add("characters");
2257
+ if (path.includes("/races")) affected.add("races");
2258
+ if (path.includes("/organizations")) affected.add("organizations");
2259
+ if (path.includes("/timeline")) affected.add("timeline");
2260
+ if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
2261
+ if (path.includes("/relationships")) affected.add("relationships");
2262
+ if (path.includes("/reviews")) affected.add("reviews");
2263
+ if (path.includes("/entity-versions/")) {
2264
+ if (path.includes("/draft/")) affected.add("drafts");
2265
+ if (path.includes("/setting/")) affected.add("settings");
2266
+ if (path.includes("/character/")) affected.add("characters");
2267
+ if (path.includes("/race/")) affected.add("races");
2268
+ if (path.includes("/organization/")) affected.add("organizations");
2269
+ if (path.includes("/timeline-event/") || path.includes("/timeline-track/")) affected.add("timeline");
2270
+ if (path.includes("/chapter-outline/") || path.includes("/foreshadow/")) affected.add("outlines");
2271
+ if (path.includes("/relationship/")) affected.add("relationships");
2272
+ if (path.includes("/review/")) affected.add("reviews");
2273
+ }
2274
+ if (cachedWorkModules.has(state.module)) affected.add(state.module);
2275
+ affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
2276
+ }
2277
+
2158
2278
  async function initializeProductFooters() {
2159
2279
  const year = String(new Date().getFullYear());
2160
2280
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
@@ -3345,11 +3465,15 @@ function resetWorkScopedUiCaches() {
3345
3465
  state.races = [];
3346
3466
  characterListPage = 1;
3347
3467
  draftTypeFilter = "all";
3468
+ draftFiltersPanelOpen = false;
3348
3469
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3349
3470
  relationshipFilters.fromCharacterIds = [];
3350
3471
  relationshipFilters.toCharacterIds = [];
3351
3472
  taskListPage = 1;
3352
3473
  state.collapsedVolumeIds.clear();
3474
+ loadedVolumeChapterIds.clear();
3475
+ volumeChapterLoadingIds.clear();
3476
+ volumeChapterRequests.clear();
3353
3477
  state.collapsedRaceIds.clear();
3354
3478
  lastSavedChapterSnapshot = null;
3355
3479
  if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
@@ -3373,7 +3497,13 @@ function resetWorkScopedUiCaches() {
3373
3497
  async function selectWork(workId, preferredChapterId = null) {
3374
3498
  const discarding = state.work?.id !== workId && state.dirty;
3375
3499
  if (discarding && !(await confirmDiscardChanges())) return false;
3376
- const nextWork = await api(`/api/works/${workId}`);
3500
+ if (state.work?.id === workId) {
3501
+ workScopedUiGeneration += 1;
3502
+ loadedVolumeChapterIds.clear();
3503
+ volumeChapterLoadingIds.clear();
3504
+ volumeChapterRequests.clear();
3505
+ }
3506
+ const nextWork = await api(`/api/works/${workId}?directory=volumes`);
3377
3507
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
3378
3508
  if (discarding) setSaveState("就绪");
3379
3509
  $("#app").classList.remove("shelf-mode");
@@ -3384,6 +3514,8 @@ async function selectWork(workId, preferredChapterId = null) {
3384
3514
  $("#settings-button").removeAttribute("aria-current");
3385
3515
  settingsReturnContext = null;
3386
3516
  state.work = nextWork;
3517
+ state.work.volumes = state.work.volumes.map((volume) => ({ ...volume, chapters: Array.isArray(volume.chapters) ? volume.chapters : [] }));
3518
+ state.collapsedVolumeIds = new Set(state.work.volumes.map((volume) => volume.id));
3387
3519
  state.chapter = null;
3388
3520
  chapterEditorReadOnly = true;
3389
3521
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
@@ -3393,40 +3525,121 @@ async function selectWork(workId, preferredChapterId = null) {
3393
3525
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
3394
3526
  $("#top-search-button").disabled = !canReadAggregateContent();
3395
3527
  renderTree();
3396
- const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
3397
- const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
3528
+ void loadAllVolumeChapters(nextWork.id);
3398
3529
  if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
3399
- else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
3400
3530
  else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
3401
3531
  else if (!canReadModule(state.module)) showWelcome(true);
3402
3532
  else await showModule(state.module);
3403
3533
  return true;
3404
3534
  }
3405
3535
 
3536
+ async function loadVolumeChapters(volumeId) {
3537
+ if (!state.work || loadedVolumeChapterIds.has(volumeId)) return;
3538
+ const existingRequest = volumeChapterRequests.get(volumeId);
3539
+ if (existingRequest) return existingRequest;
3540
+ const workId = state.work.id;
3541
+ const generation = workScopedUiGeneration;
3542
+ volumeChapterLoadingIds.add(volumeId);
3543
+ renderTree();
3544
+ const request = (async () => {
3545
+ try {
3546
+ const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
3547
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
3548
+ const volume = state.work.volumes.find((item) => item.id === volumeId);
3549
+ if (!volume) return;
3550
+ volume.chapters = chapters;
3551
+ volume.chapterCount = chapters.length;
3552
+ loadedVolumeChapterIds.add(volumeId);
3553
+ renderTree();
3554
+ } catch (error) {
3555
+ if (state.work?.id === workId && generation === workScopedUiGeneration) {
3556
+ toast(`加载分卷章节失败:${error.message}`, "error");
3557
+ renderTree();
3558
+ }
3559
+ } finally {
3560
+ volumeChapterLoadingIds.delete(volumeId);
3561
+ volumeChapterRequests.delete(volumeId);
3562
+ if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
3563
+ }
3564
+ })();
3565
+ volumeChapterRequests.set(volumeId, request);
3566
+ return request;
3567
+ }
3568
+
3569
+ async function loadAllVolumeChapters(workId) {
3570
+ const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
3571
+ for (const volumeId of volumeIds) {
3572
+ if (state.work?.id !== workId) return;
3573
+ await loadVolumeChapters(volumeId);
3574
+ }
3575
+ }
3576
+
3577
+ function mergeChapterDirectoryEntry(chapter) {
3578
+ if (!state.work || !chapter?.volumeId) return;
3579
+ const volume = state.work.volumes.find((item) => item.id === chapter.volumeId);
3580
+ if (!volume) return;
3581
+ const directoryEntry = {
3582
+ id: chapter.id,
3583
+ workId: chapter.workId,
3584
+ volumeId: chapter.volumeId,
3585
+ title: chapter.title,
3586
+ chapterType: chapter.chapterType,
3587
+ sortOrder: chapter.sortOrder,
3588
+ wordCount: chapter.wordCount,
3589
+ versionNo: chapter.versionNo,
3590
+ analysisStatus: chapter.analysisStatus,
3591
+ excludedFromAnalysis: chapter.excludedFromAnalysis,
3592
+ createdAt: chapter.createdAt,
3593
+ updatedAt: chapter.updatedAt
3594
+ };
3595
+ const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
3596
+ const existingIndex = chapters.findIndex((item) => item.id === chapter.id);
3597
+ if (existingIndex >= 0) chapters[existingIndex] = directoryEntry;
3598
+ else chapters.push(directoryEntry);
3599
+ chapters.sort((left, right) => Number(left.sortOrder) - Number(right.sortOrder));
3600
+ volume.chapters = chapters;
3601
+ volume.chapterCount = Math.max(Number(volume.chapterCount ?? 0), chapters.length);
3602
+ }
3603
+
3406
3604
  function renderTree() {
3407
3605
  if (!state.work) return;
3408
- const count = state.work.volumes.reduce((total, volume) => total + volume.chapters.length, 0);
3606
+ const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
3409
3607
  const proseEditable = canEditProse();
3410
3608
  $("#chapter-count").textContent = `${count} 章`;
3411
3609
  $("#novel-tree").classList.remove("empty-copy");
3412
- $("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
3413
- <div class="volume-node ${state.collapsedVolumeIds.has(volume.id) ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
3610
+ $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
3611
+ const collapsed = state.collapsedVolumeIds.has(volume.id);
3612
+ const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
3613
+ const chapterContent = collapsed
3614
+ ? ""
3615
+ : volumeChapterLoadingIds.has(volume.id)
3616
+ ? '<p class="entity-history-empty">正在加载章节……</p>'
3617
+ : !loadedVolumeChapterIds.has(volume.id)
3618
+ ? '<p class="entity-history-empty">展开后加载章节。</p>'
3619
+ : chapters.length
3620
+ ? chapters.map((chapter) => `
3621
+ <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
3622
+ <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
3623
+ </button>`).join("")
3624
+ : '<p class="entity-history-empty">本卷还没有章节。</p>';
3625
+ return `
3626
+ <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
3414
3627
  <div class="volume-title">
3415
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
3628
+ <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
3416
3629
  ${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
3417
3630
  </div>
3418
3631
  <div class="volume-chapters">
3419
- ${volume.chapters.map((chapter) => `
3420
- <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
3421
- <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
3422
- </button>`).join("")}
3632
+ ${chapterContent}
3423
3633
  </div>
3424
- </div>`).join("");
3634
+ </div>`;
3635
+ }).join("");
3425
3636
  $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
3426
3637
  button.addEventListener("click", () => {
3427
3638
  const volumeId = button.dataset.volumeToggle;
3428
- if (state.collapsedVolumeIds.has(volumeId)) state.collapsedVolumeIds.delete(volumeId);
3429
- else state.collapsedVolumeIds.add(volumeId);
3639
+ if (state.collapsedVolumeIds.has(volumeId)) {
3640
+ state.collapsedVolumeIds.delete(volumeId);
3641
+ void loadVolumeChapters(volumeId);
3642
+ } else state.collapsedVolumeIds.add(volumeId);
3430
3643
  renderTree();
3431
3644
  });
3432
3645
  button.addEventListener("contextmenu", (event) => {
@@ -3446,7 +3659,7 @@ function renderTree() {
3446
3659
  button.closest(".volume-node")?.classList.remove("is-drag-target");
3447
3660
  const chapterId = event.dataTransfer?.getData("text/plain");
3448
3661
  const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
3449
- if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, volume.chapters.length);
3662
+ if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, Number(volume.chapterCount ?? volume.chapters.length));
3450
3663
  });
3451
3664
  }
3452
3665
  });
@@ -3715,7 +3928,11 @@ async function deleteChapter(chapterId) {
3715
3928
  async function selectChapter(chapterId, { editMode = false } = {}) {
3716
3929
  if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
3717
3930
  cancelChapterAutoSave();
3718
- state.chapter = await api(`/api/chapters/${chapterId}`);
3931
+ if (state.chapter?.id !== chapterId) {
3932
+ state.chapter = await api(`/api/chapters/${chapterId}`);
3933
+ mergeChapterDirectoryEntry(state.chapter);
3934
+ }
3935
+ state.collapsedVolumeIds.delete(state.chapter.volumeId);
3719
3936
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
3720
3937
  chapterEditorReadOnly = !canEditProse() || !editMode;
3721
3938
  state.module = "editor";
@@ -4152,6 +4369,15 @@ function mountRelationshipFilterToggle() {
4152
4369
  });
4153
4370
  }
4154
4371
 
4372
+ function mountDraftFilterToggle() {
4373
+ $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
4374
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选草稿" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" title="筛选草稿"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
4375
+ $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
4376
+ draftFiltersPanelOpen = !draftFiltersPanelOpen;
4377
+ await renderDrafts(moduleListPages.drafts);
4378
+ });
4379
+ }
4380
+
4155
4381
  function bindRecordPreview(selector, open) {
4156
4382
  $("#module-content").querySelectorAll(selector).forEach((card) => {
4157
4383
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -4246,28 +4472,42 @@ function draftTypeLabel(draftType) {
4246
4472
 
4247
4473
  async function deleteDraft(item) {
4248
4474
  if (!item || !canEditModule("drafts")) return;
4475
+ const dialog = $("#form-dialog");
4476
+ dialog.close();
4249
4477
  if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4250
4478
  title: "删除草稿",
4251
4479
  confirmLabel: "确认删除"
4252
- })) return;
4480
+ })) {
4481
+ openDraftDialog(item);
4482
+ return;
4483
+ }
4253
4484
  try {
4254
4485
  await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4255
4486
  await renderDrafts(moduleListPages.drafts);
4256
4487
  toast("草稿已删除");
4257
4488
  } catch (error) {
4258
4489
  toast(error.message, "error");
4490
+ try {
4491
+ openDraftDialog(await api(`/api/drafts/${encodeURIComponent(item.id)}`));
4492
+ } catch (reloadError) {
4493
+ toast(reloadError.message, "error");
4494
+ }
4259
4495
  }
4260
4496
  }
4261
4497
 
4262
4498
  function openDraftDialog(item = null, { readOnly = false } = {}) {
4263
4499
  const viewOnly = readOnly || !canEditModule("drafts");
4500
+ const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="草稿操作">
4501
+ <div><strong>草稿操作</strong><small>删除后将从草稿列表移除,版本历史仍会保留。</small></div>
4502
+ <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除草稿</button></div>
4503
+ </section>` : "";
4264
4504
  const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4265
4505
  + field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
4266
4506
  + field("title", "标题", "text", item?.title ?? "")
4267
4507
  + field("content", "内容", "markdown", item?.content ?? "", {
4268
4508
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
4269
4509
  readOnly: viewOnly
4270
- });
4510
+ }) + management;
4271
4511
  openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4272
4512
  if (viewOnly) return;
4273
4513
  const title = String(form.get("title") ?? "").trim();
@@ -4287,7 +4527,7 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4287
4527
  }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4288
4528
  submitLabel: viewOnly ? "关闭" : "保存草稿",
4289
4529
  hideCancel: viewOnly,
4290
- wide: true,
4530
+ editor: true,
4291
4531
  errorPrefix: "草稿保存失败:"
4292
4532
  });
4293
4533
  if (viewOnly) {
@@ -4296,10 +4536,13 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4296
4536
  else control.readOnly = true;
4297
4537
  });
4298
4538
  }
4539
+ $("#dialog-fields").querySelector("[data-dialog-draft-delete]")?.addEventListener("click", () => {
4540
+ void deleteDraft(item);
4541
+ });
4299
4542
  }
4300
4543
 
4301
4544
  async function renderDrafts(page = moduleListPages.drafts) {
4302
- const allDrafts = await apiAllPages(`/api/works/${state.work.id}/drafts`);
4545
+ const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
4303
4546
  const drafts = draftTypeFilter === "all"
4304
4547
  ? allDrafts
4305
4548
  : allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
@@ -4309,7 +4552,8 @@ async function renderDrafts(page = moduleListPages.drafts) {
4309
4552
  const layout = readModuleLayout();
4310
4553
  if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4311
4554
  else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4312
- const filterToolbar = `<section class="draft-filter-toolbar" aria-label="草稿筛选">
4555
+ mountDraftFilterToggle();
4556
+ const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
4313
4557
  <label for="draft-type-filter">草稿类型</label>
4314
4558
  <select id="draft-type-filter" aria-label="按草稿类型筛选">
4315
4559
  <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
@@ -4319,7 +4563,7 @@ async function renderDrafts(page = moduleListPages.drafts) {
4319
4563
  ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4320
4564
  </section>`;
4321
4565
  const actions = (item) => canEditModule("drafts")
4322
- ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}<button type="button" data-delete-draft="${esc(item.id)}">删除</button>${recordHistoryButton("draft", item.id, item.title)}`
4566
+ ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4323
4567
  : recordHistoryButton("draft", item.id, item.title);
4324
4568
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4325
4569
  <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
@@ -4344,12 +4588,12 @@ async function renderDrafts(page = moduleListPages.drafts) {
4344
4588
  : emptyDrafts);
4345
4589
  $("#draft-type-filter").addEventListener("change", async (event) => {
4346
4590
  draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
4591
+ draftFiltersPanelOpen = true;
4347
4592
  moduleListPages.drafts = 1;
4348
4593
  await renderDrafts(1);
4349
4594
  });
4350
4595
  bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
4351
4596
  bindModulePagination("drafts", renderDrafts);
4352
- const draftById = (draftId) => drafts.find((draft) => draft.id === draftId);
4353
4597
  $("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
4354
4598
  const open = async () => openDraftDialog(await api(`/api/drafts/${encodeURIComponent(card.dataset.openDraft)}`), { readOnly: true });
4355
4599
  card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) void open(); });
@@ -4363,9 +4607,6 @@ async function renderDrafts(page = moduleListPages.drafts) {
4363
4607
  $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
4364
4608
  openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
4365
4609
  }));
4366
- $("#module-content").querySelectorAll("[data-delete-draft]").forEach((button) => button.addEventListener("click", () => {
4367
- void deleteDraft(draftById(button.dataset.deleteDraft));
4368
- }));
4369
4610
  bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
4370
4611
  }
4371
4612
 
@@ -4387,7 +4628,7 @@ function renderSettingRows(records) {
4387
4628
  }
4388
4629
 
4389
4630
  async function renderSettings(page = moduleListPages.settings) {
4390
- const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
4631
+ const records = await moduleApiAllPages("settings", `/api/works/${state.work.id}/settings`);
4391
4632
  state.settings = records;
4392
4633
  mountModuleCount(records.length);
4393
4634
  const pageResult = paginateModuleItems(records, page, "settings");
@@ -4408,9 +4649,11 @@ async function renderCharacters(page = characterListPage) {
4408
4649
  const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
4409
4650
  const pageSize = pageSizeFor("characters");
4410
4651
  const [characterSource, races, organizations] = await Promise.all([
4411
- hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page, pageSize),
4412
- canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4413
- canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4652
+ hasCharacterFilters
4653
+ ? moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`)
4654
+ : moduleApiPage("characters", `/api/works/${state.work.id}/characters`, page, pageSize),
4655
+ canReadModule("races") ? moduleApi("characters", `/api/works/${state.work.id}/races`) : Promise.resolve([]),
4656
+ canReadModule("organizations") ? moduleApiAllPages("characters", `/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4414
4657
  ]);
4415
4658
  const characterPage = hasCharacterFilters
4416
4659
  ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
@@ -4586,7 +4829,7 @@ async function renderRaces() {
4586
4829
  const workId = state.work.id;
4587
4830
  const generation = workScopedUiGeneration;
4588
4831
  const requestId = ++raceListRequestId;
4589
- const roots = await api(`/api/works/${workId}/races?scope=roots`);
4832
+ const roots = await moduleApi("races", `/api/works/${workId}/races?scope=roots`);
4590
4833
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4591
4834
  state.races = roots.items;
4592
4835
  loadedRaceHierarchyWorkId = roots.items.length === roots.total ? workId : null;
@@ -4594,7 +4837,7 @@ async function renderRaces() {
4594
4837
  if (loadedRaceHierarchyWorkId === workId) return;
4595
4838
 
4596
4839
  const dismissLoadingToast = persistentToast("正在加载子种族……");
4597
- const loadPromise = api(`/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4840
+ const loadPromise = moduleApi("races", `/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4598
4841
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4599
4842
  state.races = [...roots.items, ...descendants];
4600
4843
  loadedRaceHierarchyWorkId = workId;
@@ -4618,8 +4861,8 @@ async function renderRaces() {
4618
4861
 
4619
4862
  async function renderOrganizations(page = moduleListPages.organizations) {
4620
4863
  [state.organizations, state.characters] = await Promise.all([
4621
- apiAllPages(`/api/works/${state.work.id}/organizations`),
4622
- canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
4864
+ moduleApiAllPages("organizations", `/api/works/${state.work.id}/organizations`),
4865
+ canReadModule("characters") ? moduleApiAllPages("organizations", `/api/works/${state.work.id}/characters`) : Promise.resolve([])
4623
4866
  ]);
4624
4867
  mountModuleCount(state.organizations.length);
4625
4868
  const pageResult = paginateModuleItems(state.organizations, page, "organizations");
@@ -4685,8 +4928,8 @@ function setTimelineMultiSelectMode(enabled) {
4685
4928
 
4686
4929
  async function renderTimeline(page = moduleListPages.timeline) {
4687
4930
  const [events, tracks] = await Promise.all([
4688
- apiAllPages(`/api/works/${state.work.id}/timeline`),
4689
- apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
4931
+ moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline`),
4932
+ moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline-tracks`)
4690
4933
  ]);
4691
4934
  mountModuleCount(events.length);
4692
4935
  const pageResult = paginateModuleItems(events, page, "timeline");
@@ -4729,8 +4972,8 @@ async function renderTimeline(page = moduleListPages.timeline) {
4729
4972
  async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
4730
4973
  const currentChapterId = state.chapter?.id;
4731
4974
  const [outlines, foreshadows] = await Promise.all([
4732
- apiAllPages(`/api/works/${state.work.id}/outlines`),
4733
- apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
4975
+ moduleApiAllPages("outlines", `/api/works/${state.work.id}/outlines`),
4976
+ moduleApiAllPages("outlines", `/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
4734
4977
  ]);
4735
4978
  mountModuleCount(outlines.length + foreshadows.length);
4736
4979
  const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
@@ -4783,8 +5026,8 @@ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foresh
4783
5026
  }
4784
5027
 
4785
5028
  async function renderRelationships(page = moduleListPages.relationships) {
4786
- state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4787
- const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
5029
+ state.characters = canReadModule("characters") ? await moduleApiAllPages("relationships", `/api/works/${state.work.id}/characters`) : [];
5030
+ const relationships = await moduleApiAllPages("relationships", `/api/works/${state.work.id}/relationships`);
4788
5031
  const filteredRelationships = filterRelationships(relationships, relationshipFilters);
4789
5032
  const pageResult = paginateModuleItems(filteredRelationships, page, "relationships");
4790
5033
  moduleListPages.relationships = pageResult.page;
@@ -4858,8 +5101,8 @@ async function renderReviews(page = moduleListPages.reviews) {
4858
5101
  const canMergeCharacters = canResolveReview
4859
5102
  && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
4860
5103
  const [reviews, characters] = await Promise.all([
4861
- apiAllPages(`/api/works/${state.work.id}/reviews`),
4862
- canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
5104
+ moduleApiAllPages("reviews", `/api/works/${state.work.id}/reviews`),
5105
+ canReadCharacters ? moduleApiAllPages("reviews", `/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
4863
5106
  ]);
4864
5107
  mountModuleCount(reviews.length);
4865
5108
  const pageResult = paginateModuleItems(reviews, page, "reviews");
@@ -4947,16 +5190,16 @@ async function renderReviews(page = moduleListPages.reviews) {
4947
5190
  }));
4948
5191
  }
4949
5192
 
4950
- async function renderTasks(page = taskListPage) {
5193
+ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
4951
5194
  stopTaskProgressRefresh();
4952
5195
  const pageSize = pageSizeFor("analysisTasks");
4953
5196
  const [taskPage, settings] = await Promise.all([
4954
- apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
5197
+ moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
4955
5198
  canReadModule("ai-settings")
4956
- ? api(`/api/works/${state.work.id}/ai-settings`)
5199
+ ? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
4957
5200
  : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
4958
5201
  ]);
4959
- if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
5202
+ if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
4960
5203
  taskListPage = taskPage.page;
4961
5204
  const tasks = taskPage.items;
4962
5205
  const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
@@ -5067,7 +5310,7 @@ async function renderTasks(page = taskListPage) {
5067
5310
  : "自动执行已关闭");
5068
5311
  taskAutoRunEditing = false;
5069
5312
  taskAutoRunEditingWorkId = null;
5070
- await renderTasks();
5313
+ await renderTasks(taskListPage, { refresh: true });
5071
5314
  } catch (error) {
5072
5315
  toast(error.message, "error");
5073
5316
  button.disabled = false;
@@ -5171,12 +5414,7 @@ async function rerunAnalysisTaskWithModel(task, button) {
5171
5414
  try {
5172
5415
  const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
5173
5416
  const currentModelId = String(task.model?.id ?? "");
5174
- const availableModels = models.filter((model) =>
5175
- model.id !== currentModelId
5176
- && model.enabled
5177
- && model.providerStatus === "enabled"
5178
- && model.providerConnectionStatus === "success"
5179
- );
5417
+ const availableModels = models.filter((model) => model.id !== currentModelId && isSelectableModel(model));
5180
5418
  if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
5181
5419
  $("#form-dialog").close();
5182
5420
  const currentModelLabel = task.model?.displayName || "运行时默认模型";
@@ -5226,7 +5464,7 @@ function scheduleTaskProgressRefresh(workId, runningCount) {
5226
5464
  return;
5227
5465
  }
5228
5466
  try {
5229
- await renderTasks();
5467
+ await renderTasks(taskListPage, { refresh: true });
5230
5468
  } catch (error) {
5231
5469
  console.error("Failed to refresh task progress", error);
5232
5470
  scheduleTaskProgressRefresh(workId, runningCount);
@@ -5687,11 +5925,28 @@ function openTaskDetailDialog(task, trace) {
5687
5925
  }
5688
5926
 
5689
5927
  function renderProviderCards(providers, models) {
5690
- return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => `
5691
- <article class="record-card provider-card"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerStatusLabel(provider.status))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><h3>${esc(provider.name)}</h3>
5692
- <p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit} · 最大输出:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
5693
- <div class="provider-models">${models.filter((model) => model.providerId === provider.id).map((model) => `<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>`).join("")}</div>
5694
- <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}">测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`).join("")}</div>`
5928
+ return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
5929
+ const providerModels = models.filter((model) => model.providerId === provider.id);
5930
+ const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
5931
+ const disabledNotice = provider.status === "disabled"
5932
+ ? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
5933
+ : "";
5934
+ return `
5935
+ <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
5936
+ ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
5937
+ <div class="provider-models">${providerModels.map((model) => {
5938
+ const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
5939
+ const modelStatus = !model.enabled
5940
+ ? `<span class="model-status-badge is-disabled">模型已停用</span>`
5941
+ : provider.status !== "enabled"
5942
+ ? `<span class="model-status-badge is-disabled">供应商已停用</span>`
5943
+ : provider.connectionStatus !== "success"
5944
+ ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
5945
+ : "";
5946
+ 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}<button class="ghost-button model-test-button" type="button" data-test-model="${esc(model.id)}" aria-label="测试模型 ${esc(model.displayName)}">测试连接</button></div>`;
5947
+ }).join("")}</div>
5948
+ <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><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>`;
5949
+ }).join("")}</div>`
5695
5950
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
5696
5951
  }
5697
5952
 
@@ -5704,28 +5959,50 @@ function bindPlatformProviderActions(host, providers, models) {
5704
5959
  await renderPlatformAiConfig();
5705
5960
  await loadModels();
5706
5961
  }));
5962
+ host.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", async () => {
5963
+ button.disabled = true;
5964
+ button.textContent = "测试中";
5965
+ const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
5966
+ toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
5967
+ await renderPlatformAiConfig();
5968
+ await loadModels();
5969
+ }));
5707
5970
  host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
5708
5971
  host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
5709
5972
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
5710
5973
  }
5711
5974
 
5712
- function renderTaskDefaults(models, providers, taskDefaults) {
5975
+ function renderTaskDefaults(models, providers, taskDefaults, settings) {
5713
5976
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
5714
5977
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
5715
- return models.length ? `<section class="config-section">
5978
+ const availableModels = models.filter((model) => isSelectableModel(model));
5979
+ const availableModelIds = new Set(availableModels.map((model) => model.id));
5980
+ const currentDefaultModels = taskDefaults
5981
+ .map((item) => item.model)
5982
+ .filter((model) => model && !availableModelIds.has(model.id));
5983
+ const optionModels = [...availableModels, ...currentDefaultModels];
5984
+ return optionModels.length ? `<section class="config-section">
5716
5985
  <div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
5717
- <table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody>${taskTypeLabels.map(([taskType, label]) => {
5986
+ <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="创作助手对话标题生成">
5987
+ <option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
5988
+ ${models.map((model) => {
5989
+ const provider = providerById.get(model.providerId);
5990
+ const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
5991
+ return `<option value="${esc(model.id)}" ${model.id === settings.titleGenerationModelId ? "selected" : ""} ${available || model.id === settings.titleGenerationModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
5992
+ }).join("")}
5993
+ </select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
5718
5994
  const currentModelId = defaultModelByTask.get(taskType) ?? "";
5719
5995
  return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
5720
5996
  <option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
5721
- ${models.map((model) => {
5997
+ ${optionModels.map((model) => {
5722
5998
  const provider = providerById.get(model.providerId);
5723
- const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
5724
- return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available || model.id === currentModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
5999
+ const available = isSelectableModel({ ...model, providerStatus: model.providerStatus ?? provider?.status, providerConnectionStatus: model.providerConnectionStatus ?? provider?.connectionStatus });
6000
+ const unavailableLabel = !model.enabled ? "模型已停用 · " : provider?.status !== "enabled" ? "供应商已停用 · " : "连接不可用 · ";
6001
+ return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available ? "" : "disabled"}>${esc(`${available ? "" : unavailableLabel}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
5725
6002
  }).join("")}
5726
6003
  </select></td></tr>`;
5727
6004
  }).join("")}</tbody></table>
5728
- </section>` : emptyModule("尚未配置平台模型", "请先在平台 AI 管理中添加并测试供应商模型。");
6005
+ </section>` : emptyModule("尚未配置可用模型", "请先启用并测试供应商模型,已停用模型不会出现在新任务选择中。");
5729
6006
  }
5730
6007
 
5731
6008
  const relationshipIndexStatusLabels = Object.freeze({
@@ -6106,12 +6383,12 @@ async function renderBookAiSettings() {
6106
6383
  relationshipSearchIndexRefreshTimer = null;
6107
6384
  }
6108
6385
  const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
6109
- api(`/api/works/${state.work.id}/ai-settings`),
6110
- api("/api/platform/ai/providers"),
6111
- api(`/api/works/${state.work.id}/models`),
6112
- api(`/api/works/${state.work.id}/task-defaults`),
6113
- api(`/api/works/${state.work.id}/ai-settings/relationship-search-index`),
6114
- api(`/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
6386
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
6387
+ moduleApi("ai-settings", "/api/platform/ai/providers"),
6388
+ moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
6389
+ moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
6390
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
6391
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
6115
6392
  ]);
6116
6393
  const host = $("#module-content");
6117
6394
  const workId = String(state.work.id);
@@ -6119,7 +6396,7 @@ async function renderBookAiSettings() {
6119
6396
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
6120
6397
  title: "本书 Token 用量",
6121
6398
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
6122
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
6399
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
6123
6400
  scrollUsageCalendarsToLatest(host);
6124
6401
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
6125
6402
  "beforebegin",
@@ -6256,6 +6533,18 @@ async function renderBookAiSettings() {
6256
6533
  button.disabled = false;
6257
6534
  }
6258
6535
  });
6536
+ host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
6537
+ const select = event.currentTarget;
6538
+ select.disabled = true;
6539
+ try {
6540
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { titleGenerationModelId: select.value } });
6541
+ toast("创作助手对话标题生成模型已更新");
6542
+ } catch (error) {
6543
+ toast(error.message, "error");
6544
+ }
6545
+ await renderBookAiSettings();
6546
+ await loadModels();
6547
+ });
6259
6548
  host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
6260
6549
  select.disabled = true;
6261
6550
  try {
@@ -6275,11 +6564,11 @@ async function loadModels() {
6275
6564
  const generation = workScopedUiGeneration;
6276
6565
  const models = await api(`/api/works/${workId}/models`);
6277
6566
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
6278
- state.models = models;
6567
+ state.models = models.filter((model) => isSelectableModel(model));
6279
6568
  loadedAiModelsWorkId = workId;
6280
6569
  const select = $("#ai-model");
6281
6570
  select.innerHTML = state.models.length
6282
- ? state.models.map((model) => `<option value="${esc(model.id)}" ${model.enabled ? "" : "disabled"}>${esc(modelOptionLabel(model))}</option>`).join("")
6571
+ ? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
6283
6572
  : '<option value="">请先配置模型</option>';
6284
6573
  scheduleAiContextUsage();
6285
6574
  }
@@ -6327,9 +6616,50 @@ function currentAiRequestScope() {
6327
6616
  return { taskType, scope, selection };
6328
6617
  }
6329
6618
 
6619
+ function renderAiContextDistribution(usage) {
6620
+ const popover = $("#ai-context-popover");
6621
+ const host = $("#ai-context-distribution");
6622
+ const distribution = normalizeAiContextTokenDistribution(usage);
6623
+ const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
6624
+ $("#ai-context-popover-description").textContent = usage
6625
+ ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok`
6626
+ : "选择可用模型后显示当前上下文用量";
6627
+ host.replaceChildren(...distribution.items.map((item) => {
6628
+ const row = document.createElement("div");
6629
+ row.className = "ai-context-distribution-row";
6630
+ row.dataset.key = item.key;
6631
+ row.setAttribute("role", "listitem");
6632
+ row.setAttribute("aria-label", `${item.label}:${item.tokens.toLocaleString("zh-CN")} tok,占 ${item.percent}%`);
6633
+
6634
+ const label = document.createElement("div");
6635
+ label.className = "ai-context-distribution-label";
6636
+ const title = document.createElement("span");
6637
+ title.textContent = item.label;
6638
+ if (item.key === "context") {
6639
+ const description = document.createElement("small");
6640
+ description.textContent = "用户和 agent 的交互";
6641
+ title.append(" ", description);
6642
+ }
6643
+ const value = document.createElement("strong");
6644
+ value.textContent = `${item.tokens.toLocaleString("zh-CN")} tok · ${item.percent}%`;
6645
+ label.append(title, value);
6646
+
6647
+ const track = document.createElement("div");
6648
+ track.className = "ai-context-distribution-track";
6649
+ track.setAttribute("aria-hidden", "true");
6650
+ const bar = document.createElement("span");
6651
+ bar.style.setProperty("--distribution-percent", String(item.percent));
6652
+ track.append(bar);
6653
+ row.append(label, track);
6654
+ return row;
6655
+ }));
6656
+ popover.dataset.hasUsage = String(Boolean(usage));
6657
+ }
6658
+
6330
6659
  function setAiContextMeter(usage) {
6331
6660
  const meter = $("#ai-context-meter");
6332
6661
  const value = meter.querySelector("b");
6662
+ renderAiContextDistribution(usage);
6333
6663
  if (!usage) {
6334
6664
  meter.classList.add("is-empty");
6335
6665
  meter.classList.remove("is-warning", "is-danger");
@@ -6351,6 +6681,14 @@ function setAiContextMeter(usage) {
6351
6681
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
6352
6682
  }
6353
6683
 
6684
+ function setAiContextDistributionVisible(visible) {
6685
+ const meter = $("#ai-context-meter");
6686
+ const popover = $("#ai-context-popover");
6687
+ popover.classList.toggle("hidden", !visible);
6688
+ meter.setAttribute("aria-expanded", String(visible));
6689
+ if (!visible && document.activeElement === $("#ai-context-popover-close")) meter.focus();
6690
+ }
6691
+
6354
6692
  function showAiContextWarning(usage = null) {
6355
6693
  const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
6356
6694
  const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
@@ -6641,6 +6979,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6641
6979
  dialog.classList.toggle("wide-dialog", Boolean(options.wide));
6642
6980
  dialog.classList.toggle("trace-dialog", Boolean(options.trace));
6643
6981
  dialog.classList.toggle("large-dialog", Boolean(options.large));
6982
+ dialog.classList.toggle("editor-dialog", Boolean(options.editor));
6644
6983
  bindDynamicListControls($("#dialog-fields"));
6645
6984
  bindRelationshipKeywordControls($("#dialog-fields"));
6646
6985
  formDialogVditors = bindVditorEditors($("#dialog-fields"));
@@ -8361,11 +8700,7 @@ async function openTaskDialog() {
8361
8700
  return;
8362
8701
  }
8363
8702
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
8364
- const availableTaskModels = taskModels.filter((model) =>
8365
- model.enabled
8366
- && model.providerStatus === "enabled"
8367
- && model.providerConnectionStatus === "success"
8368
- );
8703
+ const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
8369
8704
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
8370
8705
  const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
8371
8706
  <span id="relationship-character-label">被分析角色(可多选)</span>
@@ -8671,8 +9006,8 @@ async function openTaskDialog() {
8671
9006
  function openProviderDialog(item) {
8672
9007
  const protocol = item?.protocol ?? "openai-chat-completions";
8673
9008
  const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
8674
- openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出令牌数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
8675
- const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), maxTokens: Number(form.get("maxTokens")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
9009
+ openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
9010
+ const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
8676
9011
  if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
8677
9012
  await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
8678
9013
  await renderPlatformAiConfig();
@@ -8758,29 +9093,37 @@ async function sendAi() {
8758
9093
  let assistantContent = "";
8759
9094
  let assistantMessage;
8760
9095
  let assistantMetadata = {};
9096
+ let persistedStreamMessage = null;
8761
9097
  let suggestion = null;
8762
9098
  if (taskType === "chat") {
8763
9099
  const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
8764
9100
  assistantContent = streamed.content;
8765
9101
  assistantMessage = streamed.message;
8766
9102
  assistantMetadata = streamed.metadata;
9103
+ persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
9104
+ applyAiConversationTitle(streamed.conversationTitle);
8767
9105
  } else {
8768
9106
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
8769
9107
  assistantContent = suggestion.content;
8770
9108
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
8771
9109
  }
8772
9110
  try {
8773
- const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
8774
- if (assistantMessage) {
8775
- updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
8776
- attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
8777
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
9111
+ if (persistedStreamMessage) {
9112
+ updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
9113
+ attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
9114
+ } else {
9115
+ const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
9116
+ if (assistantMessage) {
9117
+ updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
9118
+ attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
9119
+ } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
9120
+ }
8778
9121
  } catch (error) {
8779
9122
  if (suggestion) appendSuggestion(suggestion);
8780
9123
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
8781
9124
  }
8782
9125
  } catch (error) {
8783
- const failureMessage = `调用失败:${error.message}`;
9126
+ const failureMessage = formatAiFailureMessage(error);
8784
9127
  let persistedFailureMessage = null;
8785
9128
  try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
8786
9129
  appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
@@ -8814,6 +9157,9 @@ async function streamChat(body) {
8814
9157
  let generatedMetadata = {};
8815
9158
  let toolCalls = [];
8816
9159
  let processSteps = [];
9160
+ let persistedMessageId = null;
9161
+ let persistedMessageCreatedAt = null;
9162
+ let conversationTitle = null;
8817
9163
  let finalAnswerStarted = false;
8818
9164
  const processStartedAt = Date.now();
8819
9165
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -8825,7 +9171,7 @@ async function streamChat(body) {
8825
9171
  });
8826
9172
  if (!response.ok || !response.body) {
8827
9173
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
8828
- throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
9174
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
8829
9175
  }
8830
9176
  const reader = response.body.getReader();
8831
9177
  const decoder = new TextDecoder();
@@ -8868,6 +9214,9 @@ async function streamChat(body) {
8868
9214
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
8869
9215
  scrollAiFeedToBottom();
8870
9216
  } else if (eventName === "complete") {
9217
+ persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9218
+ persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
9219
+ conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
8871
9220
  await typewriter.finish();
8872
9221
  message.classList.remove("is-streaming");
8873
9222
  content.setAttribute("aria-busy", "false");
@@ -8881,7 +9230,7 @@ async function streamChat(body) {
8881
9230
  attachAssistantCopyAction(message, streamedText);
8882
9231
  scrollAiFeedToBottom();
8883
9232
  } else if (eventName === "error") {
8884
- streamError = new Error(payload.message ?? "AI 流式调用失败");
9233
+ streamError = createClientError(payload, "AI 流式调用失败", response.status);
8885
9234
  }
8886
9235
  };
8887
9236
  while (true) {
@@ -8895,7 +9244,7 @@ async function streamChat(body) {
8895
9244
  if (buffer.trim()) await consume(buffer);
8896
9245
  await typewriter.finish();
8897
9246
  if (streamError) throw streamError;
8898
- return { content: streamedText, message, metadata: generatedMetadata };
9247
+ return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle };
8899
9248
  } catch (error) {
8900
9249
  typewriter.reveal();
8901
9250
  message.classList.remove("is-streaming");
@@ -8910,8 +9259,12 @@ async function streamChat(body) {
8910
9259
 
8911
9260
  function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
8912
9261
  const message = document.createElement("div");
8913
- message.className = role === "user" ? "user-message" : "assistant-message";
8914
- message.innerHTML = `<div class="message-body">${renderMarkdown(text)}</div>`;
9262
+ const isFailure = role === "assistant" && text.startsWith("调用失败:");
9263
+ message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
9264
+ const messageBody = isFailure
9265
+ ? `<p class="ai-error-text">${esc(text)}</p>`
9266
+ : renderMarkdown(text);
9267
+ message.innerHTML = `<div class="message-body">${messageBody}</div>`;
8915
9268
  attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
8916
9269
  if (citations.length) {
8917
9270
  const references = document.createElement("div");
@@ -10270,6 +10623,7 @@ document.addEventListener("pointerdown", (event) => {
10270
10623
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10271
10624
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
10272
10625
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
10626
+ if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
10273
10627
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
10274
10628
  $("#account-menu").classList.add("hidden");
10275
10629
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -10291,6 +10645,7 @@ document.addEventListener("keydown", (event) => {
10291
10645
  closeLineCitationMenu();
10292
10646
  closeMarkdownTableMenu(true);
10293
10647
  hideAiMentionMenu();
10648
+ setAiContextDistributionVisible(false);
10294
10649
  }
10295
10650
  });
10296
10651
  document.addEventListener("keydown", (event) => {
@@ -10300,6 +10655,10 @@ document.addEventListener("keydown", (event) => {
10300
10655
  if (event.repeat) return;
10301
10656
  openSearchDialog().catch((error) => toast(error.message, "error"));
10302
10657
  }, { capture: true });
10658
+ $("#ai-context-meter").addEventListener("click", () => {
10659
+ setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
10660
+ });
10661
+ $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
10303
10662
  $("#ai-send").addEventListener("click", sendAi);
10304
10663
  $("#ai-new-conversation").addEventListener("click", async () => {
10305
10664
  const button = $("#ai-new-conversation");