@musnows/scriverse 0.4.9 → 0.4.11

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.
@@ -36,13 +36,43 @@ function normalizedPage(page) {
36
36
  return { key: "settings", label: "设置中心" };
37
37
  return { key: "welcome", label: "作品首页" };
38
38
  }
39
+ export function editorPageKey(chapterId) {
40
+ return `editor:${chapterId}`;
41
+ }
42
+ export function entityEditorPageKey(module, resourceId) {
43
+ return `entity-editor:${module}:${resourceId}`;
44
+ }
45
+ export function modulePageKey(module) {
46
+ return `module:${module}`;
47
+ }
48
+ export function pageLabelForKey(pageKey) {
49
+ if (pageKey.startsWith("editor:"))
50
+ return "正文编辑";
51
+ if (pageKey.startsWith("entity-editor:")) {
52
+ const module = pageKey.split(":")[1] ?? "";
53
+ return entityLabels[module] ?? "资料编辑";
54
+ }
55
+ if (pageKey.startsWith("module:")) {
56
+ const module = pageKey.slice("module:".length);
57
+ return moduleLabels[module] ?? "作品模块";
58
+ }
59
+ if (pageKey === "settings")
60
+ return "设置中心";
61
+ return "作品页面";
62
+ }
39
63
  export class CollaborationPresence {
40
64
  timeoutMs;
41
65
  now;
66
+ changeTtlMs;
67
+ maxChanges;
42
68
  entries = new Map();
43
- constructor(timeoutMs = 45_000, now = Date.now) {
69
+ changes = [];
70
+ changeSequence = 0;
71
+ constructor(timeoutMs = 45_000, now = Date.now, changeTtlMs = 120_000, maxChanges = 50) {
44
72
  this.timeoutMs = timeoutMs;
45
73
  this.now = now;
74
+ this.changeTtlMs = changeTtlMs;
75
+ this.maxChanges = maxChanges;
46
76
  }
47
77
  heartbeat(workId, clientId, user, page) {
48
78
  const now = this.now();
@@ -56,7 +86,43 @@ export class CollaborationPresence {
56
86
  lastSeenAt: new Date(now).toISOString(),
57
87
  lastSeenMs: now
58
88
  });
59
- return this.list(workId, now);
89
+ return {
90
+ participants: this.list(workId, now),
91
+ recentChanges: this.listChanges(workId, now)
92
+ };
93
+ }
94
+ publishChange(workId, pageKey, actor, label = pageLabelForKey(pageKey)) {
95
+ const now = this.now();
96
+ this.pruneChanges(now);
97
+ this.changeSequence += 1;
98
+ const change = {
99
+ id: `change-${now}-${this.changeSequence}`,
100
+ workId,
101
+ pageKey,
102
+ label,
103
+ actorUserId: actor.userId,
104
+ actorDisplayName: actor.displayName,
105
+ savedAt: new Date(now).toISOString(),
106
+ savedAtMs: now
107
+ };
108
+ this.changes.push(change);
109
+ while (this.changes.length > this.maxChanges)
110
+ this.changes.shift();
111
+ return {
112
+ id: change.id,
113
+ pageKey: change.pageKey,
114
+ label: change.label,
115
+ actorUserId: change.actorUserId,
116
+ actorDisplayName: change.actorDisplayName,
117
+ savedAt: change.savedAt
118
+ };
119
+ }
120
+ listChanges(workId, now = this.now()) {
121
+ this.pruneChanges(now);
122
+ return this.changes
123
+ .filter((change) => change.workId === workId)
124
+ .sort((left, right) => right.savedAtMs - left.savedAtMs)
125
+ .map(({ workId: _workId, savedAtMs: _savedAtMs, ...change }) => change);
60
126
  }
61
127
  list(workId, now) {
62
128
  this.prune(now);
@@ -70,6 +136,15 @@ export class CollaborationPresence {
70
136
  if (now - entry.lastSeenMs > this.timeoutMs)
71
137
  this.entries.delete(key);
72
138
  }
139
+ this.pruneChanges(now);
140
+ }
141
+ pruneChanges(now) {
142
+ while (this.changes.length > 0) {
143
+ const oldest = this.changes[0];
144
+ if (!oldest || now - oldest.savedAtMs <= this.changeTtlMs)
145
+ break;
146
+ this.changes.shift();
147
+ }
73
148
  }
74
149
  }
75
150
  //# sourceMappingURL=collaboration-presence.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"collaboration-presence.js","sourceRoot":"","sources":["../src/collaboration-presence.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"collaboration-presence.js","sourceRoot":"","sources":["../src/collaboration-presence.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260725-galaxy-ripple";
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
- import { renderMarkdown } from "/markdown.js?v=20260722-inline-code";
3
+ import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
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";
@@ -29,12 +29,13 @@ import {
29
29
  searchResultTypeLabel,
30
30
  settingStatusLabel,
31
31
  taskScopeLabel,
32
- timelineStatusLabel
33
- } from "/display-labels.js?v=20260725-unified-permissions";
32
+ timelineStatusLabel,
33
+ characterStateFieldLabel
34
+ } from "/display-labels.js?v=20260725-state-field-zh";
34
35
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
35
36
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
36
37
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
37
- import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hierarchy.js?v=20260721-race-hierarchy";
38
+ import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260726-race-filter-order";
38
39
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
39
40
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
40
41
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
@@ -97,6 +98,9 @@ let presenceParticipants = [];
97
98
  let presenceHeartbeatTimer = null;
98
99
  let presenceHeartbeatQueued = null;
99
100
  let presenceHeartbeatRequest = 0;
101
+ const acknowledgedCollaborativeChangeIds = new Set();
102
+ let collaborativeChangePromptOpen = false;
103
+ let peerPageStale = false;
100
104
  let collaborationAutoSaveDisabled = false;
101
105
 
102
106
  let timelineMultiSelectEnabled = false;
@@ -547,14 +551,16 @@ async function refreshPresence() {
547
551
  return [];
548
552
  }
549
553
  try {
550
- const participants = await api(`/api/works/${encodeURIComponent(workId)}/presence`, {
554
+ const payload = await api(`/api/works/${encodeURIComponent(workId)}/presence`, {
551
555
  method: "POST",
552
556
  body: { clientId: presenceClientId, page },
553
557
  skipOptimisticVersion: true
554
558
  });
555
559
  if (state.work?.id === workId && presenceHeartbeatRequest === requestId) {
556
- presenceParticipants = participants;
560
+ presenceParticipants = Array.isArray(payload) ? payload : (payload?.participants ?? []);
557
561
  renderPresence();
562
+ const recentChanges = Array.isArray(payload) ? [] : (payload?.recentChanges ?? []);
563
+ void handleCollaborativeChanges(recentChanges);
558
564
  }
559
565
  } catch {
560
566
  if (state.work?.id === workId) renderPresence();
@@ -564,6 +570,58 @@ async function refreshPresence() {
564
570
  return presenceParticipants;
565
571
  }
566
572
 
573
+ function localEditingDirty() {
574
+ return Boolean(state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty);
575
+ }
576
+
577
+ async function handleCollaborativeChanges(recentChanges) {
578
+ if (!Array.isArray(recentChanges) || !recentChanges.length || collaborativeChangePromptOpen) return;
579
+ const localKey = presencePageKey(presencePageForRoute());
580
+ if (!localKey) return;
581
+ const selfId = state.user?.userId;
582
+ const incoming = recentChanges.filter((change) => (
583
+ change
584
+ && change.pageKey === localKey
585
+ && change.actorUserId
586
+ && change.actorUserId !== selfId
587
+ && change.id
588
+ && !acknowledgedCollaborativeChangeIds.has(change.id)
589
+ ));
590
+ if (!incoming.length) return;
591
+ const latest = incoming[0];
592
+ for (const change of incoming) acknowledgedCollaborativeChangeIds.add(change.id);
593
+ while (acknowledgedCollaborativeChangeIds.size > 200) {
594
+ const oldest = acknowledgedCollaborativeChangeIds.values().next().value;
595
+ acknowledgedCollaborativeChangeIds.delete(oldest);
596
+ }
597
+ peerPageStale = true;
598
+ collaborativeChangePromptOpen = true;
599
+ const dirtyHint = localEditingDirty()
600
+ ? "你当前页面还有未保存修改。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。"
601
+ : "请先确认本地没有需要保留的草稿;如有,请复制保存到别处(本页无法代为另存),再刷新页面后继续编辑。";
602
+ try {
603
+ const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新「${latest.label || "当前页面"}」。${dirtyHint}`, {
604
+ title: "协作者已更新",
605
+ confirmLabel: "刷新页面",
606
+ cancelLabel: "稍后处理"
607
+ });
608
+ if (shouldReload) window.location.reload();
609
+ } finally {
610
+ collaborativeChangePromptOpen = false;
611
+ }
612
+ }
613
+
614
+ async function confirmPeerStaleSave() {
615
+ if (!peerPageStale) return true;
616
+ const confirmed = await confirmToast("协作者已更新当前页面。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。继续保存可能覆盖对方修改或触发冲突。", {
617
+ title: "页面内容已过期",
618
+ confirmLabel: "仍然保存",
619
+ cancelLabel: "暂不保存"
620
+ });
621
+ if (confirmed) peerPageStale = false;
622
+ return confirmed;
623
+ }
624
+
567
625
  function schedulePresenceHeartbeat() {
568
626
  if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
569
627
  presenceHeartbeatQueued = setTimeout(() => {
@@ -574,6 +632,7 @@ function schedulePresenceHeartbeat() {
574
632
 
575
633
  async function confirmConcurrentSave() {
576
634
  await refreshPresence();
635
+ if (!(await confirmPeerStaleSave())) return false;
577
636
  const localKey = presencePageKey(presencePageForRoute());
578
637
  const peers = presenceParticipants.filter((participant) => participant.clientId !== presenceClientId && participant.page.key === localKey);
579
638
  if (!peers.length) return true;
@@ -2735,7 +2794,7 @@ function resetWorkScopedUiCaches() {
2735
2794
  async function selectWork(workId, preferredChapterId = null) {
2736
2795
  const discarding = state.work?.id !== workId && state.dirty;
2737
2796
  if (discarding && !(await confirmDiscardChanges())) return false;
2738
- const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
2797
+ const nextWork = await api(`/api/works/${workId}`);
2739
2798
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
2740
2799
  if (discarding) setSaveState("就绪");
2741
2800
  $("#app").classList.remove("shelf-mode");
@@ -3403,14 +3462,14 @@ async function renderCharacters(page = characterListPage) {
3403
3462
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
3404
3463
  ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
3405
3464
  ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
3406
- ${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>` : ""}
3465
+ ${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(characterStateFieldLabel(detail.label))}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
3407
3466
  <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>
3408
- ${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>`}
3467
+ ${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
3409
3468
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
3410
3469
  </article>`;
3411
3470
  }).join("")}</div>`;
3412
3471
  const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
3413
- const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join(" ") || "尚未记录当前状态");
3472
+ const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
3414
3473
  const meta = [
3415
3474
  item.code ? `编号 ${item.code}` : "",
3416
3475
  item.species ? (racePathLabel(item.race) || item.species) : "",
@@ -3442,7 +3501,7 @@ async function renderCharacters(page = characterListPage) {
3442
3501
  return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
3443
3502
  }).join("");
3444
3503
  const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
3445
- <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>
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(orderRaceFilterOptions(races), selectedRaceIds)}</div></details>
3446
3505
  <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>
3447
3506
  <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>
3448
3507
  </section>`;
@@ -3482,10 +3541,7 @@ async function renderCharacters(page = characterListPage) {
3482
3541
  }
3483
3542
 
3484
3543
  async function renderRaces() {
3485
- [state.races, state.characters] = await Promise.all([
3486
- apiAllPages(`/api/works/${state.work.id}/races`),
3487
- canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
3488
- ]);
3544
+ state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
3489
3545
  mountModuleCount(state.races.length);
3490
3546
  const layout = readModuleLayout();
3491
3547
  const canEditRaces = canEditModule("races");
@@ -3689,6 +3745,7 @@ async function renderOutlines() {
3689
3745
  async function renderRelationships() {
3690
3746
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3691
3747
  const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
3748
+ const canEditRelationships = canEditModule("relationships");
3692
3749
  mountModuleCount(relationships.length);
3693
3750
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
3694
3751
  state.galaxy?.destroy();
@@ -3698,7 +3755,7 @@ async function renderRelationships() {
3698
3755
  state.relationshipGraph = graph;
3699
3756
  $("#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) => `
3700
3757
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
3701
- <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"><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>'}`;
3758
+ <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>'}`;
3702
3759
  const openGalaxy = () => {
3703
3760
  state.galaxy?.destroy();
3704
3761
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -5832,11 +5889,30 @@ async function openRelationshipDialog(item, options = {}) {
5832
5889
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
5833
5890
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
5834
5891
  const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
5835
- 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), async (form) => {
5892
+ const relationshipName = item ? `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}` : "";
5893
+ const management = item && canEditModule("relationships") ? `<section class="entity-dialog-management" aria-label="人物关系操作">
5894
+ <div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
5895
+ <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
5896
+ </section>` : "";
5897
+ 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) => {
5836
5898
  const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
5837
5899
  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 } : {}) } });
5838
5900
  await refreshRelationshipSurfaces(options.characterId ?? null);
5839
5901
  }, item ? "关系档案" : "人工确认关系");
5902
+ $("#dialog-fields").querySelector("[data-dialog-relationship-delete]")?.addEventListener("click", async () => {
5903
+ const dialog = $("#form-dialog");
5904
+ dialog.close();
5905
+ if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return dialog.showModal();
5906
+ if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return dialog.showModal();
5907
+ try {
5908
+ await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
5909
+ await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
5910
+ toast(`已删除人物关系“${relationshipName}”`);
5911
+ } catch (error) {
5912
+ dialog.showModal();
5913
+ toast(error.message, "error");
5914
+ }
5915
+ });
5840
5916
  }
5841
5917
 
5842
5918
  function openReviewDialog() {
@@ -13,3 +13,4 @@ export function providerConnectionLabel(value: unknown): string;
13
13
  export function chapterVersionSourceLabel(value: unknown): string;
14
14
  export function occurrenceRoleLabel(value: unknown): string;
15
15
  export function searchResultTypeLabel(value: unknown): string;
16
+ export function characterStateFieldLabel(value: unknown): string;
@@ -75,3 +75,11 @@ export function occurrenceRoleLabel(value) {
75
75
  export function searchResultTypeLabel(value) {
76
76
  return enumLabel({ chapter: "章节", setting: "设定", character: "角色", race: "种族", organization: "组织" }, value, "其他资料");
77
77
  }
78
+
79
+ export function characterStateFieldLabel(value) {
80
+ const normalized = String(value ?? "").trim();
81
+ return enumLabel({
82
+ location: "位置",
83
+ condition: "状况"
84
+ }, normalized, normalized || "未命名字段");
85
+ }
@@ -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=20260725-module-header-scale">
13
+ <link rel="stylesheet" href="/styles.css?v=20260726-ai-panel-header-relationship-network-palette">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -105,40 +105,42 @@
105
105
 
106
106
  <aside class="left-panel">
107
107
  <div id="left-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整作品侧栏宽度" aria-orientation="vertical" tabindex="0"></div>
108
- <div class="left-actions">
109
- <div class="left-primary-actions">
110
- <label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
111
- <span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
112
- <span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
113
- <span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
114
- <input id="import-file" type="file" accept=".txt,.docx">
115
- </label>
116
- <button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
108
+ <div class="left-panel-body">
109
+ <div class="left-actions">
110
+ <div class="left-primary-actions">
111
+ <label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
112
+ <span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
113
+ <span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
114
+ <span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
115
+ <input id="import-file" type="file" accept=".txt,.docx">
116
+ </label>
117
+ <button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
118
+ </div>
117
119
  </div>
118
- </div>
119
- <nav id="module-nav" class="module-nav" aria-label="作品模块">
120
- <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
121
- <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定库</button>
122
- <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
123
- <button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
124
- <button type="button" data-module="timeline"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>时间轴</button>
125
- <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
126
- <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
127
- <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
128
- <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
129
- <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
130
- <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
131
- <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
132
- <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
133
- </nav>
134
- <div class="panel-heading">
135
- <span>作品目录</span>
136
- <div class="panel-heading-actions">
137
- <span id="chapter-count">0 章</span>
138
- <button id="new-volume-button" class="add-button" type="button" aria-label="新建分卷" title="新建分卷">+</button>
120
+ <nav id="module-nav" class="module-nav" aria-label="作品模块">
121
+ <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
122
+ <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定库</button>
123
+ <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
124
+ <button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
125
+ <button type="button" data-module="timeline"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>时间轴</button>
126
+ <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
127
+ <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
128
+ <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
129
+ <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
130
+ <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
131
+ <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
132
+ <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
133
+ <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
134
+ </nav>
135
+ <div class="panel-heading">
136
+ <span>作品目录</span>
137
+ <div class="panel-heading-actions">
138
+ <span id="chapter-count">0 章</span>
139
+ <button id="new-volume-button" class="add-button" type="button" aria-label="新建分卷" title="新建分卷">+</button>
140
+ </div>
139
141
  </div>
142
+ <div id="novel-tree" class="novel-tree empty-copy">创建或选择作品后开始写作</div>
140
143
  </div>
141
- <div id="novel-tree" class="novel-tree empty-copy">创建或选择作品后开始写作</div>
142
144
  </aside>
143
145
 
144
146
  <main id="main-panel" class="main-panel">
@@ -237,13 +239,12 @@
237
239
  <div id="ai-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整创作助手宽度" aria-orientation="vertical" tabindex="0"></div>
238
240
  <div class="ai-heading">
239
241
  <button id="ai-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起创作助手" aria-expanded="true">›</button>
240
- <div><span class="status-dot"></span><strong>创作助手</strong></div>
241
- <small>结果作为建议</small>
242
- </div>
243
- <div class="ai-conversation-toolbar">
244
- <span id="ai-conversation-title">新对话</span>
245
- <button id="ai-new-conversation" type="button">新建对话</button>
246
- <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-new-conversation" type="button" aria-label="新建对话" title="新建对话">+</button>
246
+ <button id="ai-history-toggle" type="button" aria-controls="ai-history-dialog" aria-expanded="false" aria-haspopup="dialog" title="历史记录">历史</button>
247
+ </div>
247
248
  </div>
248
249
  <div class="quick-actions" aria-label="快捷指令">
249
250
  <button type="button" data-task="chat" data-prompt="总结当前章节,并列出尚未解决的冲突。">总结本章</button>
@@ -731,6 +732,6 @@
731
732
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
732
733
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
733
734
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
734
- <script type="module" src="/app.js?v=20260725-module-header-scale"></script>
735
+ <script type="module" src="/app.js?v=20260726-relationship-permission-actions"></script>
735
736
  </body>
736
737
  </html>
@@ -199,6 +199,22 @@ export function renderMarkdown(value) {
199
199
  }
200
200
  flushQuote();
201
201
  if (!line.trim()) {
202
+ // CommonMark 松散列表:同类型列表项之间的空行不结束列表,
203
+ // 否则每个条目会各自生成 <ol>/<ul>,有序序号会全部显示为 1。
204
+ if (list) {
205
+ let nextIndex = lineIndex + 1;
206
+ while (nextIndex < lines.length && !lines[nextIndex].trim()) nextIndex += 1;
207
+ if (nextIndex < lines.length) {
208
+ const nextListItem = lines[nextIndex].match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/u);
209
+ if (nextListItem) {
210
+ const nextTag = /\d+\./u.test(nextListItem[2]) ? "ol" : "ul";
211
+ if (nextTag === list.tag) {
212
+ flushParagraph();
213
+ continue;
214
+ }
215
+ }
216
+ }
217
+ }
202
218
  flushBlocks();
203
219
  continue;
204
220
  }
@@ -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 = [];