@musnows/scriverse 0.7.10 → 0.7.12

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.
@@ -26,6 +26,7 @@ import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-ti
26
26
  import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260812-context-usage-remaining-v2";
27
27
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
28
28
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
29
+ import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
29
30
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
30
31
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
31
32
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260801-entity-lifecycle-v1";
@@ -2144,11 +2145,27 @@ function closeAiHistoryActionMenu(restoreFocus = false) {
2144
2145
  aiHistoryActionAnchor = null;
2145
2146
  }
2146
2147
 
2148
+ function syncAiHistoryActionMenu(conversation) {
2149
+ const menu = $("#ai-history-action-menu");
2150
+ const isFavorite = conversation?.isFavorite === true;
2151
+ const favorite = menu.querySelector('[data-ai-history-action="favorite"]');
2152
+ const favoriteLabel = favorite?.querySelector("span");
2153
+ const favoriteHint = favorite?.querySelector("small");
2154
+ if (favoriteLabel) favoriteLabel.textContent = isFavorite ? "取消收藏" : "收藏对话";
2155
+ if (favoriteHint) favoriteHint.textContent = isFavorite ? "取消收藏后才可以清理对话" : "收藏后不会被清理";
2156
+ const deleteOption = menu.querySelector('[data-ai-history-action="delete"]');
2157
+ if (deleteOption) {
2158
+ deleteOption.disabled = isFavorite;
2159
+ deleteOption.title = isFavorite ? "收藏的对话不能清理,请先取消收藏" : "清理后无法恢复";
2160
+ }
2161
+ }
2162
+
2147
2163
  function showAiHistoryActionMenu(anchor, conversation) {
2148
2164
  const menu = $("#ai-history-action-menu");
2149
2165
  closeAiHistoryActionMenu();
2150
2166
  aiHistoryActionConversation = conversation;
2151
2167
  aiHistoryActionAnchor = anchor;
2168
+ syncAiHistoryActionMenu(conversation);
2152
2169
  menu.classList.remove("hidden");
2153
2170
  const anchorRect = anchor.getBoundingClientRect();
2154
2171
  const menuRect = menu.getBoundingClientRect();
@@ -2181,6 +2198,84 @@ async function downloadAiConversation(conversation) {
2181
2198
  window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
2182
2199
  }
2183
2200
 
2201
+ function resetAiConversationAfterDelete() {
2202
+ state.aiConversationId = null;
2203
+ state.aiConversationModelId = null;
2204
+ state.aiPromptSent = false;
2205
+ state.aiCitations = [];
2206
+ state.aiReferences = [];
2207
+ applyAiConversationTaskType("chat");
2208
+ applyAiConversationContextScope({ type: "none" });
2209
+ applyAiRoleplayCharacter(null);
2210
+ $("#ai-conversation-title").textContent = "新对话";
2211
+ resetAiFeed();
2212
+ hideAiContextWarning();
2213
+ resetAiContextMeter();
2214
+ setAiPromptText("");
2215
+ renderAiCitations();
2216
+ renderAiReferences();
2217
+ renderAiQuickActions();
2218
+ }
2219
+
2220
+ async function favoriteAiConversation(conversation) {
2221
+ const updated = await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}/favorite`, {
2222
+ method: "PATCH",
2223
+ body: { isFavorite: conversation.isFavorite !== true }
2224
+ });
2225
+ upsertAiConversationSummary(updated);
2226
+ toast(updated.isFavorite ? "对话已收藏" : "已取消收藏");
2227
+ }
2228
+
2229
+ async function deleteAiConversation(conversation) {
2230
+ if (conversation.isFavorite === true) {
2231
+ toast("收藏的对话不能清理,请先取消收藏", "error");
2232
+ return;
2233
+ }
2234
+ if (!await confirmToast(`清理“${conversation.title}”后,对话及其消息将无法恢复。确认继续吗?`, {
2235
+ title: "清理对话",
2236
+ confirmLabel: "确认清理"
2237
+ })) return;
2238
+ await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}`, { method: "DELETE" });
2239
+ state.aiConversations = state.aiConversations.filter((item) => item.id !== conversation.id);
2240
+ if (state.aiConversationId === conversation.id) resetAiConversationAfterDelete();
2241
+ renderAiConversationHistory();
2242
+ toast("对话已清理");
2243
+ }
2244
+
2245
+ const aiConversationTaskTypeLabels = {
2246
+ chat: "问答",
2247
+ roleplay: "角色扮演",
2248
+ continue: "续写",
2249
+ polish: "润色选中文本"
2250
+ };
2251
+
2252
+ function aiConversationTaskTypeLabel(taskType) {
2253
+ return aiConversationTaskTypeLabels[String(taskType)] ?? aiConversationTaskTypeLabels.chat;
2254
+ }
2255
+
2256
+ function aiConversationContextScopeLabel(scope) {
2257
+ const normalizedScope = scope && typeof scope === "object" ? scope : {};
2258
+ if (normalizedScope.type === "chapter" && normalizedScope.includeBookSummary === true) return "当前章节 + 全书概要";
2259
+ return {
2260
+ none: "无上下文",
2261
+ chapter: "当前章节",
2262
+ volume: "当前卷",
2263
+ book: "全书",
2264
+ "settings-catalog": "设定库"
2265
+ }[String(normalizedScope.type)] ?? "无上下文";
2266
+ }
2267
+
2268
+ function aiConversationHistoryMeta(conversation) {
2269
+ const parts = [
2270
+ `${Number(conversation.messageCount ?? 0)} 条`,
2271
+ aiConversationTaskTypeLabel(conversation.taskType),
2272
+ aiConversationContextScopeLabel(conversation.contextScope)
2273
+ ];
2274
+ if (conversation.roleplayCharacter?.name) parts.push(`扮演 ${conversation.roleplayCharacter.name}`);
2275
+ parts.push(formatDateTime(conversation.updatedAt));
2276
+ return parts.join(" · ");
2277
+ }
2278
+
2184
2279
  function renderAiConversationHistory() {
2185
2280
  closeAiHistoryActionMenu();
2186
2281
  const host = $("#ai-history-list");
@@ -2202,11 +2297,21 @@ function renderAiConversationHistory() {
2202
2297
  button.type = "button";
2203
2298
  button.className = `ai-history-item${conversation.id === state.aiConversationId ? " is-active" : ""}`;
2204
2299
  button.dataset.aiConversationId = conversation.id;
2300
+ const titleRow = document.createElement("span");
2301
+ titleRow.className = "ai-history-title-row";
2205
2302
  const title = document.createElement("strong");
2206
2303
  title.textContent = conversation.title;
2304
+ titleRow.append(title);
2305
+ if (conversation.isFavorite === true) {
2306
+ const favorite = document.createElement("small");
2307
+ favorite.className = "ai-history-favorite";
2308
+ favorite.textContent = "已收藏";
2309
+ favorite.setAttribute("aria-label", "已收藏");
2310
+ titleRow.append(favorite);
2311
+ }
2207
2312
  const meta = document.createElement("small");
2208
- meta.textContent = `${conversation.messageCount} 条${conversation.roleplayCharacter?.name ? ` · 扮演 ${conversation.roleplayCharacter.name}` : ""} · ${formatDateTime(conversation.updatedAt)}`;
2209
- button.append(title, meta);
2313
+ meta.textContent = aiConversationHistoryMeta(conversation);
2314
+ button.append(titleRow, meta);
2210
2315
  button.addEventListener("click", () => openAiConversation(conversation.id));
2211
2316
  const more = document.createElement("button");
2212
2317
  more.type = "button";
@@ -2419,12 +2524,37 @@ function renderAiRoleplayCharacterSelect() {
2419
2524
  const canSelectCharacter = Boolean(state.work)
2420
2525
  && canReadModule("characters")
2421
2526
  && canWritePermissionModule(state.work, "ai-chat");
2422
- select.disabled = aiInteractionBusy() || !canSelectCharacter || state.aiPromptSent;
2527
+ select.disabled = aiInteractionBusy() || !canSelectCharacter;
2423
2528
  select.title = canSelectCharacter
2424
- ? "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
2529
+ ? state.aiPromptSent
2530
+ ? aiConversationOptionLockedMessage
2531
+ : "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
2425
2532
  : "当前账户没有角色模块读取权限";
2426
2533
  }
2427
2534
 
2535
+ const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
2536
+
2537
+ function notifyAiConversationOptionLocked(select) {
2538
+ if (!state.aiPromptSent) return false;
2539
+ const now = Date.now();
2540
+ const lastToastAt = Number(select.dataset.lockedToastAt ?? 0);
2541
+ if (now - lastToastAt > 500) toast(aiConversationOptionLockedMessage);
2542
+ select.dataset.lockedToastAt = String(now);
2543
+ return true;
2544
+ }
2545
+
2546
+ function blockLockedAiConversationOptionInteraction(event) {
2547
+ const select = event.currentTarget;
2548
+ if (!notifyAiConversationOptionLocked(select)) return;
2549
+ event.preventDefault();
2550
+ event.stopPropagation();
2551
+ }
2552
+
2553
+ function blockLockedAiConversationOptionKeydown(event) {
2554
+ if (["Tab", "Shift", "Control", "Alt", "Meta"].includes(event.key)) return;
2555
+ blockLockedAiConversationOptionInteraction(event);
2556
+ }
2557
+
2428
2558
  function syncAiTaskOptions() {
2429
2559
  const roleplaySelected = $("#ai-task").value === "roleplay";
2430
2560
  const interactionBusy = aiInteractionBusy();
@@ -2432,14 +2562,14 @@ function syncAiTaskOptions() {
2432
2562
  $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
2433
2563
  $("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
2434
2564
  $("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
2435
- $("#ai-task").disabled = interactionBusy || state.aiPromptSent;
2436
- $("#ai-task").title = state.aiPromptSent ? "对话开始后不能切换任务类型" : "";
2437
- $("#ai-scope").disabled = interactionBusy || roleplaySelected || state.aiPromptSent;
2565
+ $("#ai-task").disabled = interactionBusy;
2566
+ $("#ai-task").title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
2567
+ $("#ai-scope").disabled = interactionBusy || roleplaySelected;
2438
2568
  $("#ai-scope").title = state.aiPromptSent
2439
- ? "对话开始后不能切换上下文引用"
2569
+ ? aiConversationOptionLockedMessage
2440
2570
  : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
2441
- $("#ai-model").disabled = interactionBusy || state.aiPromptSent;
2442
- $("#ai-model").title = state.aiPromptSent ? "对话开始后不能切换模型" : "";
2571
+ $("#ai-model").disabled = interactionBusy;
2572
+ $("#ai-model").title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
2443
2573
  }
2444
2574
 
2445
2575
  function applyAiConversationTaskType(taskType) {
@@ -11692,6 +11822,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
11692
11822
  const attachmentObserver = new MutationObserver(() => normalizeVditorAttachmentImages(editor));
11693
11823
  attachmentObserver.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ["src"] });
11694
11824
  editor.__attachmentObserver = attachmentObserver;
11825
+ editor.__plainTextPasteCleanup = bindPlainTextPaste(host);
11695
11826
  host.__vditor = editor;
11696
11827
  return editor;
11697
11828
  }
@@ -11883,6 +12014,7 @@ function ensureVditorIconScript() {
11883
12014
  function destroyVditorEditor(editor) {
11884
12015
  if (!editor) return;
11885
12016
  editor.__attachmentObserver?.disconnect();
12017
+ editor.__plainTextPasteCleanup?.();
11886
12018
  editor.__vditorLineNumberObserver?.disconnect();
11887
12019
  editor.__vditorLineNumberResizeObserver?.disconnect();
11888
12020
  if (editor.__vditorLineNumberSurface && editor.__vditorLineNumberScrollHandler) {
@@ -12955,7 +13087,12 @@ function openReviewDialog() {
12955
13087
  }
12956
13088
 
12957
13089
  async function openTaskDialog() {
12958
- const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
13090
+ const volumeOptions = state.work.volumes.map((volume) => ({ id: String(volume.id), title: String(volume.title) }));
13091
+ const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
13092
+ id: String(chapter.id),
13093
+ title: String(chapter.title),
13094
+ volumeTitle: String(volume.title)
13095
+ })));
12959
13096
  let relationshipCharacters = [];
12960
13097
  let taskModels = [];
12961
13098
  let taskDefaults = [];
@@ -12971,6 +13108,23 @@ async function openTaskDialog() {
12971
13108
  toast(`分析任务配置加载失败:${error.message}`, "error");
12972
13109
  return;
12973
13110
  }
13111
+ const taskScopePicker = (kind, label, options, emptyLabel) => `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13112
+ <span id="task-${kind}-label">${esc(label)}(可多选)</span>
13113
+ <div class="task-scope-picker">
13114
+ <button class="task-scope-trigger" type="button" data-task-scope-trigger="${kind}" aria-expanded="false" aria-controls="task-${kind}-bubble" aria-labelledby="task-${kind}-label task-${kind}-summary" disabled>
13115
+ <span id="task-${kind}-summary">${esc(emptyLabel)}</span>
13116
+ <span class="task-scope-trigger-meta"><span data-task-scope-count="${kind}">未选择</span><span class="task-scope-chevron" aria-hidden="true">⌄</span></span>
13117
+ </button>
13118
+ <div id="task-${kind}-bubble" class="task-scope-bubble hidden" data-task-scope-bubble="${kind}">
13119
+ <label class="task-scope-search">筛选${esc(label)}<input type="search" data-task-scope-search="${kind}" placeholder="输入名称" autocomplete="off"></label>
13120
+ <div class="task-scope-bubble-meta"><span>共 ${options.length} ${kind === "chapter" ? "章" : "卷"}</span><button type="button" data-task-scope-clear="${kind}" disabled>清空选择</button></div>
13121
+ <div class="task-scope-options" role="group" aria-labelledby="task-${kind}-label">
13122
+ ${options.map((item) => `<label class="task-scope-option" data-task-scope-search-name="${esc(`${item.title} ${item.volumeTitle ?? ""}`.toLocaleLowerCase())}"><input type="checkbox" name="${kind === "chapter" ? "chapterIds" : "volumeIds"}" value="${esc(item.id)}" data-task-scope-input="${kind}"><span><strong>${esc(item.title)}</strong>${item.volumeTitle ? `<small>${esc(item.volumeTitle)}</small>` : ""}</span></label>`).join("")}
13123
+ </div>
13124
+ <p class="task-scope-empty hidden" data-task-scope-empty="${kind}">没有匹配的${esc(label)}</p>
13125
+ </div>
13126
+ </div>
13127
+ </div>`;
12974
13128
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
12975
13129
  const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
12976
13130
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
@@ -12997,8 +13151,9 @@ async function openTaskDialog() {
12997
13151
  const modelField = `<label>任务模型<select name="modelId" required aria-describedby="analysis-task-model-help">
12998
13152
  <option value="" ${availableTaskModels.some((model) => model.id === defaultModelId) ? "" : "selected"} disabled>${availableTaskModels.length ? "请选择模型" : "没有可用模型"}</option>
12999
13153
  ${availableTaskModels.map((model) => `<option value="${esc(model.id)}" ${model.id === defaultModelId ? "selected" : ""}>${esc(modelOptionLabel(model))}</option>`).join("")}
13000
- </select><small id="analysis-task-model-help">默认值来自“本书 AI 设置”,只修改当前任务,不会改变全书默认模型。</small></label>`;
13001
- const chapterField = `<label class="task-chapter-field">章节<select name="chapterId">${chapterOptions.map(([key, text], index) => `<option value="${esc(key)}" ${index === 0 ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
13154
+ </select><small id="analysis-task-model-help">默认值来自“本书 AI 设置”,只修改当前任务,不会改变全书默认模型。</small><p class="analysis-context-warning hidden" data-analysis-context-warning role="alert"></p></label>`;
13155
+ const chapterField = taskScopePicker("chapter", "章节", chapterOptions, "选择需要分析的章节");
13156
+ const volumeField = taskScopePicker("volume", "分卷", volumeOptions, "选择需要分析的分卷");
13002
13157
  const relationshipFields = `<div class="relationship-analysis-options hidden">
13003
13158
  ${relationshipCharacterPicker}
13004
13159
  <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。默认仅追加不存在的关系,不修改或删除已有关系。</span></p>
@@ -13035,25 +13190,43 @@ async function openTaskDialog() {
13035
13190
  const preFilterRelationshipSources = characterIds.length > 0 && form.get("preFilterRelationshipSources") === "on";
13036
13191
  const previewRelationshipChanges = taskType === "relationship-analysis" && form.get("previewRelationshipChanges") === "on";
13037
13192
  const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
13193
+ const chapterIds = form.getAll("chapterIds").map(String).filter(Boolean);
13194
+ const volumeIds = form.getAll("volumeIds").map(String).filter(Boolean);
13195
+ const chapterScope = {
13196
+ type: "chapter",
13197
+ ...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
13198
+ ...(chapterIds.length ? { chapterIds } : {})
13199
+ };
13200
+ const volumeScope = {
13201
+ type: "volume",
13202
+ ...(volumeIds[0] ? { volumeId: volumeIds[0] } : {}),
13203
+ ...(volumeIds.length ? { volumeIds } : {})
13204
+ };
13038
13205
  const scope = settingsOnly
13039
13206
  ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13040
- : taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
13207
+ : scopeType === "book" || includeAllSettings
13041
13208
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13042
- : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13209
+ : { ...(scopeType === "volume" ? volumeScope : chapterScope), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13043
13210
  return scope;
13044
13211
  };
13045
13212
  const relationshipPreviewKey = (scope, modelId) => JSON.stringify({
13046
13213
  type: scope.type,
13047
13214
  chapterId: scope.chapterId ?? null,
13215
+ chapterIds: scope.chapterIds ?? [],
13216
+ volumeId: scope.volumeId ?? null,
13217
+ volumeIds: scope.volumeIds ?? [],
13048
13218
  includeAllSettings: scope.includeAllSettings === true,
13049
13219
  characterIds: scope.characterIds ?? [],
13050
13220
  preFilterRelationshipSources: scope.preFilterRelationshipSources !== false,
13051
13221
  modelId
13052
13222
  });
13053
- openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
13223
+ openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["volume", "指定分卷"], ["book", "全书"]]) + chapterField + volumeField + relationshipFields, async (form) => {
13224
+ const workId = state.work.id;
13054
13225
  const taskType = String(form.get("taskType"));
13055
13226
  const modelId = String(form.get("modelId"));
13056
13227
  const scope = buildRelationshipScope(form);
13228
+ if (scope.type === "chapter" && !scope.chapterIds?.length) return toast("请先选择至少一个章节", "error");
13229
+ if (scope.type === "volume" && !scope.volumeIds?.length) return toast("请先选择至少一个分卷", "error");
13057
13230
  if (taskType === "relationship-analysis" && relationshipSourcePreview
13058
13231
  && relationshipSourcePreviewConfigKey === relationshipPreviewKey(scope, modelId)) {
13059
13232
  scope.relationshipSourceRefs = [...$("#dialog-fields").querySelectorAll("[data-relationship-source-selected]:checked")]
@@ -13063,15 +13236,19 @@ async function openTaskDialog() {
13063
13236
  sourceVersion: input.dataset.sourceVersion
13064
13237
  }));
13065
13238
  }
13066
- await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope, modelId } });
13067
- await refreshBackgroundTaskCenter({ announce: false });
13068
- taskListPage = 1;
13239
+ clearContextWarning();
13069
13240
  try {
13070
- await renderTasks(1);
13071
- toast("分析任务已创建,已进入任务队列");
13241
+ await api(`/api/works/${workId}/tasks`, { method: "POST", body: { taskType, scope, modelId } });
13072
13242
  } catch (error) {
13073
- toast(`任务已创建,但列表刷新失败:${error.message}`, "error");
13243
+ if (error.code === "AI_CONTEXT_TOO_LARGE") {
13244
+ contextWarning.textContent = error.message || "当前分析范围超过模型上下文阈值,请切换到上下文更长的模型后重试。";
13245
+ contextWarning.classList.remove("hidden");
13246
+ }
13247
+ throw error;
13074
13248
  }
13249
+ taskListPage = 1;
13250
+ toast("分析任务已创建,已进入任务队列");
13251
+ void refreshAnalysisTaskViewsAfterCreate(workId);
13075
13252
  }, "AI 分析", {
13076
13253
  submitLabel: "创建任务",
13077
13254
  pendingLabel: "创建中…",
@@ -13081,8 +13258,42 @@ async function openTaskDialog() {
13081
13258
  const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
13082
13259
  const taskModelSelect = $("#dialog-fields").querySelector('select[name="modelId"]');
13083
13260
  const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
13084
- const chapterSelect = $("#dialog-fields").querySelector('select[name="chapterId"]');
13085
- const chapterFieldElement = chapterSelect.closest(".task-chapter-field");
13261
+ const scopeFields = {
13262
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-field="chapter"]'),
13263
+ volume: $("#dialog-fields").querySelector('[data-task-scope-field="volume"]')
13264
+ };
13265
+ const scopeTriggers = {
13266
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-trigger="chapter"]'),
13267
+ volume: $("#dialog-fields").querySelector('[data-task-scope-trigger="volume"]')
13268
+ };
13269
+ const scopeBubbles = {
13270
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-bubble="chapter"]'),
13271
+ volume: $("#dialog-fields").querySelector('[data-task-scope-bubble="volume"]')
13272
+ };
13273
+ const scopeSearches = {
13274
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-search="chapter"]'),
13275
+ volume: $("#dialog-fields").querySelector('[data-task-scope-search="volume"]')
13276
+ };
13277
+ const scopeInputs = {
13278
+ chapter: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="chapter"]')],
13279
+ volume: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="volume"]')]
13280
+ };
13281
+ const scopeSummaries = {
13282
+ chapter: $("#dialog-fields").querySelector("#task-chapter-summary"),
13283
+ volume: $("#dialog-fields").querySelector("#task-volume-summary")
13284
+ };
13285
+ const scopeCounts = {
13286
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-count="chapter"]'),
13287
+ volume: $("#dialog-fields").querySelector('[data-task-scope-count="volume"]')
13288
+ };
13289
+ const scopeClearButtons = {
13290
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-clear="chapter"]'),
13291
+ volume: $("#dialog-fields").querySelector('[data-task-scope-clear="volume"]')
13292
+ };
13293
+ const scopeEmptyMessages = {
13294
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-empty="chapter"]'),
13295
+ volume: $("#dialog-fields").querySelector('[data-task-scope-empty="volume"]')
13296
+ };
13086
13297
  const description = $("#analysis-type-description");
13087
13298
  const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
13088
13299
  const relationshipPrompt = relationshipOptions.querySelector('textarea[name="additionalPrompt"]');
@@ -13100,17 +13311,57 @@ async function openTaskDialog() {
13100
13311
  const relationshipSourcePreviewContent = relationshipOptions.querySelector("[data-relationship-source-preview-content]");
13101
13312
  const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
13102
13313
  const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-existing-overwrite-card");
13314
+ const contextWarning = $("#dialog-fields").querySelector("[data-analysis-context-warning]");
13315
+ const clearContextWarning = () => {
13316
+ contextWarning.textContent = "";
13317
+ contextWarning.classList.add("hidden");
13318
+ };
13103
13319
  const allSettingsOption = document.createElement("option");
13104
13320
  allSettingsOption.value = "book-with-settings";
13105
13321
  allSettingsOption.textContent = "全书 + 设定集";
13106
13322
  const settingsOnlyOption = document.createElement("option");
13107
13323
  settingsOnlyOption.value = "settings";
13108
13324
  settingsOnlyOption.textContent = "仅设定集";
13325
+ const setTaskScopeBubbleOpen = (kind, open) => {
13326
+ scopeBubbles[kind].classList.toggle("hidden", !open);
13327
+ scopeTriggers[kind].setAttribute("aria-expanded", String(open));
13328
+ if (open) scopeSearches[kind].focus();
13329
+ };
13330
+ const syncTaskScopePicker = (kind) => {
13331
+ const selected = scopeInputs[kind].filter((input) => input.checked);
13332
+ const selectedNames = selected.map((input) => input.closest(".task-scope-option")?.querySelector("strong")?.textContent ?? "");
13333
+ const label = kind === "chapter" ? "选择需要分析的章节" : "选择需要分析的分卷";
13334
+ scopeSummaries[kind].textContent = selectedNames.length
13335
+ ? `${selectedNames.slice(0, 2).join("、")}${selectedNames.length > 2 ? ` 等 ${selectedNames.length} 个` : ""}`
13336
+ : label;
13337
+ scopeCounts[kind].textContent = selected.length ? `已选 ${selected.length}` : "未选择";
13338
+ scopeClearButtons[kind].disabled = selected.length === 0;
13339
+ scopeTriggers[kind].setAttribute("aria-label", `筛选${kind === "chapter" ? "章节" : "分卷"},已选择 ${selected.length} 个`);
13340
+ };
13341
+ const filterTaskScopeOptions = (kind) => {
13342
+ const query = scopeSearches[kind].value.trim().toLocaleLowerCase();
13343
+ let visibleCount = 0;
13344
+ for (const input of scopeInputs[kind]) {
13345
+ const option = input.closest(".task-scope-option");
13346
+ const visible = !query || option.dataset.taskScopeSearchName.includes(query);
13347
+ option.classList.toggle("hidden", !visible);
13348
+ if (visible) visibleCount += 1;
13349
+ }
13350
+ scopeEmptyMessages[kind].classList.toggle("hidden", visibleCount > 0);
13351
+ };
13109
13352
  const syncChapterField = () => {
13110
- const disabled = scopeTypeSelect.value !== "chapter";
13111
- chapterSelect.disabled = disabled;
13112
- chapterFieldElement.classList.toggle("is-disabled", disabled);
13113
- chapterFieldElement.setAttribute("aria-disabled", String(disabled));
13353
+ const activeKind = ["chapter", "volume"].includes(scopeTypeSelect.value) ? scopeTypeSelect.value : null;
13354
+ for (const kind of ["chapter", "volume"]) {
13355
+ const enabled = kind === activeKind;
13356
+ scopeFields[kind].classList.toggle("is-disabled", !enabled);
13357
+ scopeFields[kind].classList.toggle("hidden", !enabled);
13358
+ scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
13359
+ scopeTriggers[kind].disabled = !enabled;
13360
+ for (const input of scopeInputs[kind]) input.disabled = !enabled;
13361
+ if (!enabled) setTaskScopeBubbleOpen(kind, false);
13362
+ syncTaskScopePicker(kind);
13363
+ filterTaskScopeOptions(kind);
13364
+ }
13114
13365
  };
13115
13366
  const setRelationshipCharacterBubbleOpen = (open) => {
13116
13367
  relationshipCharacterBubble.classList.toggle("hidden", !open);
@@ -13208,10 +13459,12 @@ async function openTaskDialog() {
13208
13459
  taskTypeSelect.addEventListener("change", () => {
13209
13460
  description.textContent = analysisTypeDescription(taskTypeSelect.value);
13210
13461
  syncTaskModelDefault();
13462
+ clearContextWarning();
13211
13463
  invalidateRelationshipSourcePreview();
13212
13464
  syncRelationshipOptions();
13213
13465
  });
13214
13466
  taskModelSelect.addEventListener("change", () => {
13467
+ clearContextWarning();
13215
13468
  invalidateRelationshipSourcePreview();
13216
13469
  syncRelationshipOptions();
13217
13470
  });
@@ -13258,11 +13511,39 @@ async function openTaskDialog() {
13258
13511
  syncRelationshipOptions();
13259
13512
  }
13260
13513
  });
13514
+ for (const kind of ["chapter", "volume"]) {
13515
+ scopeTriggers[kind].addEventListener("click", () => {
13516
+ setTaskScopeBubbleOpen(kind, scopeTriggers[kind].getAttribute("aria-expanded") !== "true");
13517
+ });
13518
+ scopeSearches[kind].addEventListener("input", () => filterTaskScopeOptions(kind));
13519
+ for (const input of scopeInputs[kind]) input.addEventListener("change", () => {
13520
+ syncTaskScopePicker(kind);
13521
+ clearContextWarning();
13522
+ invalidateRelationshipSourcePreview();
13523
+ });
13524
+ scopeClearButtons[kind].addEventListener("click", () => {
13525
+ for (const input of scopeInputs[kind]) input.checked = false;
13526
+ syncTaskScopePicker(kind);
13527
+ clearContextWarning();
13528
+ invalidateRelationshipSourcePreview();
13529
+ });
13530
+ }
13261
13531
  $("#dynamic-form").onclick = (event) => {
13262
13532
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
13533
+ for (const kind of ["chapter", "volume"]) {
13534
+ if (!scopeFields[kind].contains(event.target)) setTaskScopeBubbleOpen(kind, false);
13535
+ }
13263
13536
  };
13264
13537
  $("#dynamic-form").onkeydown = (event) => {
13265
- if (event.key !== "Escape" || relationshipCharacterBubble.classList.contains("hidden")) return;
13538
+ if (event.key !== "Escape") return;
13539
+ const openScope = ["chapter", "volume"].find((kind) => !scopeBubbles[kind].classList.contains("hidden"));
13540
+ if (openScope) {
13541
+ event.preventDefault();
13542
+ setTaskScopeBubbleOpen(openScope, false);
13543
+ scopeTriggers[openScope].focus();
13544
+ return;
13545
+ }
13546
+ if (relationshipCharacterBubble.classList.contains("hidden")) return;
13266
13547
  event.preventDefault();
13267
13548
  setRelationshipCharacterBubbleOpen(false);
13268
13549
  relationshipCharacterTrigger.focus();
@@ -13271,10 +13552,23 @@ async function openTaskDialog() {
13271
13552
  invalidateRelationshipSourcePreview();
13272
13553
  syncChapterField();
13273
13554
  });
13274
- chapterSelect.addEventListener("change", invalidateRelationshipSourcePreview);
13275
13555
  syncRelationshipOptions();
13276
13556
  }
13277
13557
 
13558
+ async function refreshAnalysisTaskViewsAfterCreate(workId) {
13559
+ try {
13560
+ await Promise.all([
13561
+ refreshBackgroundTaskCenter({ announce: false }),
13562
+ state.module === "tasks" && state.work?.id === workId
13563
+ ? renderTasks(1, { refresh: true })
13564
+ : Promise.resolve()
13565
+ ]);
13566
+ } catch (error) {
13567
+ console.error("Failed to refresh analysis task views after creation", error);
13568
+ toast(`任务已创建,但列表刷新失败:${error.message}`, "error");
13569
+ }
13570
+ }
13571
+
13278
13572
  function openProviderDialog(item) {
13279
13573
  const protocol = item?.protocol ?? "openai-chat-completions";
13280
13574
  const providerProtocolOptions = [
@@ -15476,6 +15770,7 @@ $("#module-nav").addEventListener("click", (event) => {
15476
15770
  });
15477
15771
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
15478
15772
  $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
15773
+ bindPlainTextPaste($("#ai-prompt"));
15479
15774
  $("#ai-prompt").addEventListener("input", async () => {
15480
15775
  updateAiMentionMenu();
15481
15776
  setAiContextMeter(null);
@@ -15490,13 +15785,18 @@ $("#ai-prompt").addEventListener("input", async () => {
15490
15785
  $("#ai-prompt").addEventListener("focus", () => {
15491
15786
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
15492
15787
  });
15788
+ for (const select of [$("#ai-task"), $("#ai-scope"), $("#ai-roleplay-character"), $("#ai-model")]) {
15789
+ select.addEventListener("pointerdown", blockLockedAiConversationOptionInteraction);
15790
+ select.addEventListener("click", blockLockedAiConversationOptionInteraction);
15791
+ select.addEventListener("keydown", blockLockedAiConversationOptionKeydown);
15792
+ }
15493
15793
  $("#ai-model").addEventListener("focus", () => {
15494
15794
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
15495
15795
  });
15496
15796
  $("#ai-model").addEventListener("change", (event) => {
15497
15797
  if (state.aiPromptSent) {
15498
15798
  event.currentTarget.value = state.aiConversationModelId ?? event.currentTarget.value;
15499
- return toast("当前对话已经开始,请新建对话后再切换模型", "error");
15799
+ return toast(aiConversationOptionLockedMessage);
15500
15800
  }
15501
15801
  setAiContextMeter(null);
15502
15802
  });
@@ -15510,6 +15810,10 @@ $("#ai-roleplay-character").addEventListener("focus", async () => {
15510
15810
  });
15511
15811
  $("#ai-roleplay-character").addEventListener("change", async (event) => {
15512
15812
  const select = event.currentTarget;
15813
+ if (state.aiPromptSent) {
15814
+ renderAiRoleplayCharacterSelect();
15815
+ return toast(aiConversationOptionLockedMessage);
15816
+ }
15513
15817
  const characterId = select.value;
15514
15818
  select.disabled = true;
15515
15819
  try {
@@ -15527,7 +15831,7 @@ $("#ai-task").addEventListener("change", async (event) => {
15527
15831
  const nextTaskType = select.value;
15528
15832
  if (state.aiPromptSent) {
15529
15833
  applyAiConversationTaskType(previousTaskType);
15530
- return toast("当前对话已经开始,请新建对话后再切换任务类型", "error");
15834
+ return toast(aiConversationOptionLockedMessage);
15531
15835
  }
15532
15836
  applyAiConversationTaskType(nextTaskType);
15533
15837
  if (state.aiConversationId) {
@@ -15547,7 +15851,7 @@ $("#ai-task").addEventListener("change", async (event) => {
15547
15851
  $("#ai-scope").addEventListener("change", (event) => {
15548
15852
  if (state.aiPromptSent) {
15549
15853
  applyAiConversationContextScope(state.aiContextScope);
15550
- return toast("当前对话已经开始,请新建对话后再切换上下文引用", "error");
15854
+ return toast(aiConversationOptionLockedMessage);
15551
15855
  }
15552
15856
  event.currentTarget.title = "";
15553
15857
  setAiContextMeter(null);
@@ -15860,9 +16164,37 @@ $("#ai-history-dialog").addEventListener("cancel", (event) => {
15860
16164
  closeAiHistoryActionMenu(true);
15861
16165
  });
15862
16166
  $("#ai-history-action-menu").addEventListener("click", async (event) => {
15863
- const option = event.target.closest("[data-ai-history-action=\"export\"]");
16167
+ const option = event.target.closest("[data-ai-history-action]");
15864
16168
  const conversation = aiHistoryActionConversation;
15865
16169
  if (!option || !conversation || option.disabled) return;
16170
+ const action = option.dataset.aiHistoryAction;
16171
+ if (action === "favorite") {
16172
+ option.disabled = true;
16173
+ try {
16174
+ await favoriteAiConversation(conversation);
16175
+ closeAiHistoryActionMenu(true);
16176
+ } catch (error) {
16177
+ toast(`对话收藏状态更新失败:${error.message}`, "error");
16178
+ option.focus();
16179
+ } finally {
16180
+ option.disabled = false;
16181
+ }
16182
+ return;
16183
+ }
16184
+ if (action === "delete") {
16185
+ option.disabled = true;
16186
+ try {
16187
+ await deleteAiConversation(conversation);
16188
+ closeAiHistoryActionMenu(true);
16189
+ } catch (error) {
16190
+ toast(`对话清理失败:${error.message}`, "error");
16191
+ option.focus();
16192
+ } finally {
16193
+ option.disabled = false;
16194
+ }
16195
+ return;
16196
+ }
16197
+ if (action !== "export") return;
15866
16198
  const label = option.querySelector("span");
15867
16199
  option.disabled = true;
15868
16200
  if (label) label.textContent = "下载中";