@musnows/scriverse 0.5.12 → 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.
@@ -11,7 +11,7 @@ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.j
11
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";
@@ -86,6 +86,12 @@ function normalizePageSizes(value) {
86
86
  ]));
87
87
  }
88
88
 
89
+ function isSelectableModel(model) {
90
+ return Boolean(model?.enabled)
91
+ && model?.providerStatus === "enabled"
92
+ && model?.providerConnectionStatus === "success";
93
+ }
94
+
89
95
  const state = {
90
96
  user: null,
91
97
  csrfToken: null,
@@ -1612,6 +1618,14 @@ function renderAiConversationHistory() {
1612
1618
  }
1613
1619
  }
1614
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
+
1615
1629
  async function loadAiConversations(openLatest = true) {
1616
1630
  const workId = state.work?.id;
1617
1631
  if (!workId) return;
@@ -2149,9 +2163,7 @@ async function api(path, options = {}) {
2149
2163
  moduleRequestCache.clear();
2150
2164
  showAuth(false);
2151
2165
  }
2152
- const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
2153
- error.code = payload.error?.code;
2154
- throw error;
2166
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2155
2167
  }
2156
2168
  if (response.status === 204) {
2157
2169
  invalidateModuleRequestsAfterMutation(path, method);
@@ -2162,6 +2174,32 @@ async function api(path, options = {}) {
2162
2174
  return payload.data;
2163
2175
  }
2164
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
+
2165
2203
  async function apiPage(path, page = 1, limit = 30) {
2166
2204
  const separator = path.includes("?") ? "&" : "?";
2167
2205
  const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
@@ -5376,12 +5414,7 @@ async function rerunAnalysisTaskWithModel(task, button) {
5376
5414
  try {
5377
5415
  const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
5378
5416
  const currentModelId = String(task.model?.id ?? "");
5379
- const availableModels = models.filter((model) =>
5380
- model.id !== currentModelId
5381
- && model.enabled
5382
- && model.providerStatus === "enabled"
5383
- && model.providerConnectionStatus === "success"
5384
- );
5417
+ const availableModels = models.filter((model) => model.id !== currentModelId && isSelectableModel(model));
5385
5418
  if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
5386
5419
  $("#form-dialog").close();
5387
5420
  const currentModelLabel = task.model?.displayName || "运行时默认模型";
@@ -5892,11 +5925,28 @@ function openTaskDetailDialog(task, trace) {
5892
5925
  }
5893
5926
 
5894
5927
  function renderProviderCards(providers, models) {
5895
- return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => `
5896
- <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>
5897
- <p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit} · 最大输出:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
5898
- <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>
5899
- <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>`
5900
5950
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
5901
5951
  }
5902
5952
 
@@ -5909,28 +5959,50 @@ function bindPlatformProviderActions(host, providers, models) {
5909
5959
  await renderPlatformAiConfig();
5910
5960
  await loadModels();
5911
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
+ }));
5912
5970
  host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
5913
5971
  host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
5914
5972
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
5915
5973
  }
5916
5974
 
5917
- function renderTaskDefaults(models, providers, taskDefaults) {
5975
+ function renderTaskDefaults(models, providers, taskDefaults, settings) {
5918
5976
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
5919
5977
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
5920
- 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">
5921
5985
  <div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
5922
- <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]) => {
5923
5994
  const currentModelId = defaultModelByTask.get(taskType) ?? "";
5924
5995
  return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
5925
5996
  <option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
5926
- ${models.map((model) => {
5997
+ ${optionModels.map((model) => {
5927
5998
  const provider = providerById.get(model.providerId);
5928
- const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
5929
- 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>`;
5930
6002
  }).join("")}
5931
6003
  </select></td></tr>`;
5932
6004
  }).join("")}</tbody></table>
5933
- </section>` : emptyModule("尚未配置平台模型", "请先在平台 AI 管理中添加并测试供应商模型。");
6005
+ </section>` : emptyModule("尚未配置可用模型", "请先启用并测试供应商模型,已停用模型不会出现在新任务选择中。");
5934
6006
  }
5935
6007
 
5936
6008
  const relationshipIndexStatusLabels = Object.freeze({
@@ -6324,7 +6396,7 @@ async function renderBookAiSettings() {
6324
6396
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
6325
6397
  title: "本书 Token 用量",
6326
6398
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
6327
- })}</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)}`;
6328
6400
  scrollUsageCalendarsToLatest(host);
6329
6401
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
6330
6402
  "beforebegin",
@@ -6461,6 +6533,18 @@ async function renderBookAiSettings() {
6461
6533
  button.disabled = false;
6462
6534
  }
6463
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
+ });
6464
6548
  host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
6465
6549
  select.disabled = true;
6466
6550
  try {
@@ -6480,11 +6564,11 @@ async function loadModels() {
6480
6564
  const generation = workScopedUiGeneration;
6481
6565
  const models = await api(`/api/works/${workId}/models`);
6482
6566
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
6483
- state.models = models;
6567
+ state.models = models.filter((model) => isSelectableModel(model));
6484
6568
  loadedAiModelsWorkId = workId;
6485
6569
  const select = $("#ai-model");
6486
6570
  select.innerHTML = state.models.length
6487
- ? 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("")
6488
6572
  : '<option value="">请先配置模型</option>';
6489
6573
  scheduleAiContextUsage();
6490
6574
  }
@@ -6532,9 +6616,50 @@ function currentAiRequestScope() {
6532
6616
  return { taskType, scope, selection };
6533
6617
  }
6534
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
+
6535
6659
  function setAiContextMeter(usage) {
6536
6660
  const meter = $("#ai-context-meter");
6537
6661
  const value = meter.querySelector("b");
6662
+ renderAiContextDistribution(usage);
6538
6663
  if (!usage) {
6539
6664
  meter.classList.add("is-empty");
6540
6665
  meter.classList.remove("is-warning", "is-danger");
@@ -6556,6 +6681,14 @@ function setAiContextMeter(usage) {
6556
6681
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
6557
6682
  }
6558
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
+
6559
6692
  function showAiContextWarning(usage = null) {
6560
6693
  const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
6561
6694
  const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
@@ -8567,11 +8700,7 @@ async function openTaskDialog() {
8567
8700
  return;
8568
8701
  }
8569
8702
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
8570
- const availableTaskModels = taskModels.filter((model) =>
8571
- model.enabled
8572
- && model.providerStatus === "enabled"
8573
- && model.providerConnectionStatus === "success"
8574
- );
8703
+ const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
8575
8704
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
8576
8705
  const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
8577
8706
  <span id="relationship-character-label">被分析角色(可多选)</span>
@@ -8877,8 +9006,8 @@ async function openTaskDialog() {
8877
9006
  function openProviderDialog(item) {
8878
9007
  const protocol = item?.protocol ?? "openai-chat-completions";
8879
9008
  const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
8880
- 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) => {
8881
- 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" };
8882
9011
  if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
8883
9012
  await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
8884
9013
  await renderPlatformAiConfig();
@@ -8972,6 +9101,7 @@ async function sendAi() {
8972
9101
  assistantMessage = streamed.message;
8973
9102
  assistantMetadata = streamed.metadata;
8974
9103
  persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
9104
+ applyAiConversationTitle(streamed.conversationTitle);
8975
9105
  } else {
8976
9106
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
8977
9107
  assistantContent = suggestion.content;
@@ -8993,7 +9123,7 @@ async function sendAi() {
8993
9123
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
8994
9124
  }
8995
9125
  } catch (error) {
8996
- const failureMessage = `调用失败:${error.message}`;
9126
+ const failureMessage = formatAiFailureMessage(error);
8997
9127
  let persistedFailureMessage = null;
8998
9128
  try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
8999
9129
  appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
@@ -9029,6 +9159,7 @@ async function streamChat(body) {
9029
9159
  let processSteps = [];
9030
9160
  let persistedMessageId = null;
9031
9161
  let persistedMessageCreatedAt = null;
9162
+ let conversationTitle = null;
9032
9163
  let finalAnswerStarted = false;
9033
9164
  const processStartedAt = Date.now();
9034
9165
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -9040,7 +9171,7 @@ async function streamChat(body) {
9040
9171
  });
9041
9172
  if (!response.ok || !response.body) {
9042
9173
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
9043
- throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
9174
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
9044
9175
  }
9045
9176
  const reader = response.body.getReader();
9046
9177
  const decoder = new TextDecoder();
@@ -9085,6 +9216,7 @@ async function streamChat(body) {
9085
9216
  } else if (eventName === "complete") {
9086
9217
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9087
9218
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
9219
+ conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
9088
9220
  await typewriter.finish();
9089
9221
  message.classList.remove("is-streaming");
9090
9222
  content.setAttribute("aria-busy", "false");
@@ -9098,7 +9230,7 @@ async function streamChat(body) {
9098
9230
  attachAssistantCopyAction(message, streamedText);
9099
9231
  scrollAiFeedToBottom();
9100
9232
  } else if (eventName === "error") {
9101
- streamError = new Error(payload.message ?? "AI 流式调用失败");
9233
+ streamError = createClientError(payload, "AI 流式调用失败", response.status);
9102
9234
  }
9103
9235
  };
9104
9236
  while (true) {
@@ -9112,7 +9244,7 @@ async function streamChat(body) {
9112
9244
  if (buffer.trim()) await consume(buffer);
9113
9245
  await typewriter.finish();
9114
9246
  if (streamError) throw streamError;
9115
- return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt };
9247
+ return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle };
9116
9248
  } catch (error) {
9117
9249
  typewriter.reveal();
9118
9250
  message.classList.remove("is-streaming");
@@ -9127,8 +9259,12 @@ async function streamChat(body) {
9127
9259
 
9128
9260
  function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
9129
9261
  const message = document.createElement("div");
9130
- message.className = role === "user" ? "user-message" : "assistant-message";
9131
- 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>`;
9132
9268
  attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
9133
9269
  if (citations.length) {
9134
9270
  const references = document.createElement("div");
@@ -10487,6 +10623,7 @@ document.addEventListener("pointerdown", (event) => {
10487
10623
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10488
10624
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
10489
10625
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
10626
+ if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
10490
10627
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
10491
10628
  $("#account-menu").classList.add("hidden");
10492
10629
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -10508,6 +10645,7 @@ document.addEventListener("keydown", (event) => {
10508
10645
  closeLineCitationMenu();
10509
10646
  closeMarkdownTableMenu(true);
10510
10647
  hideAiMentionMenu();
10648
+ setAiContextDistributionVisible(false);
10511
10649
  }
10512
10650
  });
10513
10651
  document.addEventListener("keydown", (event) => {
@@ -10517,6 +10655,10 @@ document.addEventListener("keydown", (event) => {
10517
10655
  if (event.repeat) return;
10518
10656
  openSearchDialog().catch((error) => toast(error.message, "error"));
10519
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));
10520
10662
  $("#ai-send").addEventListener("click", sendAi);
10521
10663
  $("#ai-new-conversation").addEventListener("click", async () => {
10522
10664
  const button = $("#ai-new-conversation");
@@ -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=20260730-drafts-stream-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260730-ai-error-drafts-filter-align-model-availability-token-distribution-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -309,7 +309,11 @@
309
309
  <div id="ai-prompt" class="ai-prompt" contenteditable="true" role="textbox" aria-multiline="true" aria-keyshortcuts="Enter" title="Enter 发送,Shift+Enter 换行" data-placeholder="告诉 AI 你想讨论或修改什么……"></div>
310
310
  <div id="ai-mention-menu" class="ai-mention-menu hidden" role="listbox" aria-label="引用角色、设定或章节"></div>
311
311
  <div class="prompt-composer-actions">
312
- <output id="ai-context-meter" class="ai-context-meter is-empty" aria-live="polite" aria-label="当前上下文用量" tabindex="0"><b>—</b></output>
312
+ <button id="ai-context-meter" class="ai-context-meter is-empty" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="ai-context-popover" aria-live="polite" aria-label="当前上下文用量"><b>—</b></button>
313
+ <section id="ai-context-popover" class="ai-context-popover hidden" role="dialog" aria-labelledby="ai-context-popover-title" aria-describedby="ai-context-popover-description">
314
+ <header class="ai-context-popover-header"><div><strong id="ai-context-popover-title">Token 分布</strong><small id="ai-context-popover-description">按当前模型上下文窗口计算</small></div><button id="ai-context-popover-close" class="ai-context-popover-close" type="button" aria-label="关闭 Token 分布">×</button></header>
315
+ <div id="ai-context-distribution" class="ai-context-distribution" role="list" aria-label="当前 Token 分布"></div>
316
+ </section>
313
317
  <button id="ai-send" class="ai-send-button" type="button" aria-label="发送消息">发送</button>
314
318
  </div>
315
319
  </div>
@@ -884,6 +888,6 @@
884
888
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
885
889
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
886
890
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
887
- <script type="module" src="/app.js?v=20260730-volume-lazy-module-cache-v1"></script>
891
+ <script type="module" src="/app.js?v=20260730-ai-error-model-availability-token-distribution-conversation-title-v1"></script>
888
892
  </body>
889
893
  </html>
@@ -1275,7 +1275,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1275
1275
  .character-filter-toolbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; min-height: 38px; }
1276
1276
  .character-filter-result-count { color: var(--muted); font-size: 11px; white-space: nowrap; }
1277
1277
  .character-filter-toolbar-actions button:disabled { cursor: default; opacity: .45; }
1278
- .draft-filter-toolbar { display: flex; align-items: end; gap: 10px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--line); background: var(--surface-soft); }
1278
+ .draft-filter-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--line); background: var(--surface-soft); }
1279
1279
  .draft-filter-toolbar label { color: var(--muted); font-size: 11px; }
1280
1280
  .draft-filter-toolbar select { min-width: 180px; min-height: 38px; padding: 8px 10px; background: var(--surface); font-size: 11px; }
1281
1281
  .draft-filter-toolbar span { margin-left: auto; align-self: center; color: var(--muted); font-size: 11px; }
@@ -1301,9 +1301,24 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1301
1301
  .card-actions .primary-button:hover, .card-actions .primary-button:focus-visible { background: var(--accent-dark); }
1302
1302
  .provider-card-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); }
1303
1303
  .provider-card { min-width: 0; }
1304
+ .provider-card.is-disabled { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 68%, var(--line)); background: color-mix(in srgb, var(--accent) 5%, var(--surface-soft)); }
1305
+ .provider-card-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
1306
+ .provider-card-meta small { min-width: 0; }
1307
+ .provider-status-badge, .model-status-badge { display: inline-flex; flex: 0 0 auto; align-items: center; min-height: 22px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; font-size: 9px; font-weight: 700; line-height: 1.1; white-space: nowrap; }
1308
+ .provider-status-badge.is-enabled { border-color: color-mix(in srgb, var(--green) 48%, var(--line)); background: color-mix(in srgb, var(--green) 10%, var(--surface)); color: var(--green); }
1309
+ .provider-status-badge.is-disabled, .model-status-badge.is-disabled { border-color: color-mix(in srgb, var(--accent) 68%, var(--line)); background: color-mix(in srgb, var(--accent) 18%, var(--surface)); color: var(--accent-dark); }
1310
+ .provider-status-badge.is-error, .model-status-badge.is-unavailable { border-color: color-mix(in srgb, var(--accent) 46%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--accent-dark); }
1311
+ .provider-disabled-notice { display: flex; align-items: baseline; gap: 8px; margin: 12px 0; padding: 9px 11px; border-left: 3px solid var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
1312
+ .provider-disabled-notice strong { color: var(--accent-dark); font-size: 12px; }
1313
+ .provider-disabled-notice span { color: var(--muted); font-size: 10px; line-height: 1.4; }
1304
1314
  .provider-card p, .provider-card .model-pill { overflow-wrap: anywhere; word-break: break-word; }
1305
1315
  .provider-models { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
1306
- .provider-card .model-pill { max-width: 100%; margin: 0; border: 0; text-align: left; white-space: normal; cursor: pointer; }
1316
+ .provider-model-row { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }
1317
+ .provider-model-row.is-unavailable { padding: 4px 6px; border: 1px dashed color-mix(in srgb, var(--accent) 42%, var(--line)); border-radius: 4px; background: color-mix(in srgb, var(--accent) 4%, transparent); }
1318
+ .provider-card .model-pill { flex: 1 1 auto; max-width: 100%; margin: 0; border: 0; text-align: left; white-space: normal; cursor: pointer; }
1319
+ .provider-model-row .model-pill { min-width: 0; }
1320
+ .provider-model-row.is-unavailable .model-pill { color: var(--muted); opacity: .72; }
1321
+ .provider-model-row .model-test-button { flex: 0 0 auto; min-height: 30px; padding: 6px 9px; }
1307
1322
  .provider-card .model-pill:hover, .provider-card .model-pill:focus-visible { background: var(--surface-hover); color: var(--ink); }
1308
1323
  .provider-card .card-actions { flex-wrap: wrap; }
1309
1324
  .pill { display: inline-block; padding: 3px 7px; margin: 0 4px 4px 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--muted); font-size: 9px; }
@@ -2038,6 +2053,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2038
2053
  .assistant-message > .message-heading, .user-message > .message-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; opacity: .65; font-size: 9px; }
2039
2054
  .message-heading > span { letter-spacing: .12em; }.message-heading time { flex: 0 0 auto; font-variant-numeric: tabular-nums; letter-spacing: .03em; }
2040
2055
  .message-body { min-width: 0; overflow-wrap: anywhere; }
2056
+ .assistant-message.is-error .message-body { font-family: inherit; font-size: inherit; line-height: inherit; }
2057
+ .assistant-message.is-error .ai-error-text { margin: 0; white-space: pre-wrap; font: inherit; }
2041
2058
  .message-card-actions { position: absolute; right: 0; bottom: -31px; display: flex; align-items: center; gap: 12px; height: 24px; }
2042
2059
  .message-card-actions button { display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 3px 2px; border: 0; background: transparent; color: var(--muted); font-size: 9px; }
2043
2060
  .message-card-actions button:hover, .message-card-actions button:focus-visible { color: var(--accent-dark); }
@@ -2139,11 +2156,33 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2139
2156
  .ai-prompt-reference button { width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; font: 14px/1 var(--font-latin), monospace; }
2140
2157
  .ai-prompt-reference button:hover, .ai-prompt-reference button:focus-visible { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
2141
2158
  .prompt-composer-actions { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 6px; align-items: center; }
2142
- .ai-context-meter { --context-usage: 0; --context-meter-color: var(--green); position: relative; display: grid; flex: 0 0 32px; place-items: center; width: 32px; height: 32px; overflow: visible; border: 0; border-radius: 50%; background: conic-gradient(var(--context-meter-color) calc(var(--context-usage) * 1%), rgba(143,132,116,.2) 0); color: var(--ink); font-family: var(--font-latin); line-height: 1; }
2159
+ .ai-context-meter { --context-usage: 0; --context-meter-color: var(--green); position: relative; display: grid; flex: 0 0 32px; place-items: center; width: 32px; height: 32px; overflow: visible; border: 0; border-radius: 50%; background: conic-gradient(var(--context-meter-color) calc(var(--context-usage) * 1%), rgba(143,132,116,.2) 0); color: var(--ink); font-family: var(--font-latin); line-height: 1; cursor: pointer; }
2143
2160
  .ai-context-meter::before { content: ""; position: absolute; inset: 3px; border-radius: 50%; background: var(--paper); box-shadow: inset 0 0 0 1px rgba(143,132,116,.12); }
2144
- .ai-context-meter::after { content: attr(data-tooltip); position: absolute; z-index: 30; right: 0; bottom: calc(100% + 8px); width: max-content; max-width: 220px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.1); border-radius: 4px; background: #292724; color: #fff; font: 10px/1.35 var(--font-latin), monospace; white-space: nowrap; box-shadow: 0 6px 18px rgba(49,42,32,.25); opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease; }
2145
- .ai-context-meter:hover::after, .ai-context-meter:focus-visible::after { opacity: 1; transform: translateY(0); }
2161
+ .ai-context-meter:hover, .ai-context-meter:focus-visible, .ai-context-meter[aria-expanded="true"] { outline: 0; filter: brightness(.96); }
2162
+ .ai-context-meter:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
2146
2163
  .ai-context-meter b { position: relative; z-index: 1; font-size: 9px; font-weight: 600; }.ai-context-meter.is-warning { --context-meter-color: #bf6a35; }.ai-context-meter.is-danger { --context-meter-color: #ad463c; }.ai-context-meter.is-empty { opacity: .62; }
2164
+ .ai-context-popover { position: absolute; right: 0; bottom: calc(100% + 10px); z-index: 40; display: grid; width: min(320px, calc(100vw - 32px)); gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--paper); color: var(--ink); box-shadow: 0 14px 38px rgba(36, 30, 24, .18); }
2165
+ .ai-context-popover.hidden { display: none; }
2166
+ .ai-context-popover::after { position: absolute; right: 12px; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--paper); content: ""; transform: rotate(45deg); }
2167
+ .ai-context-popover-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
2168
+ .ai-context-popover-header > div { display: grid; gap: 3px; min-width: 0; }
2169
+ .ai-context-popover-header strong { font-size: 12px; }
2170
+ .ai-context-popover-header small { color: var(--muted); font: 9px/1.4 var(--font-latin), var(--font-cjk), sans-serif; }
2171
+ .ai-context-popover-close { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font: 17px/1 var(--font-latin), sans-serif; }
2172
+ .ai-context-popover-close:hover, .ai-context-popover-close:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: 0; }
2173
+ .ai-context-distribution { display: grid; gap: 10px; }
2174
+ .ai-context-distribution-row { display: grid; gap: 5px; }
2175
+ .ai-context-distribution-label { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font: 10px/1.3 var(--font-latin), sans-serif; }
2176
+ .ai-context-distribution-label > span { min-width: 0; color: var(--ink); }
2177
+ .ai-context-distribution-label small { color: var(--muted); font: 9px/1.3 var(--font-cjk), sans-serif; }
2178
+ .ai-context-distribution-label strong { flex: 0 0 auto; color: var(--muted); font: 10px/1.3 var(--font-latin), monospace; font-weight: 500; }
2179
+ .ai-context-distribution-track { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--muted) 14%, transparent); }
2180
+ .ai-context-distribution-track > span { display: block; width: calc(var(--distribution-percent) * 1%); height: 100%; min-width: 0; border-radius: inherit; background: var(--distribution-color, var(--accent)); transition: width .16s ease; }
2181
+ .ai-context-distribution-row[data-key="system-prompt"] { --distribution-color: var(--accent); }
2182
+ .ai-context-distribution-row[data-key="function"] { --distribution-color: #9a7651; }
2183
+ .ai-context-distribution-row[data-key="skills"] { --distribution-color: var(--muted); }
2184
+ .ai-context-distribution-row[data-key="context"] { --distribution-color: var(--green); }
2185
+ .ai-context-distribution-row[data-key="left"] { --distribution-color: color-mix(in srgb, var(--muted) 55%, transparent); }
2147
2186
  .ai-send-button { min-width: 54px; height: 32px; padding: 0 11px; border: 0; border-radius: 4px; background: var(--accent); color: #fff; font-size: 10px; font-weight: 600; box-shadow: 0 4px 12px rgba(139,61,44,.2); }
2148
2187
  .ai-send-button:hover { background: var(--accent-dark); }.ai-send-button:disabled { cursor: wait; opacity: .62; }
2149
2188
  .ai-citations { display: grid; gap: 6px; max-height: 190px; overflow-y: auto; margin-bottom: 8px; }
@@ -2970,7 +3009,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2970
3009
  .app-shell.shelf-mode .main-panel { grid-column: 1; grid-row: 2; min-height: 0; }
2971
3010
  .ai-panel {
2972
3011
  position: fixed;
2973
- z-index: 25;
3012
+ z-index: 35;
2974
3013
  right: 0;
2975
3014
  bottom: 0;
2976
3015
  left: 0;