@musnows/scriverse 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,13 @@ 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";
20
21
 
21
22
  const state = {
22
23
  user: null,
@@ -55,6 +56,7 @@ const analysisTaskTypeLabels = new Map([
55
56
  ...MODEL_PURPOSE_OPTIONS,
56
57
  ["character-extraction", "全书角色抽取"],
57
58
  ["character-summary", "全书角色抽取"],
59
+ ["character-identity-audit", "AI 角色查重"],
58
60
  ["worldview-analysis", "世界观分析"],
59
61
  ["setting-extraction", "设定抽取"],
60
62
  ["structure", "结构分析"],
@@ -462,11 +464,14 @@ let characterEditorItem = null;
462
464
  let characterEditorVersions = [];
463
465
  let characterEditorRelationships = [];
464
466
  let characterEditorRelationshipsLoading = false;
467
+ let characterEditorSections = [];
468
+ let characterSectionPreviewTimer = null;
469
+ let characterSectionPendingAttachments = [];
465
470
  let entityHistoryContext = null;
466
471
 
467
472
  function setModuleNavExpanded(expanded) {
468
473
  moduleNavExpanded = expanded;
469
- $("#module-more-button").textContent = expanded ? "收起" : "更多";
474
+ $("#module-more-button .nav-label").textContent = expanded ? "收起" : "更多";
470
475
  $("#module-more-button").setAttribute("aria-expanded", String(expanded));
471
476
  $("#module-nav").querySelectorAll(".module-nav-secondary").forEach((button) => button.classList.toggle("hidden", !expanded));
472
477
  }
@@ -827,14 +832,16 @@ const AI_TOOL_DISPLAY_NAMES = {
827
832
  story_index: "作品目录与章节概要",
828
833
  read_chapters: "读取章节",
829
834
  grep: "查询正文关键字",
830
- query_story_knowledge: "查询作品知识"
835
+ query_story_knowledge: "查询作品知识",
836
+ read_character_sections: "读取人物 Markdown 章节"
831
837
  };
832
838
 
833
839
  const AI_TOOL_DESCRIPTIONS = {
834
840
  story_index: "分页读取当前作品的卷章目录和章节概要。",
835
841
  read_chapters: "读取指定章节的概要、正文或两者。",
836
842
  grep: "查询正文关键字所在的完整段落及章节信息。",
837
- query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。"
843
+ query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。",
844
+ read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
838
845
  };
839
846
 
840
847
  let aiFeedScrollFrame = null;
@@ -2338,21 +2345,33 @@ async function renderCharacters() {
2338
2345
  api(`/api/works/${state.work.id}/races`),
2339
2346
  api(`/api/works/${state.work.id}/organizations`)
2340
2347
  ]);
2341
- $("#module-content").innerHTML = state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2348
+ 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>`;
2349
+ $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2342
2350
  const details = normalizeCharacterDetails(item.attributes?.details);
2343
- const sections = normalizeCharacterSections(item.profile?.sections);
2344
2351
  return `
2345
2352
  <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
2353
  <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>` : ""}
2354
+ ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
2348
2355
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
2349
2356
  ${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
2357
  <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
2358
  ${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>` : ""}
2359
+ ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2353
2360
  <div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button></div></article>`;
2354
2361
  }).join("")}</div>`
2355
- : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。");
2362
+ : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2363
+ $("#create-character-audit-task")?.addEventListener("click", async () => {
2364
+ const button = $("#create-character-audit-task");
2365
+ button.disabled = true;
2366
+ try {
2367
+ await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: "character-identity-audit", scope: { type: "book" } } });
2368
+ toast("角色查重任务已加入分析队列");
2369
+ await showModule("tasks");
2370
+ } catch (error) {
2371
+ toast(error.message, "error");
2372
+ button.disabled = false;
2373
+ }
2374
+ });
2356
2375
  $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
2357
2376
  const open = () => openCharacterDialog(state.characters.find((item) => item.id === card.dataset.openCharacter));
2358
2377
  card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
@@ -2370,13 +2389,20 @@ async function renderRaces() {
2370
2389
  api(`/api/works/${state.work.id}/races`),
2371
2390
  api(`/api/works/${state.work.id}/characters`)
2372
2391
  ]);
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("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2392
+ const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2393
+ <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
2394
+ <div class="race-tree-branch">
2395
+ <article class="record-card race-card"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
2396
+ <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
2397
+ <p>${esc(item.description || "尚未填写种族简介")}</p>
2398
+ <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>
2399
+ <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2400
+ <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>
2401
+ </article>
2402
+ ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2403
+ </div>
2404
+ </details>`;
2405
+ $("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2380
2406
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
2381
2407
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2382
2408
  }
@@ -2433,8 +2459,8 @@ async function renderOutlines() {
2433
2459
  ]);
2434
2460
  const unresolved = foreshadows.filter((item) => item.unresolved);
2435
2461
  const overdue = unresolved.filter((item) => item.overdue);
2436
- const navButton = $("#module-nav [data-module=outlines]");
2437
- navButton.textContent = unresolved.length ? `大纲与伏笔 · ${unresolved.length}` : "大纲与伏笔";
2462
+ const navButton = $("#module-nav [data-module=outlines] .nav-label");
2463
+ if (navButton) navButton.textContent = unresolved.length ? `大纲与伏笔 · ${unresolved.length}` : "大纲与伏笔";
2438
2464
  const foreshadowHtml = foreshadows.length ? `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
2439
2465
  <article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
2440
2466
  <small>${esc(item.importance)} · ${esc(item.status)}${item.overdue ? " · 已逾期" : ""}</small>
@@ -2488,8 +2514,24 @@ async function renderRelationships() {
2488
2514
  }
2489
2515
 
2490
2516
  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) => `
2517
+ const [reviews, characters] = await Promise.all([
2518
+ api(`/api/works/${state.work.id}/reviews`),
2519
+ api(`/api/works/${state.work.id}/characters?includeMerged=1`)
2520
+ ]);
2521
+ const characterById = new Map(characters.map((character) => [character.id, character]));
2522
+ const duplicateCard = (item) => {
2523
+ const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
2524
+ const sides = refs.map((reference) => ({ reference, character: characterById.get(reference.id) }));
2525
+ 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("");
2526
+ 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("");
2527
+ const actions = item.status === "pending" && sides.length === 2 ? `<div class="card-actions character-duplicate-actions">
2528
+ <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>
2529
+ <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>
2530
+ <button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>
2531
+ </div>` : "";
2532
+ 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>`;
2533
+ };
2534
+ $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
2493
2535
  <article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
2494
2536
  <p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
2495
2537
  ${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 +2540,38 @@ async function renderReviews() {
2498
2540
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
2499
2541
  await renderReviews();
2500
2542
  }));
2543
+ $("#module-content").querySelectorAll("[data-merge-review]").forEach((button) => button.addEventListener("click", async () => {
2544
+ const target = characterById.get(button.dataset.mergeTarget);
2545
+ const source = characterById.get(button.dataset.mergeSource);
2546
+ if (!target || !source || !window.confirm(`确认把“${source.name}”合并到“${target.name}”?来源角色的别名、组织、时间线和关系会迁移到目标角色。`)) return;
2547
+ button.disabled = true;
2548
+ try {
2549
+ await api(`/api/reviews/${button.dataset.mergeReview}/character-resolution`, { method: "POST", body: {
2550
+ action: "merge",
2551
+ targetCharacterId: target.id,
2552
+ sourceCharacterId: source.id,
2553
+ expectedTargetVersionNo: Number(button.dataset.targetVersion),
2554
+ expectedSourceVersionNo: Number(button.dataset.sourceVersion)
2555
+ } });
2556
+ toast(`已将“${source.name}”合并到“${target.name}”`);
2557
+ await renderReviews();
2558
+ await loadAiReferences();
2559
+ } catch (error) {
2560
+ toast(error.message, "error");
2561
+ button.disabled = false;
2562
+ }
2563
+ }));
2564
+ $("#module-content").querySelectorAll("[data-keep-characters-separate]").forEach((button) => button.addEventListener("click", async () => {
2565
+ button.disabled = true;
2566
+ try {
2567
+ await api(`/api/reviews/${button.dataset.keepCharactersSeparate}/character-resolution`, { method: "POST", body: { action: "keep-separate" } });
2568
+ toast("已确认这两个档案属于不同角色");
2569
+ await renderReviews();
2570
+ } catch (error) {
2571
+ toast(error.message, "error");
2572
+ button.disabled = false;
2573
+ }
2574
+ }));
2501
2575
  }
2502
2576
 
2503
2577
  async function renderTasks() {
@@ -2716,12 +2790,16 @@ async function renderBookAiSettings() {
2716
2790
  api(`/api/works/${state.work.id}/task-defaults`)
2717
2791
  ]);
2718
2792
  const host = $("#module-content");
2719
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge"]);
2793
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge", "read_character_sections"]);
2720
2794
  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
2795
  host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
2722
2796
  "beforebegin",
2723
2797
  `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
2724
2798
  );
2799
+ host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
2800
+ "afterend",
2801
+ `<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>`
2802
+ );
2725
2803
  $("#save-work-system-prompt").addEventListener("click", async () => {
2726
2804
  const button = $("#save-work-system-prompt");
2727
2805
  button.disabled = true;
@@ -3349,8 +3427,218 @@ async function refreshRelationshipSurfaces(characterId = null) {
3349
3427
  await Promise.all(tasks);
3350
3428
  }
3351
3429
 
3430
+ const characterSectionTypeLabels = {
3431
+ overview: "基本档案",
3432
+ appearance: "外貌与生理",
3433
+ abilities: "能力与弱点",
3434
+ personality: "性格与行为",
3435
+ ecology: "生态",
3436
+ background: "背景故事",
3437
+ history: "经历记录",
3438
+ legends: "相关传说",
3439
+ research: "研究记录",
3440
+ notes: "作者备注",
3441
+ custom: "自定义章节"
3442
+ };
3443
+
3444
+ async function discardPendingCharacterAttachments() {
3445
+ const pending = characterSectionPendingAttachments.splice(0);
3446
+ await Promise.all(pending.map(async (attachmentId) => {
3447
+ try { await api(`/api/attachments/${attachmentId}`, { method: "DELETE" }); } catch { /* 已被引用的附件由正常引用生命周期管理。 */ }
3448
+ }));
3449
+ }
3450
+
3451
+ function scheduleCharacterSectionPreview() {
3452
+ if (characterSectionPreviewTimer !== null) clearTimeout(characterSectionPreviewTimer);
3453
+ characterSectionPreviewTimer = window.setTimeout(() => {
3454
+ characterSectionPreviewTimer = null;
3455
+ const preview = $("#character-section-preview");
3456
+ const content = $("#character-section-markdown");
3457
+ if (preview && content) preview.innerHTML = renderMarkdown(content.value) || '<p class="character-markdown-empty">预览区域暂无内容。</p>';
3458
+ }, 260);
3459
+ }
3460
+
3461
+ function characterSectionEditorHtml(section = null) {
3462
+ const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
3463
+ return `<section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
3464
+ <div class="character-markdown-editor-meta">
3465
+ <label>章节类型<select id="character-section-type">${options}</select></label>
3466
+ <label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
3467
+ <label class="character-markdown-summary-field">章节摘要<textarea id="character-section-summary" maxlength="20000" placeholder="用于角色列表和 AI 快速定位,不会替代正文">${esc(section?.summary ?? "")}</textarea></label>
3468
+ </div>
3469
+ <div class="character-markdown-toolbar">
3470
+ <label class="ghost-button" for="character-section-attachment">上传并插入图片</label>
3471
+ <input id="character-section-attachment" class="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.gif,image/png,image/jpeg,image/webp,image/gif">
3472
+ <span>图片会优先转换为体积更小的无损 WebP</span>
3473
+ </div>
3474
+ <div class="character-markdown-compose">
3475
+ <label>Markdown 原文<textarea id="character-section-markdown" maxlength="500000" spellcheck="true" placeholder="支持标题、列表、引用、表格、链接和图片">${esc(section?.contentMarkdown ?? "")}</textarea></label>
3476
+ <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>
3477
+ </div>
3478
+ <label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
3479
+ <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>
3480
+ </section>`;
3481
+ }
3482
+
3483
+ async function openCharacterSectionEditor(section = null) {
3484
+ await discardPendingCharacterAttachments();
3485
+ const host = $("#character-markdown-sections");
3486
+ if (!host) return;
3487
+ host.innerHTML = characterSectionEditorHtml(section);
3488
+ const textarea = $("#character-section-markdown");
3489
+ textarea.addEventListener("input", scheduleCharacterSectionPreview);
3490
+ $("#character-section-attachment").addEventListener("change", async (event) => {
3491
+ const file = event.target.files?.[0];
3492
+ if (!file) return;
3493
+ const input = event.currentTarget;
3494
+ input.disabled = true;
3495
+ const body = new FormData();
3496
+ body.append("file", file);
3497
+ try {
3498
+ const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
3499
+ if (!attachment.deduplicated) characterSectionPendingAttachments.push(String(attachment.id));
3500
+ const imageLabel = file.name.replace(/[\]\r\n]/gu, "").trim() || "图片附件";
3501
+ const insertion = `![${imageLabel}](attachment://${attachment.id})`;
3502
+ const start = textarea.selectionStart ?? textarea.value.length;
3503
+ const end = textarea.selectionEnd ?? start;
3504
+ const prefix = start > 0 && !textarea.value.slice(0, start).endsWith("\n") ? "\n\n" : "";
3505
+ const suffix = end < textarea.value.length && !textarea.value.slice(end).startsWith("\n") ? "\n\n" : "";
3506
+ textarea.setRangeText(`${prefix}${insertion}${suffix}`, start, end, "end");
3507
+ textarea.focus();
3508
+ scheduleCharacterSectionPreview();
3509
+ toast(attachment.storedMimeType === "image/webp" ? "图片已转换为无损 WebP 并插入" : "图片已插入;转换后未变小,因此保留原格式");
3510
+ } catch (error) {
3511
+ toast(error.message, "error");
3512
+ } finally {
3513
+ input.disabled = false;
3514
+ input.value = "";
3515
+ }
3516
+ });
3517
+ host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", async () => {
3518
+ await discardPendingCharacterAttachments();
3519
+ renderCharacterMarkdownSections();
3520
+ });
3521
+ host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
3522
+ const button = event.currentTarget;
3523
+ const title = $("#character-section-title").value.trim();
3524
+ if (!title) {
3525
+ toast("请填写章节标题", "error");
3526
+ $("#character-section-title").focus();
3527
+ return;
3528
+ }
3529
+ button.disabled = true;
3530
+ const contentMarkdown = textarea.value;
3531
+ try {
3532
+ const saved = await api(section ? `/api/character-sections/${section.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
3533
+ method: section ? "PATCH" : "POST",
3534
+ body: {
3535
+ sectionType: $("#character-section-type").value,
3536
+ title,
3537
+ summary: $("#character-section-summary").value.trim(),
3538
+ contentMarkdown,
3539
+ ...(section ? { changeNote: $("#character-section-change-note").value.trim() } : {})
3540
+ }
3541
+ });
3542
+ const referenced = new Set([...contentMarkdown.matchAll(/attachment:\/\/([A-Za-z0-9_-]+)/gu)].map((match) => String(match[1])));
3543
+ const unused = characterSectionPendingAttachments.filter((attachmentId) => !referenced.has(attachmentId));
3544
+ characterSectionPendingAttachments = [];
3545
+ await Promise.all(unused.map((attachmentId) => api(`/api/attachments/${attachmentId}`, { method: "DELETE" }).catch(() => null)));
3546
+ characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3547
+ renderCharacterMarkdownSections();
3548
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3549
+ toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
3550
+ } catch (error) {
3551
+ toast(error.message, "error");
3552
+ button.disabled = false;
3553
+ }
3554
+ });
3555
+ }
3556
+
3557
+ async function showCharacterSectionVersions(sectionId) {
3558
+ const host = document.querySelector(`[data-character-section-versions-host="${CSS.escape(sectionId)}"]`);
3559
+ if (!host) return;
3560
+ host.innerHTML = '<p class="character-markdown-status">正在读取章节版本……</p>';
3561
+ try {
3562
+ const versions = await api(`/api/character-sections/${sectionId}/versions`);
3563
+ 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>`;
3564
+ host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
3565
+ button.disabled = true;
3566
+ try {
3567
+ await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
3568
+ characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3569
+ renderCharacterMarkdownSections();
3570
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3571
+ toast("人物 Markdown 章节已恢复");
3572
+ } catch (error) {
3573
+ button.disabled = false;
3574
+ toast(error.message, "error");
3575
+ }
3576
+ }));
3577
+ } catch (error) {
3578
+ host.innerHTML = `<p class="character-markdown-status">版本载入失败:${esc(error.message)}</p>`;
3579
+ }
3580
+ }
3581
+
3582
+ function renderCharacterMarkdownSections() {
3583
+ const host = $("#character-markdown-sections");
3584
+ if (!host) return;
3585
+ if (!characterEditorItem?.id) {
3586
+ host.innerHTML = '<div class="character-editor-empty-field"><b>Markdown 档案章节</b><span>创建人物档案后即可添加背景故事、能力、经历和研究记录。</span></div>';
3587
+ return;
3588
+ }
3589
+ 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>`;
3590
+ const sections = characterEditorSections.map((section) => `<article class="character-markdown-section">
3591
+ <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>
3592
+ <div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
3593
+ <div data-character-section-versions-host="${esc(section.id)}"></div>
3594
+ </article>`).join("");
3595
+ host.innerHTML = `${toolbar}${sections || '<p class="character-markdown-status">还没有 Markdown 档案章节。</p>'}`;
3596
+ host.querySelector("[data-character-section-create]")?.addEventListener("click", () => void openCharacterSectionEditor());
3597
+ host.querySelectorAll("[data-character-section-edit]").forEach((button) => button.addEventListener("click", () => {
3598
+ const section = characterEditorSections.find((item) => item.id === button.dataset.characterSectionEdit);
3599
+ if (section) void openCharacterSectionEditor(section);
3600
+ }));
3601
+ host.querySelectorAll("[data-character-section-versions]").forEach((button) => button.addEventListener("click", () => void showCharacterSectionVersions(button.dataset.characterSectionVersions)));
3602
+ host.querySelectorAll("[data-character-section-delete]").forEach((button) => button.addEventListener("click", async () => {
3603
+ if (button.dataset.confirmed !== "true") {
3604
+ button.dataset.confirmed = "true";
3605
+ button.textContent = "确认删除";
3606
+ window.setTimeout(() => {
3607
+ if (button.isConnected && button.dataset.confirmed === "true") {
3608
+ button.dataset.confirmed = "false";
3609
+ button.textContent = "删除";
3610
+ }
3611
+ }, 5000);
3612
+ return;
3613
+ }
3614
+ button.disabled = true;
3615
+ try {
3616
+ await api(`/api/character-sections/${button.dataset.characterSectionDelete}`, { method: "DELETE" });
3617
+ characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3618
+ renderCharacterMarkdownSections();
3619
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3620
+ toast("人物 Markdown 章节已删除");
3621
+ } catch (error) {
3622
+ button.disabled = false;
3623
+ toast(error.message, "error");
3624
+ }
3625
+ }));
3626
+ }
3627
+
3628
+ async function loadCharacterMarkdownSections(characterId) {
3629
+ characterEditorSections = [];
3630
+ renderCharacterMarkdownSections();
3631
+ try {
3632
+ characterEditorSections = await api(`/api/characters/${characterId}/sections`);
3633
+ if (characterEditorItem?.id === characterId) renderCharacterMarkdownSections();
3634
+ } catch (error) {
3635
+ const host = $("#character-markdown-sections");
3636
+ if (host && characterEditorItem?.id === characterId) host.innerHTML = `<p class="character-markdown-status">章节载入失败:${esc(error.message)}</p>`;
3637
+ }
3638
+ }
3639
+
3352
3640
  function renderCharacterEditorFields(item) {
3353
- const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, race.name])];
3641
+ const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
3354
3642
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
3355
3643
  const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
3356
3644
  const stateEntries = characterStateEntries(item?.currentState ?? {});
@@ -3372,7 +3660,7 @@ function renderCharacterEditorFields(item) {
3372
3660
  field("summary", "人物简介", "textarea", item?.profile?.summary)),
3373
3661
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
3374
3662
  field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
3375
- field("sections", "设定章节", "section-list", item?.profile?.sections)),
3663
+ '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
3376
3664
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
3377
3665
  field("currentState", "当前状态", "key-value-list", stateEntries, {
3378
3666
  keyName: "stateKey",
@@ -3393,11 +3681,14 @@ function renderCharacterEditorFields(item) {
3393
3681
  if (name) name.required = true;
3394
3682
  bindDynamicListControls($("#character-editor-fields"));
3395
3683
  renderCharacterEditorRelationships();
3684
+ renderCharacterMarkdownSections();
3396
3685
  activateCharacterEditorTab("basic");
3397
3686
  }
3398
3687
 
3399
3688
  function collectCharacterBody(form) {
3400
3689
  const item = characterEditorItem;
3690
+ const profile = { ...(item?.profile ?? {}) };
3691
+ delete profile.sections;
3401
3692
  return {
3402
3693
  name: String(form.get("name") ?? "").trim(),
3403
3694
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
@@ -3409,10 +3700,9 @@ function collectCharacterBody(form) {
3409
3700
  details: buildCharacterDetails(form.getAll("detailLabel"), form.getAll("detailValue"))
3410
3701
  },
3411
3702
  profile: {
3412
- ...(item?.profile ?? {}),
3703
+ ...profile,
3413
3704
  motivation: String(form.get("motivation") ?? "").trim(),
3414
- summary: String(form.get("summary") ?? "").trim(),
3415
- sections: buildCharacterSections(form.getAll("sectionTitle"), form.getAll("sectionContent"))
3705
+ summary: String(form.get("summary") ?? "").trim()
3416
3706
  },
3417
3707
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
3418
3708
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
@@ -3498,6 +3788,7 @@ async function openCharacterDialog(item) {
3498
3788
  characterEditorVersions = [];
3499
3789
  characterEditorRelationships = [];
3500
3790
  characterEditorRelationshipsLoading = Boolean(item);
3791
+ characterEditorSections = [];
3501
3792
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
3502
3793
  $("#character-editor-title").textContent = item?.name || "新建角色";
3503
3794
  $("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
@@ -3543,20 +3834,27 @@ async function openCharacterDialog(item) {
3543
3834
  }
3544
3835
  };
3545
3836
  dialog.showModal();
3546
- if (item) void loadCharacterEditorRelationships(item.id);
3837
+ if (item) {
3838
+ void loadCharacterEditorRelationships(item.id);
3839
+ void loadCharacterMarkdownSections(item.id);
3840
+ }
3547
3841
  }
3548
3842
 
3549
3843
  async function openRaceDialog(item) {
3550
3844
  state.characters = await api(`/api/works/${state.work.id}/characters`);
3551
3845
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3846
+ const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
3847
+ .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
3848
+ .map((race) => [race.id, racePathLabel(race)])];
3552
3849
  openDialog(item ? "编辑种族" : "新建种族",
3553
3850
  field("name", "种族名称", "text", item?.name) +
3851
+ field("parentRaceId", "父种族", "select", item?.parentRaceId ?? "", parentOptions) +
3554
3852
  field("description", "种族简介", "textarea", item?.description) +
3555
3853
  field("settings", "种族共同设定(逐条填写)", "item-list", item?.settings ?? []) +
3556
3854
  (memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
3557
3855
  async (form) => {
3558
3856
  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) };
3857
+ const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
3560
3858
  await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
3561
3859
  await renderRaces();
3562
3860
  await loadAiReferences();
@@ -3690,8 +3988,8 @@ function openReviewDialog() {
3690
3988
 
3691
3989
  function openTaskDialog() {
3692
3990
  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") };
3991
+ openDialog("开始 AI 分析", field("taskType", "分析类型", "select", "chapter-analysis", [["chapter-analysis", "章节理解"], ["character-extraction", "全书角色抽取"], ["character-identity-audit", "AI 角色查重"], ["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) => {
3992
+ const scope = form.get("taskType") === "character-identity-audit" || form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
3695
3993
  await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
3696
3994
  await renderTasks();
3697
3995
  });
@@ -4292,6 +4590,7 @@ $("#entity-history-close").addEventListener("click", () => $("#entity-history-di
4292
4590
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
4293
4591
  $("#character-editor-close").addEventListener("click", () => $("#character-editor-dialog").close());
4294
4592
  $("#character-editor-cancel").addEventListener("click", () => $("#character-editor-dialog").close());
4593
+ $("#character-editor-dialog").addEventListener("close", () => { void discardPendingCharacterAttachments(); });
4295
4594
  $("#character-history-button").addEventListener("click", () => {
4296
4595
  if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
4297
4596
  else setCharacterHistoryVisible(false);