@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.
- package/dist/ai.js +223 -15
- package/dist/ai.js.map +1 -1
- package/dist/app.js +129 -9
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +162 -0
- package/dist/attachment-storage.js.map +1 -0
- package/dist/cli-contract.js +4 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +219 -1
- package/dist/database.js.map +1 -1
- package/dist/public/analysis-types.d.ts +8 -0
- package/dist/public/analysis-types.js +53 -0
- package/dist/public/app.js +336 -29
- package/dist/public/index.html +2 -2
- package/dist/public/markdown.js +16 -1
- package/dist/public/race-hierarchy.js +49 -0
- package/dist/public/relationship-graph.js +205 -31
- package/dist/public/styles.css +89 -3
- package/dist/server-runtime.js +1 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +565 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +7 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/dist/public/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=
|
|
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=
|
|
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,
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
2374
|
-
<
|
|
2375
|
-
|
|
2376
|
-
<
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
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
|
|
2492
|
-
|
|
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 = ``;
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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)
|
|
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
|
-
|
|
3694
|
-
|
|
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);
|