@musnows/scriverse 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.js +209 -17
- package/dist/ai.js.map +1 -1
- package/dist/app.js +53 -5
- package/dist/app.js.map +1 -1
- package/dist/public/ai-message-meta.js +7 -2
- package/dist/public/app.js +207 -22
- package/dist/public/index.html +8 -9
- package/dist/public/race-hierarchy.js +36 -0
- package/dist/public/relationship-graph.js +32 -10
- package/dist/public/styles.css +57 -11
- package/dist/store.js +21 -6
- package/dist/store.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=
|
|
1
|
+
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260726-network-theme-palette";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
3
|
import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
|
|
4
4
|
import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
|
|
@@ -6,7 +6,7 @@ import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-a
|
|
|
6
6
|
import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
7
7
|
import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
|
|
8
8
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
9
|
-
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=
|
|
9
|
+
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
|
|
10
10
|
import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
|
|
11
11
|
import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
|
|
12
12
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
|
|
36
36
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
37
37
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
38
|
-
import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hierarchy.js?v=
|
|
38
|
+
import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260726-race-filter-order";
|
|
39
39
|
import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
|
|
40
40
|
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
|
|
41
41
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
@@ -104,6 +104,8 @@ let peerPageStale = false;
|
|
|
104
104
|
let collaborationAutoSaveDisabled = false;
|
|
105
105
|
|
|
106
106
|
let timelineMultiSelectEnabled = false;
|
|
107
|
+
let taskProgressRefreshTimer = null;
|
|
108
|
+
const taskProgressRefreshInterval = 2_500;
|
|
107
109
|
|
|
108
110
|
const chapterTypes = ["正文", "设定", "作者的话", "其他"];
|
|
109
111
|
|
|
@@ -2989,6 +2991,7 @@ async function showModule(module) {
|
|
|
2989
2991
|
if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
|
|
2990
2992
|
if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
|
|
2991
2993
|
state.module = module;
|
|
2994
|
+
if (module !== "tasks") stopTaskProgressRefresh();
|
|
2992
2995
|
applyWorkAccessMode();
|
|
2993
2996
|
markActiveModule(module);
|
|
2994
2997
|
if (module === "editor") {
|
|
@@ -3501,7 +3504,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3501
3504
|
return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
|
|
3502
3505
|
}).join("");
|
|
3503
3506
|
const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
|
|
3504
|
-
<details class="character-filter-dropdown"><summary><span>按种族筛选</span><strong>${selectedRaceNames.length ? `已选 ${selectedRaceNames.length} 项` : "全部种族"}</strong></summary><div id="character-race-filter" class="character-filter-options">${filterOptionList(races, selectedRaceIds)}</div></details>
|
|
3507
|
+
<details class="character-filter-dropdown"><summary><span>按种族筛选</span><strong>${selectedRaceNames.length ? `已选 ${selectedRaceNames.length} 项` : "全部种族"}</strong></summary><div id="character-race-filter" class="character-filter-options">${filterOptionList(orderRaceFilterOptions(races), selectedRaceIds)}</div></details>
|
|
3505
3508
|
<details class="character-filter-dropdown"><summary><span>按组织筛选</span><strong>${selectedOrganizationNames.length ? `已选 ${selectedOrganizationNames.length} 项` : "全部组织"}</strong></summary><div id="character-organization-filter" class="character-filter-options">${filterOptionList(organizations, selectedOrganizationIds, "id")}</div></details>
|
|
3506
3509
|
<div class="character-filter-toolbar-actions">${hasCharacterFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${characterPage.total} 个角色</span>` : ""}<button id="clear-character-filters" class="ghost-button" type="button" ${hasCharacterFilters ? "" : "disabled"}>重置筛选</button></div>
|
|
3507
3510
|
</section>`;
|
|
@@ -3541,10 +3544,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3541
3544
|
}
|
|
3542
3545
|
|
|
3543
3546
|
async function renderRaces() {
|
|
3544
|
-
|
|
3545
|
-
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
3546
|
-
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
3547
|
-
]);
|
|
3547
|
+
state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
|
|
3548
3548
|
mountModuleCount(state.races.length);
|
|
3549
3549
|
const layout = readModuleLayout();
|
|
3550
3550
|
const canEditRaces = canEditModule("races");
|
|
@@ -3748,6 +3748,7 @@ async function renderOutlines() {
|
|
|
3748
3748
|
async function renderRelationships() {
|
|
3749
3749
|
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
3750
3750
|
const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
|
|
3751
|
+
const canEditRelationships = canEditModule("relationships");
|
|
3751
3752
|
mountModuleCount(relationships.length);
|
|
3752
3753
|
const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
|
|
3753
3754
|
state.galaxy?.destroy();
|
|
@@ -3757,7 +3758,7 @@ async function renderRelationships() {
|
|
|
3757
3758
|
state.relationshipGraph = graph;
|
|
3758
3759
|
$("#module-content").innerHTML = `<div id="relationship-map-host"></div>${relationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${relationships.map((item) => `
|
|
3759
3760
|
<tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
|
|
3760
|
-
<td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions"
|
|
3761
|
+
<td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
|
|
3761
3762
|
const openGalaxy = () => {
|
|
3762
3763
|
state.galaxy?.destroy();
|
|
3763
3764
|
state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
|
|
@@ -3870,6 +3871,7 @@ async function renderReviews() {
|
|
|
3870
3871
|
}
|
|
3871
3872
|
|
|
3872
3873
|
async function renderTasks() {
|
|
3874
|
+
stopTaskProgressRefresh();
|
|
3873
3875
|
const [tasks, settings] = await Promise.all([
|
|
3874
3876
|
apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
|
|
3875
3877
|
canReadModule("ai-settings")
|
|
@@ -3879,7 +3881,12 @@ async function renderTasks() {
|
|
|
3879
3881
|
mountModuleCount(tasks.length);
|
|
3880
3882
|
const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
|
|
3881
3883
|
const pendingCount = tasks.filter((item) => item.status === "pending").length;
|
|
3882
|
-
const
|
|
3884
|
+
const runningTasks = tasks.filter((item) => item.status === "running");
|
|
3885
|
+
const runningCount = runningTasks.length;
|
|
3886
|
+
const activeTaskCount = pendingCount + runningCount;
|
|
3887
|
+
const runningProgress = runningCount
|
|
3888
|
+
? Math.round(runningTasks.reduce((total, item) => total + Math.min(100, Math.max(0, Number(item.progress) || 0)), 0) / runningCount)
|
|
3889
|
+
: 0;
|
|
3883
3890
|
$("#module-content").innerHTML = `
|
|
3884
3891
|
<section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
|
|
3885
3892
|
<div class="task-auto-run-copy">
|
|
@@ -3895,6 +3902,10 @@ async function renderTasks() {
|
|
|
3895
3902
|
<button id="task-auto-run-continue" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>开始下一轮</button>
|
|
3896
3903
|
</div>
|
|
3897
3904
|
<p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个</p>
|
|
3905
|
+
<div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
|
|
3906
|
+
<div class="task-auto-run-progress-label"><span>${runningCount ? "运行中任务平均进度" : "等待任务开始"}</span><strong>${runningProgress}%</strong></div>
|
|
3907
|
+
<progress class="task-auto-run-progress-bar" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
|
|
3908
|
+
</div>
|
|
3898
3909
|
</section>
|
|
3899
3910
|
${tasks.length ? `<table class="table-list task-table"><thead><tr><th>分析类型</th><th>范围</th><th>状态</th><th>进度</th><th>操作</th></tr></thead><tbody>${tasks.map((item) => `
|
|
3900
3911
|
<tr>
|
|
@@ -3958,6 +3969,7 @@ async function renderTasks() {
|
|
|
3958
3969
|
button.textContent = "运行中";
|
|
3959
3970
|
const cancel = button.parentElement.querySelector("[data-cancel-task]");
|
|
3960
3971
|
if (cancel) cancel.textContent = "取消运行";
|
|
3972
|
+
scheduleTaskProgressRefresh(workId, 1);
|
|
3961
3973
|
const completed = await api(`/api/tasks/${button.dataset.runTask}/run`, { method: "POST", body: { modelId: $("#ai-model").value || undefined } });
|
|
3962
3974
|
toast(completed.status === "cancelled" ? "分析任务已取消" : completed.status === "expired" ? "正文已变化,本次分析已过期" : "分析已完成");
|
|
3963
3975
|
if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
|
|
@@ -3977,6 +3989,32 @@ async function renderTasks() {
|
|
|
3977
3989
|
button.disabled = false;
|
|
3978
3990
|
}
|
|
3979
3991
|
}));
|
|
3992
|
+
scheduleTaskProgressRefresh(state.work.id, runningCount);
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
function stopTaskProgressRefresh() {
|
|
3996
|
+
if (taskProgressRefreshTimer === null) return;
|
|
3997
|
+
window.clearTimeout(taskProgressRefreshTimer);
|
|
3998
|
+
taskProgressRefreshTimer = null;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
function scheduleTaskProgressRefresh(workId, runningCount) {
|
|
4002
|
+
stopTaskProgressRefresh();
|
|
4003
|
+
if (runningCount === 0) return;
|
|
4004
|
+
taskProgressRefreshTimer = window.setTimeout(async () => {
|
|
4005
|
+
taskProgressRefreshTimer = null;
|
|
4006
|
+
if (state.module !== "tasks" || state.work?.id !== workId) return;
|
|
4007
|
+
if ($(".task-auto-run-controls")?.contains(document.activeElement)) {
|
|
4008
|
+
scheduleTaskProgressRefresh(workId, runningCount);
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
try {
|
|
4012
|
+
await renderTasks();
|
|
4013
|
+
} catch (error) {
|
|
4014
|
+
console.error("Failed to refresh task progress", error);
|
|
4015
|
+
scheduleTaskProgressRefresh(workId, runningCount);
|
|
4016
|
+
}
|
|
4017
|
+
}, taskProgressRefreshInterval);
|
|
3980
4018
|
}
|
|
3981
4019
|
|
|
3982
4020
|
function openTaskDetailDialog(task) {
|
|
@@ -4532,6 +4570,8 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
4532
4570
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
4533
4571
|
bindVditorEditors($("#dialog-fields"));
|
|
4534
4572
|
const form = $("#dynamic-form");
|
|
4573
|
+
form.onclick = null;
|
|
4574
|
+
form.onkeydown = null;
|
|
4535
4575
|
form.onsubmit = async (event) => {
|
|
4536
4576
|
if (event.submitter?.value === "cancel") {
|
|
4537
4577
|
void discardPendingMarkdownAttachments();
|
|
@@ -5891,11 +5931,30 @@ async function openRelationshipDialog(item, options = {}) {
|
|
|
5891
5931
|
const characterOptions = state.characters.map((item) => [item.id, item.name]);
|
|
5892
5932
|
const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
|
|
5893
5933
|
const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
|
|
5894
|
-
|
|
5934
|
+
const relationshipName = item ? `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}` : "";
|
|
5935
|
+
const management = item && canEditModule("relationships") ? `<section class="entity-dialog-management" aria-label="人物关系操作">
|
|
5936
|
+
<div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
|
|
5937
|
+
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
|
|
5938
|
+
</section>` : "";
|
|
5939
|
+
openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? defaultFrom, characterOptions) + field("to", "终点人物", "select", item?.toCharacterId ?? defaultTo, characterOptions) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词", "keyword-chips", item?.keywords ?? []) + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false) + management, async (form) => {
|
|
5895
5940
|
const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
|
|
5896
5941
|
await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) } });
|
|
5897
5942
|
await refreshRelationshipSurfaces(options.characterId ?? null);
|
|
5898
5943
|
}, item ? "关系档案" : "人工确认关系");
|
|
5944
|
+
$("#dialog-fields").querySelector("[data-dialog-relationship-delete]")?.addEventListener("click", async () => {
|
|
5945
|
+
const dialog = $("#form-dialog");
|
|
5946
|
+
dialog.close();
|
|
5947
|
+
if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return dialog.showModal();
|
|
5948
|
+
if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return dialog.showModal();
|
|
5949
|
+
try {
|
|
5950
|
+
await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
5951
|
+
await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
|
|
5952
|
+
toast(`已删除人物关系“${relationshipName}”`);
|
|
5953
|
+
} catch (error) {
|
|
5954
|
+
dialog.showModal();
|
|
5955
|
+
toast(error.message, "error");
|
|
5956
|
+
}
|
|
5957
|
+
});
|
|
5899
5958
|
}
|
|
5900
5959
|
|
|
5901
5960
|
function openReviewDialog() {
|
|
@@ -5905,14 +5964,58 @@ function openReviewDialog() {
|
|
|
5905
5964
|
});
|
|
5906
5965
|
}
|
|
5907
5966
|
|
|
5908
|
-
function openTaskDialog() {
|
|
5967
|
+
async function openTaskDialog() {
|
|
5909
5968
|
const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
|
|
5969
|
+
let relationshipCharacters = [];
|
|
5970
|
+
try {
|
|
5971
|
+
relationshipCharacters = canReadModule("characters")
|
|
5972
|
+
? await apiAllPages(`/api/works/${state.work.id}/characters`)
|
|
5973
|
+
: [];
|
|
5974
|
+
} catch (error) {
|
|
5975
|
+
toast(`角色列表加载失败:${error.message}`, "error");
|
|
5976
|
+
return;
|
|
5977
|
+
}
|
|
5978
|
+
const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
|
|
5979
|
+
const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
|
|
5980
|
+
<span id="relationship-character-label">被分析角色(可多选)</span>
|
|
5981
|
+
<div class="relationship-character-picker">
|
|
5982
|
+
<button class="relationship-character-trigger" type="button" aria-expanded="false" aria-controls="relationship-character-bubble" aria-labelledby="relationship-character-label relationship-character-summary">
|
|
5983
|
+
<span id="relationship-character-summary">选择需要定向分析的角色</span>
|
|
5984
|
+
<span class="relationship-character-trigger-meta"><span data-relationship-character-count>未选择</span><span class="relationship-character-chevron" aria-hidden="true">⌄</span></span>
|
|
5985
|
+
</button>
|
|
5986
|
+
<div id="relationship-character-bubble" class="relationship-character-bubble hidden">
|
|
5987
|
+
<label class="relationship-character-search">筛选角色<input type="search" data-relationship-character-search placeholder="输入角色名" autocomplete="off"></label>
|
|
5988
|
+
<div class="relationship-character-bubble-meta"><span>共 ${characterOptions.length} 个角色</span><button type="button" data-relationship-character-clear disabled>清空选择</button></div>
|
|
5989
|
+
<div class="relationship-character-options" role="group" aria-labelledby="relationship-character-label">
|
|
5990
|
+
${characterOptions.map(([characterId, characterName]) => `<label class="relationship-character-chip" data-character-search-name="${esc(String(characterName).toLocaleLowerCase())}"><input type="checkbox" name="characterIds" value="${esc(characterId)}" data-character-name="${esc(characterName)}"><span>${esc(characterName)}</span></label>`).join("")}
|
|
5991
|
+
</div>
|
|
5992
|
+
<p class="relationship-character-empty hidden" data-relationship-character-empty>没有匹配的角色</p>
|
|
5993
|
+
</div>
|
|
5994
|
+
</div>
|
|
5995
|
+
</div>`;
|
|
5910
5996
|
const defaultTaskType = ANALYSIS_TYPES[0].value;
|
|
5911
5997
|
const taskTypeField = `<div class="form-field analysis-type-field"><label>分析类型<select name="taskType" aria-describedby="analysis-type-description">${ANALYSIS_TYPES.map(({ value, label }) => `<option value="${esc(value)}" ${value === defaultTaskType ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label><p id="analysis-type-description" class="analysis-type-description" aria-live="polite">${esc(analysisTypeDescription(defaultTaskType))}</p></div>`;
|
|
5912
5998
|
const chapterField = `<label class="task-chapter-field">章节<select name="chapterId">${chapterOptions.map(([key, text], index) => `<option value="${esc(key)}" ${index === 0 ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5999
|
+
const relationshipFields = `<div class="relationship-analysis-options hidden">
|
|
6000
|
+
${relationshipCharacterPicker}
|
|
6001
|
+
<p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。</span></p>
|
|
6002
|
+
<div class="relationship-overwrite-card hidden">
|
|
6003
|
+
<label class="checkbox-field"><input name="replaceExistingRelationships" type="checkbox" disabled><span>用本次结果覆盖所选角色的已有关系</span></label>
|
|
6004
|
+
<p>任务成功后,会先删除所有涉及所选角色的旧关系,再写入本次分析结果。</p>
|
|
6005
|
+
</div>
|
|
6006
|
+
<label>额外分析提示<textarea name="additionalPrompt" maxlength="10000" placeholder="例如:重点识别权力继承、师承变化或隐秘亲缘关系"></textarea><small>将同时追加到证据收集和全局关系归纳提示词,仅影响本次任务。</small></label>
|
|
6007
|
+
</div>`;
|
|
6008
|
+
openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
|
|
6009
|
+
const taskType = String(form.get("taskType"));
|
|
6010
|
+
const scopeType = String(form.get("scopeType"));
|
|
6011
|
+
const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
|
|
6012
|
+
const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
|
|
6013
|
+
const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
|
|
6014
|
+
const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
|
|
6015
|
+
const scope = taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
|
|
6016
|
+
? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
|
|
6017
|
+
: { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
|
|
6018
|
+
await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope } });
|
|
5916
6019
|
await renderTasks();
|
|
5917
6020
|
});
|
|
5918
6021
|
const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
|
|
@@ -5920,17 +6023,99 @@ function openTaskDialog() {
|
|
|
5920
6023
|
const chapterSelect = $("#dialog-fields").querySelector('select[name="chapterId"]');
|
|
5921
6024
|
const chapterFieldElement = chapterSelect.closest(".task-chapter-field");
|
|
5922
6025
|
const description = $("#analysis-type-description");
|
|
6026
|
+
const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
|
|
6027
|
+
const relationshipPrompt = relationshipOptions.querySelector('textarea[name="additionalPrompt"]');
|
|
6028
|
+
const relationshipCharacterPickerElement = relationshipOptions.querySelector(".relationship-character-picker");
|
|
6029
|
+
const relationshipCharacterTrigger = relationshipOptions.querySelector(".relationship-character-trigger");
|
|
6030
|
+
const relationshipCharacterBubble = relationshipOptions.querySelector(".relationship-character-bubble");
|
|
6031
|
+
const relationshipCharacterSearch = relationshipOptions.querySelector("[data-relationship-character-search]");
|
|
6032
|
+
const relationshipCharacterInputs = [...relationshipOptions.querySelectorAll('input[name="characterIds"]')];
|
|
6033
|
+
const relationshipCharacterSummary = relationshipOptions.querySelector("#relationship-character-summary");
|
|
6034
|
+
const relationshipCharacterCount = relationshipOptions.querySelector("[data-relationship-character-count]");
|
|
6035
|
+
const relationshipCharacterClear = relationshipOptions.querySelector("[data-relationship-character-clear]");
|
|
6036
|
+
const relationshipCharacterEmpty = relationshipOptions.querySelector("[data-relationship-character-empty]");
|
|
6037
|
+
const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
|
|
6038
|
+
const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-overwrite-card");
|
|
6039
|
+
const allSettingsOption = document.createElement("option");
|
|
6040
|
+
allSettingsOption.value = "book-with-settings";
|
|
6041
|
+
allSettingsOption.textContent = "全书 + 所有设定";
|
|
5923
6042
|
const syncChapterField = () => {
|
|
5924
|
-
const disabled = scopeTypeSelect.value
|
|
6043
|
+
const disabled = scopeTypeSelect.value !== "chapter";
|
|
5925
6044
|
chapterSelect.disabled = disabled;
|
|
5926
6045
|
chapterFieldElement.classList.toggle("is-disabled", disabled);
|
|
5927
6046
|
chapterFieldElement.setAttribute("aria-disabled", String(disabled));
|
|
5928
6047
|
};
|
|
6048
|
+
const setRelationshipCharacterBubbleOpen = (open) => {
|
|
6049
|
+
relationshipCharacterBubble.classList.toggle("hidden", !open);
|
|
6050
|
+
relationshipCharacterTrigger.setAttribute("aria-expanded", String(open));
|
|
6051
|
+
if (open) relationshipCharacterSearch.focus();
|
|
6052
|
+
};
|
|
6053
|
+
const syncRelationshipCharacterPicker = () => {
|
|
6054
|
+
const selected = relationshipCharacterInputs.filter((input) => input.checked);
|
|
6055
|
+
const selectedNames = selected.map((input) => input.dataset.characterName);
|
|
6056
|
+
relationshipCharacterSummary.textContent = selectedNames.length
|
|
6057
|
+
? `${selectedNames.slice(0, 2).join("、")}${selectedNames.length > 2 ? ` 等 ${selectedNames.length} 人` : ""}`
|
|
6058
|
+
: "选择需要定向分析的角色";
|
|
6059
|
+
relationshipCharacterCount.textContent = selected.length ? `已选 ${selected.length}` : "未选择";
|
|
6060
|
+
relationshipCharacterClear.disabled = selected.length === 0;
|
|
6061
|
+
relationshipCharacterTrigger.setAttribute("aria-label", `筛选被分析角色,已选择 ${selected.length} 个`);
|
|
6062
|
+
};
|
|
6063
|
+
const filterRelationshipCharacters = () => {
|
|
6064
|
+
const query = relationshipCharacterSearch.value.trim().toLocaleLowerCase();
|
|
6065
|
+
let visibleCount = 0;
|
|
6066
|
+
for (const input of relationshipCharacterInputs) {
|
|
6067
|
+
const chip = input.closest(".relationship-character-chip");
|
|
6068
|
+
const visible = !query || chip.dataset.characterSearchName.includes(query);
|
|
6069
|
+
chip.classList.toggle("hidden", !visible);
|
|
6070
|
+
if (visible) visibleCount += 1;
|
|
6071
|
+
}
|
|
6072
|
+
relationshipCharacterEmpty.classList.toggle("hidden", visibleCount > 0);
|
|
6073
|
+
};
|
|
6074
|
+
const syncRelationshipOptions = () => {
|
|
6075
|
+
const enabled = taskTypeSelect.value === "relationship-analysis";
|
|
6076
|
+
if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
|
|
6077
|
+
if (!enabled && allSettingsOption.isConnected) {
|
|
6078
|
+
if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
|
|
6079
|
+
allSettingsOption.remove();
|
|
6080
|
+
}
|
|
6081
|
+
relationshipOptions.classList.toggle("hidden", !enabled);
|
|
6082
|
+
relationshipPrompt.disabled = !enabled;
|
|
6083
|
+
relationshipCharacterTrigger.disabled = !enabled;
|
|
6084
|
+
relationshipCharacterSearch.disabled = !enabled;
|
|
6085
|
+
for (const input of relationshipCharacterInputs) input.disabled = !enabled;
|
|
6086
|
+
if (!enabled) setRelationshipCharacterBubbleOpen(false);
|
|
6087
|
+
const hasSelectedCharacters = enabled && relationshipCharacterInputs.some((input) => input.checked);
|
|
6088
|
+
replaceRelationships.disabled = !hasSelectedCharacters;
|
|
6089
|
+
relationshipOverwriteCard.classList.toggle("hidden", !hasSelectedCharacters);
|
|
6090
|
+
if (!hasSelectedCharacters) replaceRelationships.checked = false;
|
|
6091
|
+
syncRelationshipCharacterPicker();
|
|
6092
|
+
filterRelationshipCharacters();
|
|
6093
|
+
syncChapterField();
|
|
6094
|
+
};
|
|
5929
6095
|
taskTypeSelect.addEventListener("change", () => {
|
|
5930
6096
|
description.textContent = analysisTypeDescription(taskTypeSelect.value);
|
|
6097
|
+
syncRelationshipOptions();
|
|
5931
6098
|
});
|
|
6099
|
+
relationshipCharacterTrigger.addEventListener("click", () => {
|
|
6100
|
+
setRelationshipCharacterBubbleOpen(relationshipCharacterTrigger.getAttribute("aria-expanded") !== "true");
|
|
6101
|
+
});
|
|
6102
|
+
relationshipCharacterSearch.addEventListener("input", filterRelationshipCharacters);
|
|
6103
|
+
for (const input of relationshipCharacterInputs) input.addEventListener("change", syncRelationshipOptions);
|
|
6104
|
+
relationshipCharacterClear.addEventListener("click", () => {
|
|
6105
|
+
for (const input of relationshipCharacterInputs) input.checked = false;
|
|
6106
|
+
syncRelationshipOptions();
|
|
6107
|
+
});
|
|
6108
|
+
$("#dynamic-form").onclick = (event) => {
|
|
6109
|
+
if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
|
|
6110
|
+
};
|
|
6111
|
+
$("#dynamic-form").onkeydown = (event) => {
|
|
6112
|
+
if (event.key !== "Escape" || relationshipCharacterBubble.classList.contains("hidden")) return;
|
|
6113
|
+
event.preventDefault();
|
|
6114
|
+
setRelationshipCharacterBubbleOpen(false);
|
|
6115
|
+
relationshipCharacterTrigger.focus();
|
|
6116
|
+
};
|
|
5932
6117
|
scopeTypeSelect.addEventListener("change", syncChapterField);
|
|
5933
|
-
|
|
6118
|
+
syncRelationshipOptions();
|
|
5934
6119
|
}
|
|
5935
6120
|
|
|
5936
6121
|
function openProviderDialog(item) {
|
|
@@ -6021,7 +6206,7 @@ async function sendAi() {
|
|
|
6021
6206
|
} else {
|
|
6022
6207
|
suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
|
|
6023
6208
|
assistantContent = suggestion.content;
|
|
6024
|
-
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens };
|
|
6209
|
+
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
|
|
6025
6210
|
}
|
|
6026
6211
|
try {
|
|
6027
6212
|
const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
|
|
@@ -6117,9 +6302,9 @@ async function streamChat(body) {
|
|
|
6117
6302
|
toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
|
|
6118
6303
|
processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
|
|
6119
6304
|
const processDurationMs = elapsedProcessTime();
|
|
6120
|
-
generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, toolCalls, processSteps, processDurationMs };
|
|
6305
|
+
generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, cacheHitPercent: payload.cacheHitPercent, toolCalls, processSteps, processDurationMs };
|
|
6121
6306
|
renderAiProcessSteps(message, processSteps, true, processDurationMs);
|
|
6122
|
-
meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens);
|
|
6307
|
+
meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent);
|
|
6123
6308
|
attachAssistantCopyAction(message, streamedText);
|
|
6124
6309
|
scrollAiFeedToBottom();
|
|
6125
6310
|
} else if (eventName === "error") {
|
|
@@ -6174,7 +6359,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
6174
6359
|
const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
|
|
6175
6360
|
const meta = document.createElement("div");
|
|
6176
6361
|
meta.className = "message-meta";
|
|
6177
|
-
meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens);
|
|
6362
|
+
meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent);
|
|
6178
6363
|
message.append(meta);
|
|
6179
6364
|
attachAssistantCopyAction(message, text);
|
|
6180
6365
|
}
|
|
@@ -6189,7 +6374,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
6189
6374
|
const applicable = suggestion.action !== "note";
|
|
6190
6375
|
const guard = suggestion.guard;
|
|
6191
6376
|
const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
|
|
6192
|
-
message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
|
|
6377
|
+
message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
|
|
6193
6378
|
attachMessageHeading(message, "助手建议", createdAt ?? undefined);
|
|
6194
6379
|
attachAssistantCopyAction(message, suggestion.content);
|
|
6195
6380
|
attachMessageIdentity(message, messageId);
|
package/dist/public/index.html
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
|
|
11
11
|
<link rel="manifest" href="/site.webmanifest">
|
|
12
12
|
<link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
|
|
13
|
-
<link rel="stylesheet" href="/styles.css?v=
|
|
13
|
+
<link rel="stylesheet" href="/styles.css?v=20260726-ai-history-cache-analysis-progress">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="auth-pending">
|
|
16
16
|
<section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
|
|
@@ -239,13 +239,12 @@
|
|
|
239
239
|
<div id="ai-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整创作助手宽度" aria-orientation="vertical" tabindex="0"></div>
|
|
240
240
|
<div class="ai-heading">
|
|
241
241
|
<button id="ai-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起创作助手" aria-expanded="true">›</button>
|
|
242
|
-
<div><span class="status-dot"></span><strong>创作助手</strong></div>
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
<button id="ai-history-toggle" type="button" aria-controls="ai-history-dialog" aria-expanded="false" aria-haspopup="dialog">历史记录</button>
|
|
242
|
+
<div class="ai-heading-title"><span class="status-dot"></span><strong>创作助手</strong></div>
|
|
243
|
+
<div class="ai-heading-actions">
|
|
244
|
+
<span id="ai-conversation-title" title="当前对话">新对话</span>
|
|
245
|
+
<button id="ai-history-toggle" type="button" aria-label="历史记录" aria-controls="ai-history-dialog" aria-expanded="false" aria-haspopup="dialog" title="历史记录"><svg class="ai-heading-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path><path d="M3 3v5h5M12 7v5l3.5 2"></path></svg></button>
|
|
246
|
+
<button id="ai-new-conversation" type="button" aria-label="新建对话" title="新建对话">+</button>
|
|
247
|
+
</div>
|
|
249
248
|
</div>
|
|
250
249
|
<div class="quick-actions" aria-label="快捷指令">
|
|
251
250
|
<button type="button" data-task="chat" data-prompt="总结当前章节,并列出尚未解决的冲突。">总结本章</button>
|
|
@@ -733,6 +732,6 @@
|
|
|
733
732
|
<div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
|
|
734
733
|
<script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
|
|
735
734
|
<script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
|
|
736
|
-
<script type="module" src="/app.js?v=
|
|
735
|
+
<script type="module" src="/app.js?v=20260726-ai-history-cache-analysis-progress"></script>
|
|
737
736
|
</body>
|
|
738
737
|
</html>
|
|
@@ -32,6 +32,42 @@ export function eligibleRaceParents(races, currentRaceId = null) {
|
|
|
32
32
|
return races.filter((race) => !excluded.has(String(race?.id ?? "")));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function orderRaceFilterOptions(races) {
|
|
36
|
+
const raceIds = new Set(races.map((race) => String(race?.id ?? "")).filter(Boolean));
|
|
37
|
+
const childrenByParent = new Map();
|
|
38
|
+
const roots = [];
|
|
39
|
+
for (const race of races) {
|
|
40
|
+
const id = String(race?.id ?? "");
|
|
41
|
+
const parentRaceId = race?.parentRaceId == null ? "" : String(race.parentRaceId);
|
|
42
|
+
if (!parentRaceId || parentRaceId === id || !raceIds.has(parentRaceId)) {
|
|
43
|
+
roots.push(race);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const children = childrenByParent.get(parentRaceId) ?? [];
|
|
47
|
+
children.push(race);
|
|
48
|
+
childrenByParent.set(parentRaceId, children);
|
|
49
|
+
}
|
|
50
|
+
const sortByName = (items) => items.sort((left, right) => String(left?.name ?? "").localeCompare(String(right?.name ?? ""), "zh-CN"));
|
|
51
|
+
sortByName(roots);
|
|
52
|
+
for (const children of childrenByParent.values()) sortByName(children);
|
|
53
|
+
|
|
54
|
+
const ordered = [...roots];
|
|
55
|
+
const pending = [...roots];
|
|
56
|
+
const visited = new Set(roots);
|
|
57
|
+
while (pending.length) {
|
|
58
|
+
const parent = pending.shift();
|
|
59
|
+
const children = childrenByParent.get(String(parent?.id ?? "")) ?? [];
|
|
60
|
+
for (const child of children) {
|
|
61
|
+
if (visited.has(child)) continue;
|
|
62
|
+
visited.add(child);
|
|
63
|
+
ordered.push(child);
|
|
64
|
+
pending.push(child);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
ordered.push(...sortByName(races.filter((race) => !visited.has(race))));
|
|
68
|
+
return ordered;
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
export function buildRaceForest(races) {
|
|
36
72
|
const nodes = new Map(races.map((race) => [String(race.id), { ...race, children: [] }]));
|
|
37
73
|
const roots = [];
|
|
@@ -6,14 +6,26 @@ const RELATION_STYLE = Object.freeze({
|
|
|
6
6
|
uncertain: { label: "未确定", color: "#9aa5b5" }
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
/** Obsidian Graph View
|
|
9
|
+
/** Obsidian Graph View 风格:为浅色和深色背景分别提供可辨识的低饱和度分组配色 */
|
|
10
10
|
export const OBSIDIAN_NODE_PALETTE = Object.freeze([
|
|
11
|
-
Object.freeze({ key: "blue", color: "#
|
|
12
|
-
Object.freeze({ key: "
|
|
13
|
-
Object.freeze({ key: "
|
|
14
|
-
Object.freeze({ key: "
|
|
15
|
-
Object.freeze({ key: "
|
|
16
|
-
Object.freeze({ key: "
|
|
11
|
+
Object.freeze({ key: "blue", color: "#3f6f99", darkColor: "#7fb7e3", glow: "rgba(63,111,153,.42)", darkGlow: "rgba(127,183,227,.56)" }),
|
|
12
|
+
Object.freeze({ key: "indigo", color: "#5b5fa3", darkColor: "#9ea6ed", glow: "rgba(91,95,163,.42)", darkGlow: "rgba(158,166,237,.56)" }),
|
|
13
|
+
Object.freeze({ key: "violet", color: "#77549a", darkColor: "#bd91df", glow: "rgba(119,84,154,.42)", darkGlow: "rgba(189,145,223,.56)" }),
|
|
14
|
+
Object.freeze({ key: "purple", color: "#8b4f83", darkColor: "#d28bc5", glow: "rgba(139,79,131,.42)", darkGlow: "rgba(210,139,197,.56)" }),
|
|
15
|
+
Object.freeze({ key: "rose", color: "#a84e6a", darkColor: "#e78da8", glow: "rgba(168,78,106,.42)", darkGlow: "rgba(231,141,168,.56)" }),
|
|
16
|
+
Object.freeze({ key: "red", color: "#ad554f", darkColor: "#ef948c", glow: "rgba(173,85,79,.42)", darkGlow: "rgba(239,148,140,.56)" }),
|
|
17
|
+
Object.freeze({ key: "coral", color: "#a95d45", darkColor: "#eda083", glow: "rgba(169,93,69,.42)", darkGlow: "rgba(237,160,131,.56)" }),
|
|
18
|
+
Object.freeze({ key: "amber", color: "#956b2f", darkColor: "#deb46d", glow: "rgba(149,107,47,.42)", darkGlow: "rgba(222,180,109,.56)" }),
|
|
19
|
+
Object.freeze({ key: "olive", color: "#777331", darkColor: "#bdba70", glow: "rgba(119,115,49,.42)", darkGlow: "rgba(189,186,112,.56)" }),
|
|
20
|
+
Object.freeze({ key: "green", color: "#47794f", darkColor: "#84c98f", glow: "rgba(71,121,79,.42)", darkGlow: "rgba(132,201,143,.56)" }),
|
|
21
|
+
Object.freeze({ key: "emerald", color: "#337965", darkColor: "#74c9aa", glow: "rgba(51,121,101,.42)", darkGlow: "rgba(116,201,170,.56)" }),
|
|
22
|
+
Object.freeze({ key: "teal", color: "#31777a", darkColor: "#72c6c8", glow: "rgba(49,119,122,.42)", darkGlow: "rgba(114,198,200,.56)" }),
|
|
23
|
+
Object.freeze({ key: "cyan", color: "#34758f", darkColor: "#7cc4de", glow: "rgba(52,117,143,.42)", darkGlow: "rgba(124,196,222,.56)" }),
|
|
24
|
+
Object.freeze({ key: "sky", color: "#41749f", darkColor: "#87bce6", glow: "rgba(65,116,159,.42)", darkGlow: "rgba(135,188,230,.56)" }),
|
|
25
|
+
Object.freeze({ key: "steel", color: "#576f86", darkColor: "#98afc5", glow: "rgba(87,111,134,.42)", darkGlow: "rgba(152,175,197,.56)" }),
|
|
26
|
+
Object.freeze({ key: "slate", color: "#626b78", darkColor: "#aab3c0", glow: "rgba(98,107,120,.42)", darkGlow: "rgba(170,179,192,.56)" }),
|
|
27
|
+
Object.freeze({ key: "sand", color: "#806e58", darkColor: "#c5ad91", glow: "rgba(128,110,88,.42)", darkGlow: "rgba(197,173,145,.56)" }),
|
|
28
|
+
Object.freeze({ key: "taupe", color: "#75636d", darkColor: "#b9a0ad", glow: "rgba(117,99,109,.42)", darkGlow: "rgba(185,160,173,.56)" })
|
|
17
29
|
]);
|
|
18
30
|
|
|
19
31
|
const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
|
|
@@ -186,12 +198,18 @@ export function getObsidianNodeAppearance(node, maxDegree = 1) {
|
|
|
186
198
|
const group = resolveRelationshipNodeGroup(node);
|
|
187
199
|
const degree = Math.max(0, Number(node?.degree) || 0);
|
|
188
200
|
const normalizedDegree = clamp(degree / Math.max(1, Number(maxDegree) || 1), 0, 1);
|
|
189
|
-
|
|
201
|
+
// 未分组角色没有可共享的分组语义,按角色稳定标识散列,避免资料不完整时大片同色。
|
|
202
|
+
const colorKey = group.key === "default"
|
|
203
|
+
? `node:${String(node?.id ?? node?.name ?? "default")}`
|
|
204
|
+
: group.key;
|
|
205
|
+
const paletteIndex = hashString(colorKey) % OBSIDIAN_NODE_PALETTE.length;
|
|
190
206
|
const palette = OBSIDIAN_NODE_PALETTE[paletteIndex];
|
|
191
207
|
return {
|
|
192
208
|
group,
|
|
193
209
|
color: palette.color,
|
|
210
|
+
darkColor: palette.darkColor,
|
|
194
211
|
glow: palette.glow,
|
|
212
|
+
darkGlow: palette.darkGlow,
|
|
195
213
|
size: clamp(8 + Math.sqrt(degree) * 4.8 + normalizedDegree * 4, 8, 38),
|
|
196
214
|
degree,
|
|
197
215
|
normalizedDegree
|
|
@@ -299,7 +317,9 @@ export function buildRelationshipGraph(characters, relationships) {
|
|
|
299
317
|
node.importance = node.weightedDegree + Math.sqrt(node.degree) * 0.8;
|
|
300
318
|
const appearance = getObsidianNodeAppearance(node, maxDegree);
|
|
301
319
|
node.color = appearance.color;
|
|
320
|
+
node.darkColor = appearance.darkColor;
|
|
302
321
|
node.glow = appearance.glow;
|
|
322
|
+
node.darkGlow = appearance.darkGlow;
|
|
303
323
|
node.nodeSize = appearance.size;
|
|
304
324
|
}
|
|
305
325
|
nodes.sort((left, right) => right.importance - left.importance || left.name.localeCompare(right.name, "zh-CN"));
|
|
@@ -1253,8 +1273,10 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
|
|
|
1253
1273
|
button.dataset.groupKey = node.groupKey || appearance.group.key;
|
|
1254
1274
|
button.classList.toggle("is-label-visible", graph.nodes.indexOf(node) < defaultLabelLimit || node.degree >= 4);
|
|
1255
1275
|
button.style.setProperty("--node-size", `${nodeSize}px`);
|
|
1256
|
-
button.style.setProperty("--node-color", node.color || appearance.color);
|
|
1257
|
-
button.style.setProperty("--node-
|
|
1276
|
+
button.style.setProperty("--node-color-light", node.color || appearance.color);
|
|
1277
|
+
button.style.setProperty("--node-color-dark", node.darkColor || appearance.darkColor);
|
|
1278
|
+
button.style.setProperty("--node-glow-light", node.glow || appearance.glow);
|
|
1279
|
+
button.style.setProperty("--node-glow-dark", node.darkGlow || appearance.darkGlow);
|
|
1258
1280
|
const labelEl = document.createElement("span");
|
|
1259
1281
|
labelEl.textContent = node.name;
|
|
1260
1282
|
button.append(labelEl);
|