@musnows/scriverse 0.4.11 → 0.4.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.
@@ -1,7 +1,12 @@
1
- export function formatAiMessageMeta(modelDisplayName, outputTokens, suffix = "") {
1
+ export function formatAiMessageMeta(modelDisplayName, outputTokens, cacheHitPercent, suffix = "") {
2
2
  const modelName = String(modelDisplayName || "模型").trim();
3
3
  const tokenCount = Math.max(0, Math.round(Number(outputTokens) || 0)).toLocaleString("zh-CN");
4
- return [modelName, `${tokenCount} tok`, String(suffix || "").trim()].filter(Boolean).join(" · ");
4
+ const cachePercent = Number(cacheHitPercent);
5
+ const roundedCachePercent = Math.round((cachePercent + Number.EPSILON) * 10) / 10;
6
+ const cacheLabel = Number.isFinite(cachePercent)
7
+ ? `缓存命中 ${Math.max(0, Math.min(100, roundedCachePercent)).toLocaleString("zh-CN")}%`
8
+ : "";
9
+ return [modelName, `${tokenCount} tok`, cacheLabel, String(suffix || "").trim()].filter(Boolean).join(" · ");
5
10
  }
6
11
 
7
12
  export function estimateAiMessageTokens(value) {
@@ -6,7 +6,7 @@ import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-a
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
7
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
8
8
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
9
- import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260713-persisted-output-tokens";
9
+ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
10
10
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
11
11
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
12
12
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
@@ -104,6 +104,8 @@ let peerPageStale = false;
104
104
  let collaborationAutoSaveDisabled = false;
105
105
 
106
106
  let timelineMultiSelectEnabled = false;
107
+ let taskProgressRefreshTimer = null;
108
+ const taskProgressRefreshInterval = 2_500;
107
109
 
108
110
  const chapterTypes = ["正文", "设定", "作者的话", "其他"];
109
111
 
@@ -2989,6 +2991,7 @@ async function showModule(module) {
2989
2991
  if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
2990
2992
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2991
2993
  state.module = module;
2994
+ if (module !== "tasks") stopTaskProgressRefresh();
2992
2995
  applyWorkAccessMode();
2993
2996
  markActiveModule(module);
2994
2997
  if (module === "editor") {
@@ -3868,6 +3871,7 @@ async function renderReviews() {
3868
3871
  }
3869
3872
 
3870
3873
  async function renderTasks() {
3874
+ stopTaskProgressRefresh();
3871
3875
  const [tasks, settings] = await Promise.all([
3872
3876
  apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
3873
3877
  canReadModule("ai-settings")
@@ -3877,7 +3881,12 @@ async function renderTasks() {
3877
3881
  mountModuleCount(tasks.length);
3878
3882
  const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
3879
3883
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
3880
- const runningCount = tasks.filter((item) => item.status === "running").length;
3884
+ const runningTasks = tasks.filter((item) => item.status === "running");
3885
+ const runningCount = runningTasks.length;
3886
+ const activeTaskCount = pendingCount + runningCount;
3887
+ const runningProgress = runningCount
3888
+ ? Math.round(runningTasks.reduce((total, item) => total + Math.min(100, Math.max(0, Number(item.progress) || 0)), 0) / runningCount)
3889
+ : 0;
3881
3890
  $("#module-content").innerHTML = `
3882
3891
  <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
3883
3892
  <div class="task-auto-run-copy">
@@ -3893,6 +3902,10 @@ async function renderTasks() {
3893
3902
  <button id="task-auto-run-continue" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>开始下一轮</button>
3894
3903
  </div>
3895
3904
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个</p>
3905
+ <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
3906
+ <div class="task-auto-run-progress-label"><span>${runningCount ? "运行中任务平均进度" : "等待任务开始"}</span><strong>${runningProgress}%</strong></div>
3907
+ <progress class="task-auto-run-progress-bar" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
3908
+ </div>
3896
3909
  </section>
3897
3910
  ${tasks.length ? `<table class="table-list task-table"><thead><tr><th>分析类型</th><th>范围</th><th>状态</th><th>进度</th><th>操作</th></tr></thead><tbody>${tasks.map((item) => `
3898
3911
  <tr>
@@ -3956,6 +3969,7 @@ async function renderTasks() {
3956
3969
  button.textContent = "运行中";
3957
3970
  const cancel = button.parentElement.querySelector("[data-cancel-task]");
3958
3971
  if (cancel) cancel.textContent = "取消运行";
3972
+ scheduleTaskProgressRefresh(workId, 1);
3959
3973
  const completed = await api(`/api/tasks/${button.dataset.runTask}/run`, { method: "POST", body: { modelId: $("#ai-model").value || undefined } });
3960
3974
  toast(completed.status === "cancelled" ? "分析任务已取消" : completed.status === "expired" ? "正文已变化,本次分析已过期" : "分析已完成");
3961
3975
  if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
@@ -3975,6 +3989,32 @@ async function renderTasks() {
3975
3989
  button.disabled = false;
3976
3990
  }
3977
3991
  }));
3992
+ scheduleTaskProgressRefresh(state.work.id, runningCount);
3993
+ }
3994
+
3995
+ function stopTaskProgressRefresh() {
3996
+ if (taskProgressRefreshTimer === null) return;
3997
+ window.clearTimeout(taskProgressRefreshTimer);
3998
+ taskProgressRefreshTimer = null;
3999
+ }
4000
+
4001
+ function scheduleTaskProgressRefresh(workId, runningCount) {
4002
+ stopTaskProgressRefresh();
4003
+ if (runningCount === 0) return;
4004
+ taskProgressRefreshTimer = window.setTimeout(async () => {
4005
+ taskProgressRefreshTimer = null;
4006
+ if (state.module !== "tasks" || state.work?.id !== workId) return;
4007
+ if ($(".task-auto-run-controls")?.contains(document.activeElement)) {
4008
+ scheduleTaskProgressRefresh(workId, runningCount);
4009
+ return;
4010
+ }
4011
+ try {
4012
+ await renderTasks();
4013
+ } catch (error) {
4014
+ console.error("Failed to refresh task progress", error);
4015
+ scheduleTaskProgressRefresh(workId, runningCount);
4016
+ }
4017
+ }, taskProgressRefreshInterval);
3978
4018
  }
3979
4019
 
3980
4020
  function openTaskDetailDialog(task) {
@@ -4530,6 +4570,8 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
4530
4570
  bindRelationshipKeywordControls($("#dialog-fields"));
4531
4571
  bindVditorEditors($("#dialog-fields"));
4532
4572
  const form = $("#dynamic-form");
4573
+ form.onclick = null;
4574
+ form.onkeydown = null;
4533
4575
  form.onsubmit = async (event) => {
4534
4576
  if (event.submitter?.value === "cancel") {
4535
4577
  void discardPendingMarkdownAttachments();
@@ -5922,14 +5964,58 @@ function openReviewDialog() {
5922
5964
  });
5923
5965
  }
5924
5966
 
5925
- function openTaskDialog() {
5967
+ async function openTaskDialog() {
5926
5968
  const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
5969
+ let relationshipCharacters = [];
5970
+ try {
5971
+ relationshipCharacters = canReadModule("characters")
5972
+ ? await apiAllPages(`/api/works/${state.work.id}/characters`)
5973
+ : [];
5974
+ } catch (error) {
5975
+ toast(`角色列表加载失败:${error.message}`, "error");
5976
+ return;
5977
+ }
5978
+ const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
5979
+ const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
5980
+ <span id="relationship-character-label">被分析角色(可多选)</span>
5981
+ <div class="relationship-character-picker">
5982
+ <button class="relationship-character-trigger" type="button" aria-expanded="false" aria-controls="relationship-character-bubble" aria-labelledby="relationship-character-label relationship-character-summary">
5983
+ <span id="relationship-character-summary">选择需要定向分析的角色</span>
5984
+ <span class="relationship-character-trigger-meta"><span data-relationship-character-count>未选择</span><span class="relationship-character-chevron" aria-hidden="true">⌄</span></span>
5985
+ </button>
5986
+ <div id="relationship-character-bubble" class="relationship-character-bubble hidden">
5987
+ <label class="relationship-character-search">筛选角色<input type="search" data-relationship-character-search placeholder="输入角色名" autocomplete="off"></label>
5988
+ <div class="relationship-character-bubble-meta"><span>共 ${characterOptions.length} 个角色</span><button type="button" data-relationship-character-clear disabled>清空选择</button></div>
5989
+ <div class="relationship-character-options" role="group" aria-labelledby="relationship-character-label">
5990
+ ${characterOptions.map(([characterId, characterName]) => `<label class="relationship-character-chip" data-character-search-name="${esc(String(characterName).toLocaleLowerCase())}"><input type="checkbox" name="characterIds" value="${esc(characterId)}" data-character-name="${esc(characterName)}"><span>${esc(characterName)}</span></label>`).join("")}
5991
+ </div>
5992
+ <p class="relationship-character-empty hidden" data-relationship-character-empty>没有匹配的角色</p>
5993
+ </div>
5994
+ </div>
5995
+ </div>`;
5927
5996
  const defaultTaskType = ANALYSIS_TYPES[0].value;
5928
5997
  const taskTypeField = `<div class="form-field analysis-type-field"><label>分析类型<select name="taskType" aria-describedby="analysis-type-description">${ANALYSIS_TYPES.map(({ value, label }) => `<option value="${esc(value)}" ${value === defaultTaskType ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label><p id="analysis-type-description" class="analysis-type-description" aria-live="polite">${esc(analysisTypeDescription(defaultTaskType))}</p></div>`;
5929
5998
  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>`;
5930
- openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField, async (form) => {
5931
- const scope = form.get("taskType") === "character-identity-audit" || form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
5932
- await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
5999
+ const relationshipFields = `<div class="relationship-analysis-options hidden">
6000
+ ${relationshipCharacterPicker}
6001
+ <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。</span></p>
6002
+ <div class="relationship-overwrite-card hidden">
6003
+ <label class="checkbox-field"><input name="replaceExistingRelationships" type="checkbox" disabled><span>用本次结果覆盖所选角色的已有关系</span></label>
6004
+ <p>任务成功后,会先删除所有涉及所选角色的旧关系,再写入本次分析结果。</p>
6005
+ </div>
6006
+ <label>额外分析提示<textarea name="additionalPrompt" maxlength="10000" placeholder="例如:重点识别权力继承、师承变化或隐秘亲缘关系"></textarea><small>将同时追加到证据收集和全局关系归纳提示词,仅影响本次任务。</small></label>
6007
+ </div>`;
6008
+ openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
6009
+ const taskType = String(form.get("taskType"));
6010
+ const scopeType = String(form.get("scopeType"));
6011
+ const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
6012
+ const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
6013
+ const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
6014
+ const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
6015
+ const scope = taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
6016
+ ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6017
+ : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
6018
+ await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope } });
5933
6019
  await renderTasks();
5934
6020
  });
5935
6021
  const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
@@ -5937,17 +6023,99 @@ function openTaskDialog() {
5937
6023
  const chapterSelect = $("#dialog-fields").querySelector('select[name="chapterId"]');
5938
6024
  const chapterFieldElement = chapterSelect.closest(".task-chapter-field");
5939
6025
  const description = $("#analysis-type-description");
6026
+ const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
6027
+ const relationshipPrompt = relationshipOptions.querySelector('textarea[name="additionalPrompt"]');
6028
+ const relationshipCharacterPickerElement = relationshipOptions.querySelector(".relationship-character-picker");
6029
+ const relationshipCharacterTrigger = relationshipOptions.querySelector(".relationship-character-trigger");
6030
+ const relationshipCharacterBubble = relationshipOptions.querySelector(".relationship-character-bubble");
6031
+ const relationshipCharacterSearch = relationshipOptions.querySelector("[data-relationship-character-search]");
6032
+ const relationshipCharacterInputs = [...relationshipOptions.querySelectorAll('input[name="characterIds"]')];
6033
+ const relationshipCharacterSummary = relationshipOptions.querySelector("#relationship-character-summary");
6034
+ const relationshipCharacterCount = relationshipOptions.querySelector("[data-relationship-character-count]");
6035
+ const relationshipCharacterClear = relationshipOptions.querySelector("[data-relationship-character-clear]");
6036
+ const relationshipCharacterEmpty = relationshipOptions.querySelector("[data-relationship-character-empty]");
6037
+ const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
6038
+ const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-overwrite-card");
6039
+ const allSettingsOption = document.createElement("option");
6040
+ allSettingsOption.value = "book-with-settings";
6041
+ allSettingsOption.textContent = "全书 + 所有设定";
5940
6042
  const syncChapterField = () => {
5941
- const disabled = scopeTypeSelect.value === "book";
6043
+ const disabled = scopeTypeSelect.value !== "chapter";
5942
6044
  chapterSelect.disabled = disabled;
5943
6045
  chapterFieldElement.classList.toggle("is-disabled", disabled);
5944
6046
  chapterFieldElement.setAttribute("aria-disabled", String(disabled));
5945
6047
  };
6048
+ const setRelationshipCharacterBubbleOpen = (open) => {
6049
+ relationshipCharacterBubble.classList.toggle("hidden", !open);
6050
+ relationshipCharacterTrigger.setAttribute("aria-expanded", String(open));
6051
+ if (open) relationshipCharacterSearch.focus();
6052
+ };
6053
+ const syncRelationshipCharacterPicker = () => {
6054
+ const selected = relationshipCharacterInputs.filter((input) => input.checked);
6055
+ const selectedNames = selected.map((input) => input.dataset.characterName);
6056
+ relationshipCharacterSummary.textContent = selectedNames.length
6057
+ ? `${selectedNames.slice(0, 2).join("、")}${selectedNames.length > 2 ? ` 等 ${selectedNames.length} 人` : ""}`
6058
+ : "选择需要定向分析的角色";
6059
+ relationshipCharacterCount.textContent = selected.length ? `已选 ${selected.length}` : "未选择";
6060
+ relationshipCharacterClear.disabled = selected.length === 0;
6061
+ relationshipCharacterTrigger.setAttribute("aria-label", `筛选被分析角色,已选择 ${selected.length} 个`);
6062
+ };
6063
+ const filterRelationshipCharacters = () => {
6064
+ const query = relationshipCharacterSearch.value.trim().toLocaleLowerCase();
6065
+ let visibleCount = 0;
6066
+ for (const input of relationshipCharacterInputs) {
6067
+ const chip = input.closest(".relationship-character-chip");
6068
+ const visible = !query || chip.dataset.characterSearchName.includes(query);
6069
+ chip.classList.toggle("hidden", !visible);
6070
+ if (visible) visibleCount += 1;
6071
+ }
6072
+ relationshipCharacterEmpty.classList.toggle("hidden", visibleCount > 0);
6073
+ };
6074
+ const syncRelationshipOptions = () => {
6075
+ const enabled = taskTypeSelect.value === "relationship-analysis";
6076
+ if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
6077
+ if (!enabled && allSettingsOption.isConnected) {
6078
+ if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
6079
+ allSettingsOption.remove();
6080
+ }
6081
+ relationshipOptions.classList.toggle("hidden", !enabled);
6082
+ relationshipPrompt.disabled = !enabled;
6083
+ relationshipCharacterTrigger.disabled = !enabled;
6084
+ relationshipCharacterSearch.disabled = !enabled;
6085
+ for (const input of relationshipCharacterInputs) input.disabled = !enabled;
6086
+ if (!enabled) setRelationshipCharacterBubbleOpen(false);
6087
+ const hasSelectedCharacters = enabled && relationshipCharacterInputs.some((input) => input.checked);
6088
+ replaceRelationships.disabled = !hasSelectedCharacters;
6089
+ relationshipOverwriteCard.classList.toggle("hidden", !hasSelectedCharacters);
6090
+ if (!hasSelectedCharacters) replaceRelationships.checked = false;
6091
+ syncRelationshipCharacterPicker();
6092
+ filterRelationshipCharacters();
6093
+ syncChapterField();
6094
+ };
5946
6095
  taskTypeSelect.addEventListener("change", () => {
5947
6096
  description.textContent = analysisTypeDescription(taskTypeSelect.value);
6097
+ syncRelationshipOptions();
6098
+ });
6099
+ relationshipCharacterTrigger.addEventListener("click", () => {
6100
+ setRelationshipCharacterBubbleOpen(relationshipCharacterTrigger.getAttribute("aria-expanded") !== "true");
5948
6101
  });
6102
+ relationshipCharacterSearch.addEventListener("input", filterRelationshipCharacters);
6103
+ for (const input of relationshipCharacterInputs) input.addEventListener("change", syncRelationshipOptions);
6104
+ relationshipCharacterClear.addEventListener("click", () => {
6105
+ for (const input of relationshipCharacterInputs) input.checked = false;
6106
+ syncRelationshipOptions();
6107
+ });
6108
+ $("#dynamic-form").onclick = (event) => {
6109
+ if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
6110
+ };
6111
+ $("#dynamic-form").onkeydown = (event) => {
6112
+ if (event.key !== "Escape" || relationshipCharacterBubble.classList.contains("hidden")) return;
6113
+ event.preventDefault();
6114
+ setRelationshipCharacterBubbleOpen(false);
6115
+ relationshipCharacterTrigger.focus();
6116
+ };
5949
6117
  scopeTypeSelect.addEventListener("change", syncChapterField);
5950
- syncChapterField();
6118
+ syncRelationshipOptions();
5951
6119
  }
5952
6120
 
5953
6121
  function openProviderDialog(item) {
@@ -6038,7 +6206,7 @@ async function sendAi() {
6038
6206
  } else {
6039
6207
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
6040
6208
  assistantContent = suggestion.content;
6041
- assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens };
6209
+ assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
6042
6210
  }
6043
6211
  try {
6044
6212
  const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
@@ -6134,9 +6302,9 @@ async function streamChat(body) {
6134
6302
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
6135
6303
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
6136
6304
  const processDurationMs = elapsedProcessTime();
6137
- generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, toolCalls, processSteps, processDurationMs };
6305
+ generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, cacheHitPercent: payload.cacheHitPercent, toolCalls, processSteps, processDurationMs };
6138
6306
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
6139
- meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens);
6307
+ meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent);
6140
6308
  attachAssistantCopyAction(message, streamedText);
6141
6309
  scrollAiFeedToBottom();
6142
6310
  } else if (eventName === "error") {
@@ -6191,7 +6359,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
6191
6359
  const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
6192
6360
  const meta = document.createElement("div");
6193
6361
  meta.className = "message-meta";
6194
- meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens);
6362
+ meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent);
6195
6363
  message.append(meta);
6196
6364
  attachAssistantCopyAction(message, text);
6197
6365
  }
@@ -6206,7 +6374,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
6206
6374
  const applicable = suggestion.action !== "note";
6207
6375
  const guard = suggestion.guard;
6208
6376
  const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
6209
- message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
6377
+ message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
6210
6378
  attachMessageHeading(message, "助手建议", createdAt ?? undefined);
6211
6379
  attachAssistantCopyAction(message, suggestion.content);
6212
6380
  attachMessageIdentity(message, messageId);
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260726-ai-panel-header-relationship-network-palette">
13
+ <link rel="stylesheet" href="/styles.css?v=20260726-ai-history-cache-analysis-progress">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -242,8 +242,8 @@
242
242
  <div class="ai-heading-title"><span class="status-dot"></span><strong>创作助手</strong></div>
243
243
  <div class="ai-heading-actions">
244
244
  <span id="ai-conversation-title" title="当前对话">新对话</span>
245
+ <button id="ai-history-toggle" type="button" aria-label="历史记录" aria-controls="ai-history-dialog" aria-expanded="false" aria-haspopup="dialog" title="历史记录"><svg class="ai-heading-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path><path d="M3 3v5h5M12 7v5l3.5 2"></path></svg></button>
245
246
  <button id="ai-new-conversation" type="button" aria-label="新建对话" title="新建对话">+</button>
246
- <button id="ai-history-toggle" type="button" aria-controls="ai-history-dialog" aria-expanded="false" aria-haspopup="dialog" title="历史记录">历史</button>
247
247
  </div>
248
248
  </div>
249
249
  <div class="quick-actions" aria-label="快捷指令">
@@ -732,6 +732,6 @@
732
732
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
733
733
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
734
734
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
735
- <script type="module" src="/app.js?v=20260726-relationship-permission-actions"></script>
735
+ <script type="module" src="/app.js?v=20260726-ai-history-cache-analysis-progress"></script>
736
736
  </body>
737
737
  </html>
@@ -1180,6 +1180,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1180
1180
  margin: 0;
1181
1181
  }
1182
1182
  .task-auto-run-meta { margin: 0; color: var(--muted); font-size: 10px; }
1183
+ .task-auto-run-progress { display: grid; gap: 6px; width: min(100%, 420px); }
1184
+ .task-auto-run-progress-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 10px; }
1185
+ .task-auto-run-progress-label strong { color: var(--ink); font-family: var(--font-latin), monospace; font-size: 10px; font-weight: 600; }
1186
+ .task-auto-run-progress-bar { width: 100%; height: 6px; overflow: hidden; border: 0; border-radius: 999px; appearance: none; background: color-mix(in srgb, var(--line) 72%, transparent); }
1187
+ .task-auto-run-progress-bar::-webkit-progress-bar { border-radius: inherit; background: color-mix(in srgb, var(--line) 72%, transparent); }
1188
+ .task-auto-run-progress-bar::-webkit-progress-value { border-radius: inherit; background: var(--accent); transition: width .25s ease; }
1189
+ .task-auto-run-progress-bar::-moz-progress-bar { border-radius: inherit; background: var(--accent); transition: width .25s ease; }
1183
1190
  .task-row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
1184
1191
  .task-detail { display: grid; gap: 12px; }
1185
1192
  .task-detail p, .task-detail div { margin: 0; font-size: 12px; line-height: 1.55; }
@@ -1481,6 +1488,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1481
1488
  .ai-heading-actions { display: flex; align-items: center; min-width: 0; margin-left: auto; gap: 5px; }
1482
1489
  .ai-heading-actions > span { min-width: 0; max-width: 88px; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
1483
1490
  .ai-heading-actions button { min-width: 28px; height: 28px; padding: 0 7px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font-size: 9px; }
1491
+ .ai-heading-action-icon { display: block; width: 15px; height: 15px; margin: auto; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
1484
1492
  .ai-heading-actions #ai-new-conversation { padding: 0; font: 17px/1 var(--font-latin), monospace; }
1485
1493
  .ai-heading-actions button:hover, .ai-heading-actions button[aria-expanded="true"] { background: var(--paper-deep); color: var(--ink); }
1486
1494
  .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(94,118,92,.14); }
@@ -1740,6 +1748,36 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1740
1748
  .task-chapter-field { transition: opacity .15s ease; }
1741
1749
  .task-chapter-field.is-disabled { opacity: .48; }
1742
1750
  .task-chapter-field select:disabled { cursor: not-allowed; }
1751
+ .relationship-analysis-options { display: grid; gap: 14px; }
1752
+ .relationship-character-field { min-width: 0; }
1753
+ .relationship-character-picker { display: grid; gap: 8px; min-width: 0; }
1754
+ .dialog-fields .relationship-character-trigger { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); font-size: 12px; text-align: left; }
1755
+ .relationship-character-trigger:hover, .relationship-character-trigger:focus-visible, .relationship-character-trigger[aria-expanded="true"] { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); outline: none; }
1756
+ .relationship-character-trigger:disabled { cursor: not-allowed; opacity: .48; }
1757
+ .relationship-character-trigger > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1758
+ .relationship-character-trigger-meta { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 8px; color: var(--muted); font-family: var(--font-latin), monospace; font-size: 10px; }
1759
+ .relationship-character-chevron { font-size: 15px; line-height: 1; transition: transform .15s ease; }
1760
+ .relationship-character-trigger[aria-expanded="true"] .relationship-character-chevron { transform: rotate(180deg); }
1761
+ .relationship-character-bubble { position: relative; display: grid; gap: 9px; padding: 12px; border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-radius: 8px; background: var(--surface-soft); box-shadow: 0 10px 30px rgba(48,39,31,.12); }
1762
+ .relationship-character-bubble::before { position: absolute; top: -6px; right: 20px; width: 10px; height: 10px; border-top: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-left: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); background: var(--surface-soft); content: ""; transform: rotate(45deg); }
1763
+ .dialog-fields .relationship-character-search { gap: 5px; }
1764
+ .dialog-fields .relationship-character-search input { min-height: 36px; padding: 7px 9px; background: var(--surface); }
1765
+ .relationship-character-bubble-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 9px; }
1766
+ .relationship-character-bubble-meta button { padding: 2px 0; border: 0; background: transparent; color: var(--accent-dark); font-size: 9px; }
1767
+ .relationship-character-bubble-meta button:disabled { color: var(--muted); cursor: default; opacity: .45; }
1768
+ .relationship-character-options { display: flex; flex-wrap: wrap; gap: 7px; max-height: 220px; overflow-y: auto; padding: 2px 2px 2px 0; }
1769
+ .dialog-fields .relationship-character-chip { position: relative; display: inline-flex; width: auto; cursor: pointer; }
1770
+ .relationship-character-chip input { position: absolute; width: 1px !important; min-width: 1px; height: 1px; opacity: 0; }
1771
+ .relationship-character-chip span { display: inline-flex; align-items: center; min-height: 30px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); color: var(--muted); font-size: 10px; transition: background .14s ease, border-color .14s ease, color .14s ease; }
1772
+ .relationship-character-chip input:checked + span { border-color: var(--accent); background: var(--accent); color: #fff; }
1773
+ .relationship-character-chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
1774
+ .relationship-character-empty { margin: 0; padding: 18px 8px; color: var(--muted); font-size: 10px; text-align: center; }
1775
+ .relationship-analysis-helper { display: flex; gap: 7px; align-items: flex-start; margin: -7px 2px 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
1776
+ .relationship-analysis-helper > span:first-child { display: inline-grid; flex: 0 0 16px; width: 16px; height: 16px; margin-top: 1px; place-items: center; border: 1px solid var(--line); border-radius: 50%; color: var(--accent-dark); font-family: var(--font-latin), monospace; font-size: 9px; font-style: normal; font-weight: 700; line-height: 1; }
1777
+ .relationship-overwrite-card { display: grid; gap: 5px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); transition: border-color .15s ease, background .15s ease, opacity .15s ease; }
1778
+ .relationship-overwrite-card .checkbox-field { color: var(--ink); font-size: 11px; font-weight: 600; }
1779
+ .relationship-overwrite-card > p { margin: 0 0 0 25px; color: var(--muted); font-size: 10px; line-height: 1.55; }
1780
+ .relationship-overwrite-card:has(input:checked) { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--surface)); }
1743
1781
  .model-temperature-hint { margin: 0; color: var(--accent-dark); font-size: 10px; line-height: 1.55; }
1744
1782
  .item-list-rows { display: grid; gap: 7px; }
1745
1783
  .item-list-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
package/dist/store.js CHANGED
@@ -3607,6 +3607,15 @@ export class Store {
3607
3607
  const timestamp = now();
3608
3608
  const scope = input.scope ?? { type: "book" };
3609
3609
  const sourceVersions = {};
3610
+ if (Array.isArray(scope.characterIds)) {
3611
+ for (const characterId of scope.characterIds) {
3612
+ if (typeof characterId !== "string")
3613
+ throw new AppError(400, "CHARACTER_REQUIRED", "被分析角色标识无效");
3614
+ const character = this.getCharacter(characterId);
3615
+ if (character.workId !== workId)
3616
+ throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
3617
+ }
3618
+ }
3610
3619
  if (typeof scope.chapterId === "string") {
3611
3620
  const chapter = this.getChapter(scope.chapterId);
3612
3621
  if (chapter.workId !== workId)
@@ -3759,17 +3768,23 @@ export class Store {
3759
3768
  };
3760
3769
  }
3761
3770
  taskScopeSummaryFromMaps(scope, chapterSummaries, volumeTitles) {
3771
+ const targetedSuffix = Array.isArray(scope.characterIds) && scope.characterIds.length
3772
+ ? ` · 定向 ${scope.characterIds.length} 人${scope.replaceExistingRelationships === true ? " · 覆盖已有关系" : ""}`
3773
+ : "";
3762
3774
  if (typeof scope.chapterId === "string")
3763
- return chapterSummaries.get(scope.chapterId) ?? "章节已删除";
3775
+ return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${targetedSuffix}`;
3764
3776
  if (scope.type === "volume" && typeof scope.volumeId === "string") {
3765
3777
  const title = volumeTitles.get(scope.volumeId);
3766
- return title ? `分卷 · ${title}` : "分卷已删除";
3778
+ return title ? `分卷 · ${title}${targetedSuffix}` : "分卷已删除";
3767
3779
  }
3768
3780
  if (scope.type === "book" || Object.keys(scope).length === 0)
3769
- return "全书";
3781
+ return `${scope.includeAllSettings === true ? "全书 + 所有设定" : "全书"}${targetedSuffix}`;
3770
3782
  return "未指定范围";
3771
3783
  }
3772
3784
  taskScopeSummary(workId, scope) {
3785
+ const targetedSuffix = Array.isArray(scope.characterIds) && scope.characterIds.length
3786
+ ? ` · 定向 ${scope.characterIds.length} 人${scope.replaceExistingRelationships === true ? " · 覆盖已有关系" : ""}`
3787
+ : "";
3773
3788
  if (typeof scope.chapterId === "string") {
3774
3789
  const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
3775
3790
  FROM chapters chapter
@@ -3779,14 +3794,14 @@ export class Store {
3779
3794
  return "章节已删除";
3780
3795
  const title = requiredString(chapter, "title");
3781
3796
  const volumeTitle = requiredString(chapter, "volume_title");
3782
- return `${volumeTitle} · ${title}`;
3797
+ return `${volumeTitle} · ${title}${targetedSuffix}`;
3783
3798
  }
3784
3799
  if (scope.type === "volume" && typeof scope.volumeId === "string") {
3785
3800
  const volume = this.db.get("SELECT title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
3786
- return volume ? `分卷 · ${requiredString(volume, "title")}` : "分卷已删除";
3801
+ return volume ? `分卷 · ${requiredString(volume, "title")}${targetedSuffix}` : "分卷已删除";
3787
3802
  }
3788
3803
  if (scope.type === "book" || Object.keys(scope).length === 0)
3789
- return "全书";
3804
+ return `${scope.includeAllSettings === true ? "全书 + 所有设定" : "全书"}${targetedSuffix}`;
3790
3805
  return "未指定范围";
3791
3806
  }
3792
3807
  taskScopeDetails(workId, scope) {