@musnows/scriverse 0.3.5 → 0.3.7

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.
@@ -0,0 +1,8 @@
1
+ export type AnalysisType = Readonly<{
2
+ value: string;
3
+ label: string;
4
+ desc: string;
5
+ }>;
6
+
7
+ export const ANALYSIS_TYPES: ReadonlyArray<AnalysisType>;
8
+ export function analysisTypeDescription(value: unknown): string;
@@ -0,0 +1,53 @@
1
+ export const ANALYSIS_TYPES = Object.freeze([
2
+ Object.freeze({
3
+ value: "chapter-analysis",
4
+ label: "章节理解",
5
+ desc: "分析所选章节,生成情节概要,并提取事件、出场角色、设定、原文证据和不确定项。"
6
+ }),
7
+ Object.freeze({
8
+ value: "character-extraction",
9
+ label: "全书角色抽取",
10
+ desc: "扫描分析范围内的正文,识别有跨章节意义的角色及可靠别名,并创建或更新角色档案。"
11
+ }),
12
+ Object.freeze({
13
+ value: "character-identity-audit",
14
+ label: "AI 角色查重",
15
+ desc: "对照全书正文与现有角色档案,找出可能属于同一角色的重复档案;只生成审核建议,不会自动合并。"
16
+ }),
17
+ Object.freeze({
18
+ value: "timeline-analysis",
19
+ label: "时间轴与事件抽取",
20
+ desc: "从正文提取事件、发生时间、地点和参与者,区分发生时间与叙述时间,并保存为待确认候选。"
21
+ }),
22
+ Object.freeze({
23
+ value: "relationship-analysis",
24
+ label: "全书人物关系分析",
25
+ desc: "根据原文证据识别角色之间具有长期意义的关系及变化,生成可供确认的人物关系候选。"
26
+ }),
27
+ Object.freeze({
28
+ value: "worldview-analysis",
29
+ label: "世界观分析",
30
+ desc: "归纳正文中的自然、社会、历史、科技、文化等世界观维度,同时标出冲突和证据不足之处。"
31
+ }),
32
+ Object.freeze({
33
+ value: "setting-extraction",
34
+ label: "设定抽取",
35
+ desc: "提取会影响后续创作的地点、物品、能力、制度、规则等设定,附带原文证据并等待作者确认。"
36
+ }),
37
+ Object.freeze({
38
+ value: "consistency-check",
39
+ label: "一致性校对",
40
+ desc: "检查人物状态、关系、时间与作品设定是否相互冲突,并按严重程度给出证据和修改建议。"
41
+ }),
42
+ Object.freeze({
43
+ value: "book-analysis",
44
+ label: "全书综合分析",
45
+ desc: "基于所选范围生成开放式综合分析,适合查看作品整体表现、主要问题和可改进方向。"
46
+ })
47
+ ]);
48
+
49
+ const analysisTypeDescriptions = new Map(ANALYSIS_TYPES.map(({ value, desc }) => [value, desc]));
50
+
51
+ export function analysisTypeDescription(value) {
52
+ return analysisTypeDescriptions.get(String(value)) ?? "请选择一种分析类型以查看用途说明。";
53
+ }
@@ -1,6 +1,6 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260719-group-relation-list";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260721-release-0.3.6";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
- import { renderMarkdown } from "/markdown.js?v=20260717-markdown-table-scrollbar";
3
+ import { renderMarkdown } from "/markdown.js?v=20260721-character-attachments";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
@@ -11,12 +11,14 @@ import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-ti
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";
13
13
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
14
- import { buildCharacterDetails, buildCharacterSections, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
14
+ import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
15
15
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
16
16
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260714-all-knowledge-history";
17
17
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260714-refresh-restore";
18
18
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
19
19
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
20
+ import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hierarchy.js?v=20260721-race-hierarchy";
21
+ import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
20
22
 
21
23
  const state = {
22
24
  user: null,
@@ -55,6 +57,7 @@ const analysisTaskTypeLabels = new Map([
55
57
  ...MODEL_PURPOSE_OPTIONS,
56
58
  ["character-extraction", "全书角色抽取"],
57
59
  ["character-summary", "全书角色抽取"],
60
+ ["character-identity-audit", "AI 角色查重"],
58
61
  ["worldview-analysis", "世界观分析"],
59
62
  ["setting-extraction", "设定抽取"],
60
63
  ["structure", "结构分析"],
@@ -462,6 +465,9 @@ let characterEditorItem = null;
462
465
  let characterEditorVersions = [];
463
466
  let characterEditorRelationships = [];
464
467
  let characterEditorRelationshipsLoading = false;
468
+ let characterEditorSections = [];
469
+ let characterSectionPreviewTimer = null;
470
+ let characterSectionPendingAttachments = [];
465
471
  let entityHistoryContext = null;
466
472
 
467
473
  function setModuleNavExpanded(expanded) {
@@ -827,14 +833,16 @@ const AI_TOOL_DISPLAY_NAMES = {
827
833
  story_index: "作品目录与章节概要",
828
834
  read_chapters: "读取章节",
829
835
  grep: "查询正文关键字",
830
- query_story_knowledge: "查询作品知识"
836
+ query_story_knowledge: "查询作品知识",
837
+ read_character_sections: "读取人物 Markdown 章节"
831
838
  };
832
839
 
833
840
  const AI_TOOL_DESCRIPTIONS = {
834
841
  story_index: "分页读取当前作品的卷章目录和章节概要。",
835
842
  read_chapters: "读取指定章节的概要、正文或两者。",
836
843
  grep: "查询正文关键字所在的完整段落及章节信息。",
837
- query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。"
844
+ query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。",
845
+ read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
838
846
  };
839
847
 
840
848
  let aiFeedScrollFrame = null;
@@ -2338,21 +2346,33 @@ async function renderCharacters() {
2338
2346
  api(`/api/works/${state.work.id}/races`),
2339
2347
  api(`/api/works/${state.work.id}/organizations`)
2340
2348
  ]);
2341
- $("#module-content").innerHTML = state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2349
+ const auditPanel = `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>`;
2350
+ $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2342
2351
  const details = normalizeCharacterDetails(item.attributes?.details);
2343
- const sections = normalizeCharacterSections(item.profile?.sections);
2344
2352
  return `
2345
2353
  <article class="record-card character-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}"><small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
2346
2354
  <h3>${esc(item.name)}</h3><div>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>
2347
- ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(item.species)}</span></div>` : ""}
2355
+ ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
2348
2356
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
2349
2357
  ${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(detail.label)}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
2350
2358
  <div class="organization-links"><b>所属组织</b>${(item.organizations ?? []).length ? item.organizations.map((organization) => `<span class="pill organization-pill">${esc(organization.name)}</span>`).join("") : '<span class="organization-empty">未加入组织</span>'}</div>
2351
2359
  ${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
2352
- ${sections.length ? `<small class="character-section-count">${sections.length} 个设定章节</small>` : ""}
2360
+ ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2353
2361
  <div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button></div></article>`;
2354
2362
  }).join("")}</div>`
2355
- : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。");
2363
+ : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2364
+ $("#create-character-audit-task")?.addEventListener("click", async () => {
2365
+ const button = $("#create-character-audit-task");
2366
+ button.disabled = true;
2367
+ try {
2368
+ await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: "character-identity-audit", scope: { type: "book" } } });
2369
+ toast("角色查重任务已加入分析队列");
2370
+ await showModule("tasks");
2371
+ } catch (error) {
2372
+ toast(error.message, "error");
2373
+ button.disabled = false;
2374
+ }
2375
+ });
2356
2376
  $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
2357
2377
  const open = () => openCharacterDialog(state.characters.find((item) => item.id === card.dataset.openCharacter));
2358
2378
  card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
@@ -2370,13 +2390,20 @@ async function renderRaces() {
2370
2390
  api(`/api/works/${state.work.id}/races`),
2371
2391
  api(`/api/works/${state.work.id}/characters`)
2372
2392
  ]);
2373
- $("#module-content").innerHTML = state.races.length ? `<div class="card-grid race-grid">${state.races.map((item) => `
2374
- <article class="record-card race-card"><small>${item.memberIds.length} 位角色 · ${item.settings.length} 条共同设定</small>
2375
- <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写种族简介")}</p>
2376
- <div class="race-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无共同设定</span>'}</div>
2377
- <p class="race-members">角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2378
- <div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div>
2379
- </article>`).join("")}</div>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2393
+ const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2394
+ <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
2395
+ <div class="race-tree-branch">
2396
+ <article class="record-card race-card"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
2397
+ <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
2398
+ <p>${esc(item.description || "尚未填写种族简介")}</p>
2399
+ <div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<span class="pill${setting.inherited ? " inherited" : ""}" title="${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}">${esc(setting.value)}<small>${esc(setting.sourceRaceName)}</small></span>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
2400
+ <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2401
+ <div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div>
2402
+ </article>
2403
+ ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2404
+ </div>
2405
+ </details>`;
2406
+ $("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2380
2407
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
2381
2408
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2382
2409
  }
@@ -2488,8 +2515,24 @@ async function renderRelationships() {
2488
2515
  }
2489
2516
 
2490
2517
  async function renderReviews() {
2491
- const reviews = await api(`/api/works/${state.work.id}/reviews`);
2492
- $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => `
2518
+ const [reviews, characters] = await Promise.all([
2519
+ api(`/api/works/${state.work.id}/reviews`),
2520
+ api(`/api/works/${state.work.id}/characters?includeMerged=1`)
2521
+ ]);
2522
+ const characterById = new Map(characters.map((character) => [character.id, character]));
2523
+ const duplicateCard = (item) => {
2524
+ const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
2525
+ const sides = refs.map((reference) => ({ reference, character: characterById.get(reference.id) }));
2526
+ const sideHtml = sides.map(({ character }) => `<section><strong>${esc(character.name)}</strong><small>v${esc(String(character.versionNo))} · ${esc(character.species || "种族未知")}</small><div>${character.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("") || '<span class="organization-empty">无别名</span>'}</div><p>${esc(character.attributes?.identity || character.profile?.summary || "尚未记录身份说明")}</p></section>`).join("");
2527
+ const evidenceHtml = (item.evidence ?? []).map((evidence) => `<li><strong>${esc(evidence.chapterTitle || evidence.chapterId || "原文")}</strong><q>${esc(evidence.quote || "")}</q>${evidence.supports ? `<small>${esc(evidence.supports)}</small>` : ""}</li>`).join("");
2528
+ const actions = item.status === "pending" && sides.length === 2 ? `<div class="card-actions character-duplicate-actions">
2529
+ <button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[0].character.id)}" data-merge-source="${esc(sides[1].character.id)}" data-target-version="${esc(String(sides[0].reference.versionNo))}" data-source-version="${esc(String(sides[1].reference.versionNo))}">合并为 ${esc(sides[0].character.name)}</button>
2530
+ <button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[1].character.id)}" data-merge-source="${esc(sides[0].character.id)}" data-target-version="${esc(String(sides[1].reference.versionNo))}" data-source-version="${esc(String(sides[0].reference.versionNo))}">合并为 ${esc(sides[1].character.name)}</button>
2531
+ <button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>
2532
+ </div>` : "";
2533
+ return `<article class="record-card character-duplicate-review"><small>角色查重 · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3><div class="character-duplicate-pair">${sideHtml}</div><p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>${evidenceHtml ? `<ul class="character-duplicate-evidence">${evidenceHtml}</ul>` : ""}${actions}${item.resolutionNote ? `<p class="review-resolution-note">处理结果:${esc(item.resolutionNote)}</p>` : ""}</article>`;
2534
+ };
2535
+ $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
2493
2536
  <article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
2494
2537
  <p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
2495
2538
  ${item.status === "pending" ? `<div class="card-actions"><button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button></div>` : ""}</article>`).join("")}</div>`
@@ -2498,6 +2541,38 @@ async function renderReviews() {
2498
2541
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
2499
2542
  await renderReviews();
2500
2543
  }));
2544
+ $("#module-content").querySelectorAll("[data-merge-review]").forEach((button) => button.addEventListener("click", async () => {
2545
+ const target = characterById.get(button.dataset.mergeTarget);
2546
+ const source = characterById.get(button.dataset.mergeSource);
2547
+ if (!target || !source || !window.confirm(`确认把“${source.name}”合并到“${target.name}”?来源角色的别名、组织、时间线和关系会迁移到目标角色。`)) return;
2548
+ button.disabled = true;
2549
+ try {
2550
+ await api(`/api/reviews/${button.dataset.mergeReview}/character-resolution`, { method: "POST", body: {
2551
+ action: "merge",
2552
+ targetCharacterId: target.id,
2553
+ sourceCharacterId: source.id,
2554
+ expectedTargetVersionNo: Number(button.dataset.targetVersion),
2555
+ expectedSourceVersionNo: Number(button.dataset.sourceVersion)
2556
+ } });
2557
+ toast(`已将“${source.name}”合并到“${target.name}”`);
2558
+ await renderReviews();
2559
+ await loadAiReferences();
2560
+ } catch (error) {
2561
+ toast(error.message, "error");
2562
+ button.disabled = false;
2563
+ }
2564
+ }));
2565
+ $("#module-content").querySelectorAll("[data-keep-characters-separate]").forEach((button) => button.addEventListener("click", async () => {
2566
+ button.disabled = true;
2567
+ try {
2568
+ await api(`/api/reviews/${button.dataset.keepCharactersSeparate}/character-resolution`, { method: "POST", body: { action: "keep-separate" } });
2569
+ toast("已确认这两个档案属于不同角色");
2570
+ await renderReviews();
2571
+ } catch (error) {
2572
+ toast(error.message, "error");
2573
+ button.disabled = false;
2574
+ }
2575
+ }));
2501
2576
  }
2502
2577
 
2503
2578
  async function renderTasks() {
@@ -2716,12 +2791,16 @@ async function renderBookAiSettings() {
2716
2791
  api(`/api/works/${state.work.id}/task-defaults`)
2717
2792
  ]);
2718
2793
  const host = $("#module-content");
2719
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge"]);
2794
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge", "read_character_sections"]);
2720
2795
  host.innerHTML = `<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="primary-button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="field-label"><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></div><div class="card-actions"><button id="save-book-summary-context-percent" class="primary-button">保存概要配额</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话长期记忆</h2><p>对话历史使用独立预算;达到阈值时先提醒,继续发送会把较早消息整理成带来源的结构化长期记忆,并尽量保留最近八条原文。</p></div></div><div class="field-label"><label class="context-compact-threshold-field">整理提醒阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话长期记忆整理提醒阈值"></label></div><div class="card-actions"><button id="save-context-compact-threshold" class="primary-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="query_story_knowledge" ${agentTools.has("query_story_knowledge") ? "checked" : ""}><span><strong>查询作品知识</strong><small>按关键词查询设定、人物、组织、时间线、关系、大纲和伏笔。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="primary-button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
2721
2796
  host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
2722
2797
  "beforebegin",
2723
2798
  `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
2724
2799
  );
2800
+ host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
2801
+ "afterend",
2802
+ `<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
2803
+ );
2725
2804
  $("#save-work-system-prompt").addEventListener("click", async () => {
2726
2805
  const button = $("#save-work-system-prompt");
2727
2806
  button.disabled = true;
@@ -3349,8 +3428,218 @@ async function refreshRelationshipSurfaces(characterId = null) {
3349
3428
  await Promise.all(tasks);
3350
3429
  }
3351
3430
 
3431
+ const characterSectionTypeLabels = {
3432
+ overview: "基本档案",
3433
+ appearance: "外貌与生理",
3434
+ abilities: "能力与弱点",
3435
+ personality: "性格与行为",
3436
+ ecology: "生态",
3437
+ background: "背景故事",
3438
+ history: "经历记录",
3439
+ legends: "相关传说",
3440
+ research: "研究记录",
3441
+ notes: "作者备注",
3442
+ custom: "自定义章节"
3443
+ };
3444
+
3445
+ async function discardPendingCharacterAttachments() {
3446
+ const pending = characterSectionPendingAttachments.splice(0);
3447
+ await Promise.all(pending.map(async (attachmentId) => {
3448
+ try { await api(`/api/attachments/${attachmentId}`, { method: "DELETE" }); } catch { /* 已被引用的附件由正常引用生命周期管理。 */ }
3449
+ }));
3450
+ }
3451
+
3452
+ function scheduleCharacterSectionPreview() {
3453
+ if (characterSectionPreviewTimer !== null) clearTimeout(characterSectionPreviewTimer);
3454
+ characterSectionPreviewTimer = window.setTimeout(() => {
3455
+ characterSectionPreviewTimer = null;
3456
+ const preview = $("#character-section-preview");
3457
+ const content = $("#character-section-markdown");
3458
+ if (preview && content) preview.innerHTML = renderMarkdown(content.value) || '<p class="character-markdown-empty">预览区域暂无内容。</p>';
3459
+ }, 260);
3460
+ }
3461
+
3462
+ function characterSectionEditorHtml(section = null) {
3463
+ const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
3464
+ return `<section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
3465
+ <div class="character-markdown-editor-meta">
3466
+ <label>章节类型<select id="character-section-type">${options}</select></label>
3467
+ <label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
3468
+ <label class="character-markdown-summary-field">章节摘要<textarea id="character-section-summary" maxlength="20000" placeholder="用于角色列表和 AI 快速定位,不会替代正文">${esc(section?.summary ?? "")}</textarea></label>
3469
+ </div>
3470
+ <div class="character-markdown-toolbar">
3471
+ <label class="ghost-button" for="character-section-attachment">上传并插入图片</label>
3472
+ <input id="character-section-attachment" class="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.gif,image/png,image/jpeg,image/webp,image/gif">
3473
+ <span>图片会优先转换为体积更小的无损 WebP</span>
3474
+ </div>
3475
+ <div class="character-markdown-compose">
3476
+ <label>Markdown 原文<textarea id="character-section-markdown" maxlength="500000" spellcheck="true" placeholder="支持标题、列表、引用、表格、链接和图片">${esc(section?.contentMarkdown ?? "")}</textarea></label>
3477
+ <div><span class="character-markdown-preview-label">安全预览</span><article id="character-section-preview" class="character-markdown-document message-body">${renderMarkdown(section?.contentMarkdown ?? "") || '<p class="character-markdown-empty">预览区域暂无内容。</p>'}</article></div>
3478
+ </div>
3479
+ <label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
3480
+ <div class="character-markdown-editor-actions"><button type="button" data-character-section-edit-cancel>取消</button><button type="button" class="primary-button" data-character-section-edit-save>${section ? "保存章节版本" : "创建章节"}</button></div>
3481
+ </section>`;
3482
+ }
3483
+
3484
+ async function openCharacterSectionEditor(section = null) {
3485
+ await discardPendingCharacterAttachments();
3486
+ const host = $("#character-markdown-sections");
3487
+ if (!host) return;
3488
+ host.innerHTML = characterSectionEditorHtml(section);
3489
+ const textarea = $("#character-section-markdown");
3490
+ textarea.addEventListener("input", scheduleCharacterSectionPreview);
3491
+ $("#character-section-attachment").addEventListener("change", async (event) => {
3492
+ const file = event.target.files?.[0];
3493
+ if (!file) return;
3494
+ const input = event.currentTarget;
3495
+ input.disabled = true;
3496
+ const body = new FormData();
3497
+ body.append("file", file);
3498
+ try {
3499
+ const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
3500
+ if (!attachment.deduplicated) characterSectionPendingAttachments.push(String(attachment.id));
3501
+ const imageLabel = file.name.replace(/[\]\r\n]/gu, "").trim() || "图片附件";
3502
+ const insertion = `![${imageLabel}](attachment://${attachment.id})`;
3503
+ const start = textarea.selectionStart ?? textarea.value.length;
3504
+ const end = textarea.selectionEnd ?? start;
3505
+ const prefix = start > 0 && !textarea.value.slice(0, start).endsWith("\n") ? "\n\n" : "";
3506
+ const suffix = end < textarea.value.length && !textarea.value.slice(end).startsWith("\n") ? "\n\n" : "";
3507
+ textarea.setRangeText(`${prefix}${insertion}${suffix}`, start, end, "end");
3508
+ textarea.focus();
3509
+ scheduleCharacterSectionPreview();
3510
+ toast(attachment.storedMimeType === "image/webp" ? "图片已转换为无损 WebP 并插入" : "图片已插入;转换后未变小,因此保留原格式");
3511
+ } catch (error) {
3512
+ toast(error.message, "error");
3513
+ } finally {
3514
+ input.disabled = false;
3515
+ input.value = "";
3516
+ }
3517
+ });
3518
+ host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", async () => {
3519
+ await discardPendingCharacterAttachments();
3520
+ renderCharacterMarkdownSections();
3521
+ });
3522
+ host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
3523
+ const button = event.currentTarget;
3524
+ const title = $("#character-section-title").value.trim();
3525
+ if (!title) {
3526
+ toast("请填写章节标题", "error");
3527
+ $("#character-section-title").focus();
3528
+ return;
3529
+ }
3530
+ button.disabled = true;
3531
+ const contentMarkdown = textarea.value;
3532
+ try {
3533
+ const saved = await api(section ? `/api/character-sections/${section.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
3534
+ method: section ? "PATCH" : "POST",
3535
+ body: {
3536
+ sectionType: $("#character-section-type").value,
3537
+ title,
3538
+ summary: $("#character-section-summary").value.trim(),
3539
+ contentMarkdown,
3540
+ ...(section ? { changeNote: $("#character-section-change-note").value.trim() } : {})
3541
+ }
3542
+ });
3543
+ const referenced = new Set([...contentMarkdown.matchAll(/attachment:\/\/([A-Za-z0-9_-]+)/gu)].map((match) => String(match[1])));
3544
+ const unused = characterSectionPendingAttachments.filter((attachmentId) => !referenced.has(attachmentId));
3545
+ characterSectionPendingAttachments = [];
3546
+ await Promise.all(unused.map((attachmentId) => api(`/api/attachments/${attachmentId}`, { method: "DELETE" }).catch(() => null)));
3547
+ characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3548
+ renderCharacterMarkdownSections();
3549
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3550
+ toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
3551
+ } catch (error) {
3552
+ toast(error.message, "error");
3553
+ button.disabled = false;
3554
+ }
3555
+ });
3556
+ }
3557
+
3558
+ async function showCharacterSectionVersions(sectionId) {
3559
+ const host = document.querySelector(`[data-character-section-versions-host="${CSS.escape(sectionId)}"]`);
3560
+ if (!host) return;
3561
+ host.innerHTML = '<p class="character-markdown-status">正在读取章节版本……</p>';
3562
+ try {
3563
+ const versions = await api(`/api/character-sections/${sectionId}/versions`);
3564
+ host.innerHTML = `<div class="character-markdown-version-list">${versions.map((version) => `<article><div><strong>v${version.versionNo}</strong><time>${esc(formatDateTime(version.createdAt))}</time></div><p>${esc(version.changeNote || "未填写版本说明")}</p>${version.versionNo === characterEditorSections.find((item) => item.id === sectionId)?.versionNo ? '<button type="button" disabled>当前版本</button>' : `<button type="button" data-character-section-restore="${version.versionNo}">恢复此版本</button>`}</article>`).join("")}</div>`;
3565
+ host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
3566
+ button.disabled = true;
3567
+ try {
3568
+ await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
3569
+ characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3570
+ renderCharacterMarkdownSections();
3571
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3572
+ toast("人物 Markdown 章节已恢复");
3573
+ } catch (error) {
3574
+ button.disabled = false;
3575
+ toast(error.message, "error");
3576
+ }
3577
+ }));
3578
+ } catch (error) {
3579
+ host.innerHTML = `<p class="character-markdown-status">版本载入失败:${esc(error.message)}</p>`;
3580
+ }
3581
+ }
3582
+
3583
+ function renderCharacterMarkdownSections() {
3584
+ const host = $("#character-markdown-sections");
3585
+ if (!host) return;
3586
+ if (!characterEditorItem?.id) {
3587
+ host.innerHTML = '<div class="character-editor-empty-field"><b>Markdown 档案章节</b><span>创建人物档案后即可添加背景故事、能力、经历和研究记录。</span></div>';
3588
+ return;
3589
+ }
3590
+ const toolbar = `<div class="character-markdown-list-toolbar"><div><b>Markdown 档案章节</b><span>长篇内容独立保存、渲染、检索和版本管理。</span></div>${canEditWork() ? '<button type="button" class="primary-button" data-character-section-create>新建章节</button>' : ""}</div>`;
3591
+ const sections = characterEditorSections.map((section) => `<article class="character-markdown-section">
3592
+ <header><div><span>${esc(characterSectionTypeLabels[section.sectionType] ?? section.sectionType)}</span><h4>${esc(section.title)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEditWork() ? `<button type="button" data-character-section-edit="${esc(section.id)}">编辑</button>` : ""}<button type="button" data-character-section-versions="${esc(section.id)}">版本</button>${canEditWork() ? `<button type="button" data-character-section-delete="${esc(section.id)}">删除</button>` : ""}</div></header>
3593
+ <div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
3594
+ <div data-character-section-versions-host="${esc(section.id)}"></div>
3595
+ </article>`).join("");
3596
+ host.innerHTML = `${toolbar}${sections || '<p class="character-markdown-status">还没有 Markdown 档案章节。</p>'}`;
3597
+ host.querySelector("[data-character-section-create]")?.addEventListener("click", () => void openCharacterSectionEditor());
3598
+ host.querySelectorAll("[data-character-section-edit]").forEach((button) => button.addEventListener("click", () => {
3599
+ const section = characterEditorSections.find((item) => item.id === button.dataset.characterSectionEdit);
3600
+ if (section) void openCharacterSectionEditor(section);
3601
+ }));
3602
+ host.querySelectorAll("[data-character-section-versions]").forEach((button) => button.addEventListener("click", () => void showCharacterSectionVersions(button.dataset.characterSectionVersions)));
3603
+ host.querySelectorAll("[data-character-section-delete]").forEach((button) => button.addEventListener("click", async () => {
3604
+ if (button.dataset.confirmed !== "true") {
3605
+ button.dataset.confirmed = "true";
3606
+ button.textContent = "确认删除";
3607
+ window.setTimeout(() => {
3608
+ if (button.isConnected && button.dataset.confirmed === "true") {
3609
+ button.dataset.confirmed = "false";
3610
+ button.textContent = "删除";
3611
+ }
3612
+ }, 5000);
3613
+ return;
3614
+ }
3615
+ button.disabled = true;
3616
+ try {
3617
+ await api(`/api/character-sections/${button.dataset.characterSectionDelete}`, { method: "DELETE" });
3618
+ characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3619
+ renderCharacterMarkdownSections();
3620
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3621
+ toast("人物 Markdown 章节已删除");
3622
+ } catch (error) {
3623
+ button.disabled = false;
3624
+ toast(error.message, "error");
3625
+ }
3626
+ }));
3627
+ }
3628
+
3629
+ async function loadCharacterMarkdownSections(characterId) {
3630
+ characterEditorSections = [];
3631
+ renderCharacterMarkdownSections();
3632
+ try {
3633
+ characterEditorSections = await api(`/api/characters/${characterId}/sections`);
3634
+ if (characterEditorItem?.id === characterId) renderCharacterMarkdownSections();
3635
+ } catch (error) {
3636
+ const host = $("#character-markdown-sections");
3637
+ if (host && characterEditorItem?.id === characterId) host.innerHTML = `<p class="character-markdown-status">章节载入失败:${esc(error.message)}</p>`;
3638
+ }
3639
+ }
3640
+
3352
3641
  function renderCharacterEditorFields(item) {
3353
- const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, race.name])];
3642
+ const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
3354
3643
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
3355
3644
  const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
3356
3645
  const stateEntries = characterStateEntries(item?.currentState ?? {});
@@ -3372,7 +3661,7 @@ function renderCharacterEditorFields(item) {
3372
3661
  field("summary", "人物简介", "textarea", item?.profile?.summary)),
3373
3662
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
3374
3663
  field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
3375
- field("sections", "设定章节", "section-list", item?.profile?.sections)),
3664
+ '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
3376
3665
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
3377
3666
  field("currentState", "当前状态", "key-value-list", stateEntries, {
3378
3667
  keyName: "stateKey",
@@ -3393,11 +3682,14 @@ function renderCharacterEditorFields(item) {
3393
3682
  if (name) name.required = true;
3394
3683
  bindDynamicListControls($("#character-editor-fields"));
3395
3684
  renderCharacterEditorRelationships();
3685
+ renderCharacterMarkdownSections();
3396
3686
  activateCharacterEditorTab("basic");
3397
3687
  }
3398
3688
 
3399
3689
  function collectCharacterBody(form) {
3400
3690
  const item = characterEditorItem;
3691
+ const profile = { ...(item?.profile ?? {}) };
3692
+ delete profile.sections;
3401
3693
  return {
3402
3694
  name: String(form.get("name") ?? "").trim(),
3403
3695
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
@@ -3409,10 +3701,9 @@ function collectCharacterBody(form) {
3409
3701
  details: buildCharacterDetails(form.getAll("detailLabel"), form.getAll("detailValue"))
3410
3702
  },
3411
3703
  profile: {
3412
- ...(item?.profile ?? {}),
3704
+ ...profile,
3413
3705
  motivation: String(form.get("motivation") ?? "").trim(),
3414
- summary: String(form.get("summary") ?? "").trim(),
3415
- sections: buildCharacterSections(form.getAll("sectionTitle"), form.getAll("sectionContent"))
3706
+ summary: String(form.get("summary") ?? "").trim()
3416
3707
  },
3417
3708
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
3418
3709
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
@@ -3498,6 +3789,7 @@ async function openCharacterDialog(item) {
3498
3789
  characterEditorVersions = [];
3499
3790
  characterEditorRelationships = [];
3500
3791
  characterEditorRelationshipsLoading = Boolean(item);
3792
+ characterEditorSections = [];
3501
3793
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
3502
3794
  $("#character-editor-title").textContent = item?.name || "新建角色";
3503
3795
  $("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
@@ -3543,20 +3835,27 @@ async function openCharacterDialog(item) {
3543
3835
  }
3544
3836
  };
3545
3837
  dialog.showModal();
3546
- if (item) void loadCharacterEditorRelationships(item.id);
3838
+ if (item) {
3839
+ void loadCharacterEditorRelationships(item.id);
3840
+ void loadCharacterMarkdownSections(item.id);
3841
+ }
3547
3842
  }
3548
3843
 
3549
3844
  async function openRaceDialog(item) {
3550
3845
  state.characters = await api(`/api/works/${state.work.id}/characters`);
3551
3846
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3847
+ const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
3848
+ .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
3849
+ .map((race) => [race.id, racePathLabel(race)])];
3552
3850
  openDialog(item ? "编辑种族" : "新建种族",
3553
3851
  field("name", "种族名称", "text", item?.name) +
3852
+ field("parentRaceId", "父种族", "select", item?.parentRaceId ?? "", parentOptions) +
3554
3853
  field("description", "种族简介", "textarea", item?.description) +
3555
3854
  field("settings", "种族共同设定(逐条填写)", "item-list", item?.settings ?? []) +
3556
3855
  (memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
3557
3856
  async (form) => {
3558
3857
  const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
3559
- const body = { name: form.get("name"), description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
3858
+ const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
3560
3859
  await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
3561
3860
  await renderRaces();
3562
3861
  await loadAiReferences();
@@ -3690,11 +3989,18 @@ function openReviewDialog() {
3690
3989
 
3691
3990
  function openTaskDialog() {
3692
3991
  const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
3693
- openDialog("开始 AI 分析", field("taskType", "分析类型", "select", "chapter-analysis", [["chapter-analysis", "章节理解"], ["character-extraction", "全书角色抽取"], ["timeline-analysis", "时间轴与事件抽取"], ["relationship-analysis", "全书人物关系分析"], ["worldview-analysis", "世界观分析"], ["setting-extraction", "设定抽取"], ["consistency-check", "一致性校对"], ["book-analysis", "全书综合分析"]]) + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + field("chapterId", "章节", "select", chapterOptions[0]?.[0] ?? "", chapterOptions), async (form) => {
3694
- const scope = form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
3992
+ const defaultTaskType = ANALYSIS_TYPES[0].value;
3993
+ 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>`;
3994
+ openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + field("chapterId", "章节", "select", chapterOptions[0]?.[0] ?? "", chapterOptions), async (form) => {
3995
+ const scope = form.get("taskType") === "character-identity-audit" || form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
3695
3996
  await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
3696
3997
  await renderTasks();
3697
3998
  });
3999
+ const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
4000
+ const description = $("#analysis-type-description");
4001
+ taskTypeSelect.addEventListener("change", () => {
4002
+ description.textContent = analysisTypeDescription(taskTypeSelect.value);
4003
+ });
3698
4004
  }
3699
4005
 
3700
4006
  function openProviderDialog(item) {
@@ -4292,6 +4598,7 @@ $("#entity-history-close").addEventListener("click", () => $("#entity-history-di
4292
4598
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
4293
4599
  $("#character-editor-close").addEventListener("click", () => $("#character-editor-dialog").close());
4294
4600
  $("#character-editor-cancel").addEventListener("click", () => $("#character-editor-dialog").close());
4601
+ $("#character-editor-dialog").addEventListener("close", () => { void discardPendingCharacterAttachments(); });
4295
4602
  $("#character-history-button").addEventListener("click", () => {
4296
4603
  if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
4297
4604
  else setCharacterHistoryVisible(false);