@musnows/scriverse 0.5.9 → 0.5.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.
- package/dist/ai.js +62 -7
- package/dist/ai.js.map +1 -1
- package/dist/app.js +44 -5
- package/dist/app.js.map +1 -1
- package/dist/cli-contract.js +25 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +3 -0
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +48 -1
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +609 -74
- package/dist/public/entity-version.js +2 -0
- package/dist/public/index.html +10 -2
- package/dist/public/markdown.js +6 -1
- package/dist/public/page-route.d.ts +1 -1
- package/dist/public/page-route.js +1 -0
- package/dist/public/stream-typewriter.d.ts +19 -0
- package/dist/public/stream-typewriter.js +79 -0
- package/dist/public/styles.css +75 -9
- package/dist/public/vditor-line-number-layout.d.ts +14 -0
- package/dist/public/vditor-line-number-layout.js +51 -0
- package/dist/public/work-permissions.d.ts +2 -2
- package/dist/public/work-permissions.js +8 -0
- package/dist/store.js +108 -2
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +12 -5
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/work-permissions.js +9 -0
- package/dist/work-permissions.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
|
|
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=20260730-table-wrap-menu-v1";
|
|
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";
|
|
7
|
+
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
7
8
|
import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
|
|
8
9
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
9
10
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
|
|
11
|
+
import { createStreamTypewriter } from "/stream-typewriter.js?v=20260729-ai-stream-typewriter-v1";
|
|
10
12
|
import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
|
|
11
13
|
import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
|
|
12
14
|
import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
|
|
@@ -14,7 +16,7 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
|
|
|
14
16
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
15
17
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
16
18
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
|
|
17
|
-
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=
|
|
19
|
+
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260729-drafts-v1";
|
|
18
20
|
import {
|
|
19
21
|
chapterVersionSourceLabel,
|
|
20
22
|
foreshadowStatusLabel,
|
|
@@ -34,12 +36,12 @@ import {
|
|
|
34
36
|
timelineStatusLabel,
|
|
35
37
|
characterStateFieldLabel
|
|
36
38
|
} from "/display-labels.js?v=20260728-hybrid-search-v1";
|
|
37
|
-
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=
|
|
39
|
+
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260729-drafts-v1";
|
|
38
40
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
39
41
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
40
42
|
import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
|
|
41
43
|
import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
|
|
42
|
-
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=
|
|
44
|
+
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260729-drafts-v1";
|
|
43
45
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
44
46
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
45
47
|
import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
} from "/avatar-crop.js?v=20260725-avatar-crop";
|
|
59
61
|
|
|
60
62
|
const defaultPageSizes = Object.freeze({
|
|
63
|
+
drafts: 30,
|
|
61
64
|
settings: 30,
|
|
62
65
|
characters: 30,
|
|
63
66
|
races: 30,
|
|
@@ -141,6 +144,8 @@ let chapterMovePending = false;
|
|
|
141
144
|
|
|
142
145
|
let timelineMultiSelectEnabled = false;
|
|
143
146
|
let taskProgressRefreshTimer = null;
|
|
147
|
+
let taskAutoRunEditing = false;
|
|
148
|
+
let taskAutoRunEditingWorkId = null;
|
|
144
149
|
let relationshipSearchIndexRefreshTimer = null;
|
|
145
150
|
let backgroundTaskCenterTimer = null;
|
|
146
151
|
let backgroundTaskCenterRequest = 0;
|
|
@@ -868,7 +873,9 @@ let entityEditorReadOnly = false;
|
|
|
868
873
|
let chapterEditorReadOnly = true;
|
|
869
874
|
let characterListPage = 1;
|
|
870
875
|
let taskListPage = 1;
|
|
876
|
+
let draftTypeFilter = "all";
|
|
871
877
|
const moduleListPages = {
|
|
878
|
+
drafts: 1,
|
|
872
879
|
settings: 1,
|
|
873
880
|
organizations: 1,
|
|
874
881
|
timeline: 1,
|
|
@@ -898,6 +905,7 @@ let characterSectionEditorDirty = false;
|
|
|
898
905
|
let settingEditorVditor = null;
|
|
899
906
|
let knowledgeSectionVditor = null;
|
|
900
907
|
let characterSectionVditor = null;
|
|
908
|
+
let formDialogVditors = [];
|
|
901
909
|
let entityHistoryContext = null;
|
|
902
910
|
let moduleContentInteractionsBound = false;
|
|
903
911
|
|
|
@@ -1364,7 +1372,8 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
1364
1372
|
read_chapters: "读取章节",
|
|
1365
1373
|
grep: "查询正文关键字",
|
|
1366
1374
|
search_story_entities: "搜索作品实体",
|
|
1367
|
-
read_character_sections: "读取人物 Markdown 章节"
|
|
1375
|
+
read_character_sections: "读取人物 Markdown 章节",
|
|
1376
|
+
search_drafts: "搜索草稿"
|
|
1368
1377
|
};
|
|
1369
1378
|
|
|
1370
1379
|
const AI_TOOL_DESCRIPTIONS = {
|
|
@@ -1372,10 +1381,50 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
1372
1381
|
read_chapters: "读取指定章节的概要、正文或两者。",
|
|
1373
1382
|
grep: "查询正文关键字所在的完整段落及章节信息。",
|
|
1374
1383
|
search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
|
|
1375
|
-
read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
|
|
1384
|
+
read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
|
|
1385
|
+
search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
|
|
1376
1386
|
};
|
|
1377
1387
|
|
|
1378
1388
|
let aiFeedScrollFrame = null;
|
|
1389
|
+
let markdownTableMenuTarget = null;
|
|
1390
|
+
let markdownTableMenuTrigger = null;
|
|
1391
|
+
|
|
1392
|
+
function closeMarkdownTableMenu(restoreFocus = false) {
|
|
1393
|
+
const trigger = markdownTableMenuTrigger;
|
|
1394
|
+
$("#markdown-table-menu").classList.add("hidden");
|
|
1395
|
+
markdownTableMenuTarget = null;
|
|
1396
|
+
markdownTableMenuTrigger = null;
|
|
1397
|
+
if (restoreFocus && trigger?.isConnected) trigger.focus();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function setMarkdownTableWrapping(table, wrapping) {
|
|
1401
|
+
table.classList.toggle("is-wrapping", wrapping);
|
|
1402
|
+
table.setAttribute("aria-label", wrapping ? "Markdown 表格,内容自动换行" : "Markdown 表格,内容不换行");
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function openMarkdownTableMenu(header, clientX, clientY) {
|
|
1406
|
+
const table = header.closest(".markdown-table-scroll");
|
|
1407
|
+
if (!table) return;
|
|
1408
|
+
if (header.closest(".is-streaming")) {
|
|
1409
|
+
toast("回复生成完成后可以设置表格换行");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
closeChapterTypeMenu();
|
|
1413
|
+
closeLineCitationMenu();
|
|
1414
|
+
markdownTableMenuTarget = table;
|
|
1415
|
+
markdownTableMenuTrigger = header;
|
|
1416
|
+
const menu = $("#markdown-table-menu");
|
|
1417
|
+
const toggle = $("#markdown-table-wrap-toggle");
|
|
1418
|
+
toggle.setAttribute("aria-checked", String(table.classList.contains("is-wrapping")));
|
|
1419
|
+
menu.classList.remove("hidden");
|
|
1420
|
+
const menuRect = menu.getBoundingClientRect();
|
|
1421
|
+
const headerRect = header.getBoundingClientRect();
|
|
1422
|
+
const anchorX = clientX > 0 ? clientX : headerRect.left;
|
|
1423
|
+
const anchorY = clientY > 0 ? clientY : headerRect.bottom;
|
|
1424
|
+
menu.style.left = `${Math.max(8, Math.min(anchorX, window.innerWidth - menuRect.width - 8))}px`;
|
|
1425
|
+
menu.style.top = `${Math.max(8, Math.min(anchorY, window.innerHeight - menuRect.height - 8))}px`;
|
|
1426
|
+
toggle.focus();
|
|
1427
|
+
}
|
|
1379
1428
|
|
|
1380
1429
|
function scrollAiFeedToBottom() {
|
|
1381
1430
|
const feed = $("#ai-feed");
|
|
@@ -2703,6 +2752,7 @@ function renderSettingsHub() {
|
|
|
2703
2752
|
const hasWork = Boolean(state.work);
|
|
2704
2753
|
const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
|
|
2705
2754
|
const canReadAggregate = hasWork && canReadAggregateContent();
|
|
2755
|
+
const canReadFullExport = canReadAggregate && canReadModule("drafts");
|
|
2706
2756
|
const isAdmin = state.user?.role === "admin";
|
|
2707
2757
|
$("#platform-ai-button").classList.toggle("hidden", !isAdmin);
|
|
2708
2758
|
$("#platform-usage-button").classList.toggle("hidden", !isAdmin);
|
|
@@ -2712,7 +2762,7 @@ function renderSettingsHub() {
|
|
|
2712
2762
|
$("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
|
|
2713
2763
|
$("#work-audit-button").disabled = !canManageWork;
|
|
2714
2764
|
$("#top-search-button").disabled = !canReadAggregate;
|
|
2715
|
-
$("#export-button").disabled = !
|
|
2765
|
+
$("#export-button").disabled = !canReadFullExport;
|
|
2716
2766
|
$("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
|
|
2717
2767
|
$("#settings-work-note").textContent = hasWork
|
|
2718
2768
|
? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
|
|
@@ -2793,11 +2843,15 @@ const workAuditActionLabels = {
|
|
|
2793
2843
|
"chapter.deleted": "删除章节",
|
|
2794
2844
|
"chapter.purged": "彻底删除章节",
|
|
2795
2845
|
"chapter.restored": "恢复章节",
|
|
2846
|
+
"draft.created": "创建草稿",
|
|
2847
|
+
"draft.updated": "更新草稿",
|
|
2848
|
+
"draft.deleted": "删除草稿",
|
|
2849
|
+
"draft.restored": "恢复草稿",
|
|
2796
2850
|
"work.imported": "导入正文"
|
|
2797
2851
|
};
|
|
2798
2852
|
|
|
2799
2853
|
function workAuditEntityLabel(type) {
|
|
2800
|
-
return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
|
|
2854
|
+
return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
|
|
2801
2855
|
}
|
|
2802
2856
|
|
|
2803
2857
|
function workAuditDetailText(detail) {
|
|
@@ -2908,6 +2962,7 @@ async function openPlatformUiSettingsDialog() {
|
|
|
2908
2962
|
const settings = await api("/api/platform/ui-settings");
|
|
2909
2963
|
$("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
|
|
2910
2964
|
const pageSizes = normalizePageSizes(settings.pageSizes);
|
|
2965
|
+
$("#page-size-drafts").value = String(pageSizes.drafts);
|
|
2911
2966
|
$("#page-size-settings").value = String(pageSizes.settings);
|
|
2912
2967
|
$("#page-size-characters").value = String(pageSizes.characters);
|
|
2913
2968
|
$("#page-size-organizations").value = String(pageSizes.organizations);
|
|
@@ -3289,6 +3344,7 @@ function resetWorkScopedUiCaches() {
|
|
|
3289
3344
|
state.settings = [];
|
|
3290
3345
|
state.races = [];
|
|
3291
3346
|
characterListPage = 1;
|
|
3347
|
+
draftTypeFilter = "all";
|
|
3292
3348
|
Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
|
|
3293
3349
|
relationshipFilters.fromCharacterIds = [];
|
|
3294
3350
|
relationshipFilters.toCharacterIds = [];
|
|
@@ -3727,6 +3783,7 @@ function markActiveModule(module) {
|
|
|
3727
3783
|
}
|
|
3728
3784
|
|
|
3729
3785
|
const moduleMeta = {
|
|
3786
|
+
drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
|
|
3730
3787
|
settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
|
|
3731
3788
|
characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
|
|
3732
3789
|
races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
|
|
@@ -3749,6 +3806,10 @@ async function showModule(module) {
|
|
|
3749
3806
|
}
|
|
3750
3807
|
module = fallback;
|
|
3751
3808
|
}
|
|
3809
|
+
if (module !== "tasks") {
|
|
3810
|
+
taskAutoRunEditing = false;
|
|
3811
|
+
taskAutoRunEditingWorkId = null;
|
|
3812
|
+
}
|
|
3752
3813
|
if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
|
|
3753
3814
|
if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
|
|
3754
3815
|
state.module = module;
|
|
@@ -3778,6 +3839,7 @@ async function showModule(module) {
|
|
|
3778
3839
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
3779
3840
|
bindModuleContentInteractions();
|
|
3780
3841
|
try {
|
|
3842
|
+
if (module === "drafts") await renderDrafts();
|
|
3781
3843
|
if (module === "settings") await renderSettings();
|
|
3782
3844
|
if (module === "characters") await renderCharacters(characterListPage);
|
|
3783
3845
|
if (module === "races") await renderRaces();
|
|
@@ -4178,6 +4240,135 @@ function settingRecordActions(item) {
|
|
|
4178
4240
|
: recordHistoryButton("setting", item.id, item.title);
|
|
4179
4241
|
}
|
|
4180
4242
|
|
|
4243
|
+
function draftTypeLabel(draftType) {
|
|
4244
|
+
return draftType === "setting" ? "设定草稿" : "正文草稿";
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
async function deleteDraft(item) {
|
|
4248
|
+
if (!item || !canEditModule("drafts")) return;
|
|
4249
|
+
if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
|
|
4250
|
+
title: "删除草稿",
|
|
4251
|
+
confirmLabel: "确认删除"
|
|
4252
|
+
})) return;
|
|
4253
|
+
try {
|
|
4254
|
+
await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
4255
|
+
await renderDrafts(moduleListPages.drafts);
|
|
4256
|
+
toast("草稿已删除");
|
|
4257
|
+
} catch (error) {
|
|
4258
|
+
toast(error.message, "error");
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4262
|
+
function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
4263
|
+
const viewOnly = readOnly || !canEditModule("drafts");
|
|
4264
|
+
const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
|
|
4265
|
+
+ field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
|
|
4266
|
+
+ field("title", "标题", "text", item?.title ?? "")
|
|
4267
|
+
+ field("content", "内容", "markdown", item?.content ?? "", {
|
|
4268
|
+
placeholder: "记录尚未定稿的片段、方向或设定想法……",
|
|
4269
|
+
readOnly: viewOnly
|
|
4270
|
+
});
|
|
4271
|
+
openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
|
|
4272
|
+
if (viewOnly) return;
|
|
4273
|
+
const title = String(form.get("title") ?? "").trim();
|
|
4274
|
+
if (!title) throw new Error("请填写草稿标题");
|
|
4275
|
+
const body = {
|
|
4276
|
+
draftType: form.get("draftType"),
|
|
4277
|
+
title,
|
|
4278
|
+
content: String(form.get("content") ?? ""),
|
|
4279
|
+
...(item ? { expectedVersionNo: item.versionNo } : {})
|
|
4280
|
+
};
|
|
4281
|
+
await api(item ? `/api/drafts/${encodeURIComponent(item.id)}` : `/api/works/${encodeURIComponent(state.work.id)}/drafts`, {
|
|
4282
|
+
method: item ? "PATCH" : "POST",
|
|
4283
|
+
body
|
|
4284
|
+
});
|
|
4285
|
+
await renderDrafts(moduleListPages.drafts);
|
|
4286
|
+
toast(item ? "草稿已保存" : "草稿已创建");
|
|
4287
|
+
}, item ? draftTypeLabel(item.draftType) : "未确认想法", {
|
|
4288
|
+
submitLabel: viewOnly ? "关闭" : "保存草稿",
|
|
4289
|
+
hideCancel: viewOnly,
|
|
4290
|
+
wide: true,
|
|
4291
|
+
errorPrefix: "草稿保存失败:"
|
|
4292
|
+
});
|
|
4293
|
+
if (viewOnly) {
|
|
4294
|
+
$("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
|
|
4295
|
+
if (control instanceof HTMLSelectElement) control.disabled = true;
|
|
4296
|
+
else control.readOnly = true;
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
async function renderDrafts(page = moduleListPages.drafts) {
|
|
4302
|
+
const allDrafts = await apiAllPages(`/api/works/${state.work.id}/drafts`);
|
|
4303
|
+
const drafts = draftTypeFilter === "all"
|
|
4304
|
+
? allDrafts
|
|
4305
|
+
: allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
|
|
4306
|
+
mountModuleCount(drafts.length);
|
|
4307
|
+
const pageResult = paginateModuleItems(drafts, page, "drafts");
|
|
4308
|
+
moduleListPages.drafts = pageResult.page;
|
|
4309
|
+
const layout = readModuleLayout();
|
|
4310
|
+
if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
|
|
4311
|
+
else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
|
|
4312
|
+
const filterToolbar = `<section class="draft-filter-toolbar" aria-label="草稿筛选">
|
|
4313
|
+
<label for="draft-type-filter">草稿类型</label>
|
|
4314
|
+
<select id="draft-type-filter" aria-label="按草稿类型筛选">
|
|
4315
|
+
<option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
|
|
4316
|
+
<option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文草稿</option>
|
|
4317
|
+
<option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定草稿</option>
|
|
4318
|
+
</select>
|
|
4319
|
+
${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
|
|
4320
|
+
</section>`;
|
|
4321
|
+
const actions = (item) => canEditModule("drafts")
|
|
4322
|
+
? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}<button type="button" data-delete-draft="${esc(item.id)}">删除</button>${recordHistoryButton("draft", item.id, item.title)}`
|
|
4323
|
+
: recordHistoryButton("draft", item.id, item.title);
|
|
4324
|
+
const cards = `<div class="card-grid">${pageResult.items.map((item) => `
|
|
4325
|
+
<article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
|
|
4326
|
+
<small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
4327
|
+
<h3>${esc(item.title)}</h3>
|
|
4328
|
+
<div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
|
|
4329
|
+
<div class="card-actions">${actions(item)}</div>
|
|
4330
|
+
</article>`).join("")}</div>`;
|
|
4331
|
+
const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
|
|
4332
|
+
const preview = moduleRowPreview(item.contentPreview || "暂无内容");
|
|
4333
|
+
return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
|
|
4334
|
+
<small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
4335
|
+
<h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
4336
|
+
<div class="card-actions">${actions(item)}</div>
|
|
4337
|
+
</article>`;
|
|
4338
|
+
}).join("")}</div>`;
|
|
4339
|
+
const emptyDrafts = allDrafts.length
|
|
4340
|
+
? emptyModule("没有符合筛选条件的草稿", "可以切换草稿类型查看其他想法。")
|
|
4341
|
+
: emptyModule("还没有草稿", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
|
|
4342
|
+
$("#module-content").innerHTML = filterToolbar + (drafts.length
|
|
4343
|
+
? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "草稿列表")}`
|
|
4344
|
+
: emptyDrafts);
|
|
4345
|
+
$("#draft-type-filter").addEventListener("change", async (event) => {
|
|
4346
|
+
draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
|
|
4347
|
+
moduleListPages.drafts = 1;
|
|
4348
|
+
await renderDrafts(1);
|
|
4349
|
+
});
|
|
4350
|
+
bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
|
|
4351
|
+
bindModulePagination("drafts", renderDrafts);
|
|
4352
|
+
const draftById = (draftId) => drafts.find((draft) => draft.id === draftId);
|
|
4353
|
+
$("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
|
|
4354
|
+
const open = async () => openDraftDialog(await api(`/api/drafts/${encodeURIComponent(card.dataset.openDraft)}`), { readOnly: true });
|
|
4355
|
+
card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) void open(); });
|
|
4356
|
+
card.addEventListener("keydown", (event) => {
|
|
4357
|
+
if ((event.key === "Enter" || event.key === " ") && !event.target.closest("button, a")) {
|
|
4358
|
+
event.preventDefault();
|
|
4359
|
+
void open();
|
|
4360
|
+
}
|
|
4361
|
+
});
|
|
4362
|
+
});
|
|
4363
|
+
$("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
|
|
4364
|
+
openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
|
|
4365
|
+
}));
|
|
4366
|
+
$("#module-content").querySelectorAll("[data-delete-draft]").forEach((button) => button.addEventListener("click", () => {
|
|
4367
|
+
void deleteDraft(draftById(button.dataset.deleteDraft));
|
|
4368
|
+
}));
|
|
4369
|
+
bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4181
4372
|
function renderSettingCards(records) {
|
|
4182
4373
|
return `<div class="card-grid">${records.map((item) => `
|
|
4183
4374
|
<article class="record-card preview-record-card" data-open-setting="${esc(item.id)}" role="button" tabindex="0" aria-label="查看设定 ${esc(item.title)}"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(settingStatusLabel(item.status))}</small>
|
|
@@ -4771,12 +4962,14 @@ async function renderTasks(page = taskListPage) {
|
|
|
4771
4962
|
const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
|
|
4772
4963
|
mountModuleCount(taskTotal);
|
|
4773
4964
|
const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
|
|
4965
|
+
const autoRunEditing = canConfigureAutoRun && taskAutoRunEditing && taskAutoRunEditingWorkId === state.work.id;
|
|
4774
4966
|
const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
|
|
4775
4967
|
const runningCount = Number(taskPage.stats?.runningCount ?? 0);
|
|
4776
4968
|
const activeTaskCount = pendingCount + runningCount;
|
|
4777
4969
|
const runningProgress = runningCount ? analysisTaskProgressValue(taskPage.stats?.runningProgress) : 0;
|
|
4778
4970
|
const autoRunPaused = Boolean(settings.autoRunEnabled && settings.autoRunPaused);
|
|
4779
4971
|
const autoRunActive = Boolean(settings.autoRunEnabled && !autoRunPaused);
|
|
4972
|
+
const autoRunPauseLabel = autoRunPaused ? "恢复自动执行" : "暂停自动执行";
|
|
4780
4973
|
const queueProgressLabel = runningCount
|
|
4781
4974
|
? "运行中任务平均进度"
|
|
4782
4975
|
: autoRunPaused ? "自动执行已暂停" : autoRunActive ? "等待任务开始" : "自动执行已关闭";
|
|
@@ -4794,18 +4987,24 @@ async function renderTasks(page = taskListPage) {
|
|
|
4794
4987
|
: "";
|
|
4795
4988
|
$("#module-content").innerHTML = `
|
|
4796
4989
|
<section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
|
|
4797
|
-
<div class="task-auto-run-
|
|
4798
|
-
<
|
|
4799
|
-
|
|
4800
|
-
|
|
4990
|
+
<div class="task-auto-run-header">
|
|
4991
|
+
<div class="task-auto-run-copy">
|
|
4992
|
+
<strong id="task-auto-run-title">自动执行待分析任务</strong>
|
|
4993
|
+
<small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
|
|
4994
|
+
<small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
|
|
4995
|
+
</div>
|
|
4996
|
+
<div class="task-auto-run-actions">
|
|
4997
|
+
${autoRunEditing ? "" : '<button id="task-auto-run-edit" class="ghost-button" type="button">编辑</button>'}
|
|
4998
|
+
</div>
|
|
4801
4999
|
</div>
|
|
4802
5000
|
<div class="task-auto-run-controls">
|
|
4803
|
-
<label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""}><span>持续自动执行</span></label>
|
|
4804
|
-
<label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}"></label>
|
|
4805
|
-
<label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" aria-describedby="task-auto-run-daily-help"></label>
|
|
4806
|
-
<label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}"></label>
|
|
4807
|
-
|
|
4808
|
-
|
|
5001
|
+
<label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""} ${autoRunEditing ? "" : "disabled"}><span>持续自动执行</span></label>
|
|
5002
|
+
<label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
|
|
5003
|
+
<label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}" aria-describedby="task-auto-run-daily-help"></label>
|
|
5004
|
+
<label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
|
|
5005
|
+
${autoRunEditing
|
|
5006
|
+
? `<button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button><button id="task-auto-run-pause" class="ghost-button" type="button">${autoRunPauseLabel}</button>`
|
|
5007
|
+
: ""}
|
|
4809
5008
|
</div>
|
|
4810
5009
|
<p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
|
|
4811
5010
|
<p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
|
|
@@ -4844,6 +5043,12 @@ async function renderTasks(page = taskListPage) {
|
|
|
4844
5043
|
await renderTasks(Number(button.dataset.taskPage));
|
|
4845
5044
|
}));
|
|
4846
5045
|
|
|
5046
|
+
$("#task-auto-run-edit")?.addEventListener("click", () => {
|
|
5047
|
+
taskAutoRunEditing = true;
|
|
5048
|
+
taskAutoRunEditingWorkId = state.work.id;
|
|
5049
|
+
void renderTasks(taskListPage).catch((error) => toast(error.message, "error"));
|
|
5050
|
+
});
|
|
5051
|
+
|
|
4847
5052
|
$("#task-auto-run-save")?.addEventListener("click", async () => {
|
|
4848
5053
|
const button = $("#task-auto-run-save");
|
|
4849
5054
|
button.disabled = true;
|
|
@@ -4860,15 +5065,16 @@ async function renderTasks(page = taskListPage) {
|
|
|
4860
5065
|
toast(updated.autoRunEnabled
|
|
4861
5066
|
? `持续自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个`
|
|
4862
5067
|
: "自动执行已关闭");
|
|
5068
|
+
taskAutoRunEditing = false;
|
|
5069
|
+
taskAutoRunEditingWorkId = null;
|
|
4863
5070
|
await renderTasks();
|
|
4864
|
-
window.setTimeout(() => $("#task-auto-run-save")?.focus(), 0);
|
|
4865
5071
|
} catch (error) {
|
|
4866
5072
|
toast(error.message, "error");
|
|
4867
5073
|
button.disabled = false;
|
|
4868
5074
|
}
|
|
4869
5075
|
});
|
|
4870
|
-
$("#task-auto-run-
|
|
4871
|
-
const button = $("#task-auto-run-
|
|
5076
|
+
$("#task-auto-run-pause")?.addEventListener("click", async () => {
|
|
5077
|
+
const button = $("#task-auto-run-pause");
|
|
4872
5078
|
button.disabled = true;
|
|
4873
5079
|
try {
|
|
4874
5080
|
if (autoRunPaused) {
|
|
@@ -4876,17 +5082,18 @@ async function renderTasks(page = taskListPage) {
|
|
|
4876
5082
|
toast("自动执行已恢复");
|
|
4877
5083
|
} else {
|
|
4878
5084
|
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { autoRunEnabled: false } });
|
|
4879
|
-
toast("
|
|
5085
|
+
toast("已暂停自动执行");
|
|
4880
5086
|
}
|
|
5087
|
+
taskAutoRunEditing = false;
|
|
5088
|
+
taskAutoRunEditingWorkId = null;
|
|
4881
5089
|
await refreshBackgroundTaskCenter({ announce: false });
|
|
4882
5090
|
await renderTasks();
|
|
4883
|
-
window.setTimeout(() => $(
|
|
5091
|
+
window.setTimeout(() => $("#task-auto-run-edit")?.focus(), 0);
|
|
4884
5092
|
} catch (error) {
|
|
4885
5093
|
toast(error.message, "error");
|
|
4886
5094
|
button.disabled = false;
|
|
4887
5095
|
}
|
|
4888
5096
|
});
|
|
4889
|
-
|
|
4890
5097
|
$("#module-content").querySelectorAll("[data-task-detail]").forEach((button) => button.addEventListener("click", () => {
|
|
4891
5098
|
if (button.disabled) return;
|
|
4892
5099
|
button.disabled = true;
|
|
@@ -4957,6 +5164,51 @@ async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
|
|
|
4957
5164
|
}
|
|
4958
5165
|
}
|
|
4959
5166
|
|
|
5167
|
+
async function rerunAnalysisTaskWithModel(task, button) {
|
|
5168
|
+
const originalLabel = button.textContent;
|
|
5169
|
+
button.disabled = true;
|
|
5170
|
+
button.textContent = "加载模型";
|
|
5171
|
+
try {
|
|
5172
|
+
const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
|
|
5173
|
+
const currentModelId = String(task.model?.id ?? "");
|
|
5174
|
+
const availableModels = models.filter((model) =>
|
|
5175
|
+
model.id !== currentModelId
|
|
5176
|
+
&& model.enabled
|
|
5177
|
+
&& model.providerStatus === "enabled"
|
|
5178
|
+
&& model.providerConnectionStatus === "success"
|
|
5179
|
+
);
|
|
5180
|
+
if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
|
|
5181
|
+
$("#form-dialog").close();
|
|
5182
|
+
const currentModelLabel = task.model?.displayName || "运行时默认模型";
|
|
5183
|
+
openDialog("选择重试模型",
|
|
5184
|
+
`<p class="task-rerun-model-intro">当前模型:${esc(currentModelLabel)}。选择其他模型后,将按原任务范围和最新资料创建新任务。</p><label>重试模型<select name="modelId" required aria-label="重试模型">
|
|
5185
|
+
${availableModels.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")}
|
|
5186
|
+
</select></label>`,
|
|
5187
|
+
async (form) => {
|
|
5188
|
+
const modelId = String(form.get("modelId") ?? "").trim();
|
|
5189
|
+
const selectedModel = availableModels.find((model) => model.id === modelId);
|
|
5190
|
+
const rerun = await api(`/api/tasks/${encodeURIComponent(task.id)}/rerun`, {
|
|
5191
|
+
method: "POST",
|
|
5192
|
+
body: { modelId }
|
|
5193
|
+
});
|
|
5194
|
+
toast(`已使用${selectedModel ? `“${modelOptionLabel(selectedModel)}”` : "新模型"}创建重试任务 ${rerun.id}`);
|
|
5195
|
+
await refreshBackgroundTaskCenter({ announce: false });
|
|
5196
|
+
if (state.module === "tasks" && state.work?.id === task.workId) await renderTasks();
|
|
5197
|
+
},
|
|
5198
|
+
"AI 分析重试",
|
|
5199
|
+
{
|
|
5200
|
+
submitLabel: "换模型重试",
|
|
5201
|
+
pendingLabel: "创建中…",
|
|
5202
|
+
pendingMessage: "正在使用新模型创建重试任务,请稍候",
|
|
5203
|
+
errorPrefix: "换模型重试失败:"
|
|
5204
|
+
});
|
|
5205
|
+
} catch (error) {
|
|
5206
|
+
toast(error.message, "error");
|
|
5207
|
+
button.disabled = false;
|
|
5208
|
+
button.textContent = originalLabel;
|
|
5209
|
+
}
|
|
5210
|
+
}
|
|
5211
|
+
|
|
4960
5212
|
function stopTaskProgressRefresh() {
|
|
4961
5213
|
if (taskProgressRefreshTimer === null) return;
|
|
4962
5214
|
window.clearTimeout(taskProgressRefreshTimer);
|
|
@@ -5407,7 +5659,7 @@ function openTaskDetailDialog(task, trace) {
|
|
|
5407
5659
|
<div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
|
|
5408
5660
|
<div><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
|
|
5409
5661
|
<div><strong>结果摘要</strong>${resultPreview}</div>
|
|
5410
|
-
${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
|
|
5662
|
+
${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><button class="ghost-button" type="button" data-rerun-task-model="${esc(task.id)}">换模型重试</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
|
|
5411
5663
|
</section>
|
|
5412
5664
|
${renderTaskTraceVisualization(trace, task.id)}
|
|
5413
5665
|
</div>`,
|
|
@@ -5419,6 +5671,9 @@ function openTaskDetailDialog(task, trace) {
|
|
|
5419
5671
|
$("#dialog-fields").querySelector("[data-rerun-task-detail]")?.addEventListener("click", async (event) => {
|
|
5420
5672
|
await rerunAnalysisTask(event.currentTarget.dataset.rerunTaskDetail, event.currentTarget, { closeDetail: true });
|
|
5421
5673
|
});
|
|
5674
|
+
$("#dialog-fields").querySelector("[data-rerun-task-model]")?.addEventListener("click", async (event) => {
|
|
5675
|
+
await rerunAnalysisTaskWithModel(task, event.currentTarget);
|
|
5676
|
+
});
|
|
5422
5677
|
$("#dialog-fields").querySelector("[data-task-identity-repair]")?.addEventListener("click", async (event) => {
|
|
5423
5678
|
const button = event.currentTarget;
|
|
5424
5679
|
button.disabled = true;
|
|
@@ -5860,7 +6115,7 @@ async function renderBookAiSettings() {
|
|
|
5860
6115
|
]);
|
|
5861
6116
|
const host = $("#module-content");
|
|
5862
6117
|
const workId = String(state.work.id);
|
|
5863
|
-
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
|
|
6118
|
+
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
|
|
5864
6119
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
5865
6120
|
title: "本书 Token 用量",
|
|
5866
6121
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
@@ -5874,6 +6129,10 @@ async function renderBookAiSettings() {
|
|
|
5874
6129
|
"afterend",
|
|
5875
6130
|
`<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>`
|
|
5876
6131
|
);
|
|
6132
|
+
host.querySelector(".ai-agent-tools").insertAdjacentHTML(
|
|
6133
|
+
"beforeend",
|
|
6134
|
+
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索草稿</strong><small>查询正文草稿和设定草稿。草稿只是可能采用、也可能永远不会进入正文或正式设定的临时想法,Agent 不会把它当作已确认事实。</small></span></label>`
|
|
6135
|
+
);
|
|
5877
6136
|
if (!canEditModule("ai-settings")) {
|
|
5878
6137
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
5879
6138
|
host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
|
|
@@ -6187,7 +6446,7 @@ async function ensureAiReferencesLoaded() {
|
|
|
6187
6446
|
|
|
6188
6447
|
function field(name, label, type = "text", value = "", options = []) {
|
|
6189
6448
|
if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
|
|
6190
|
-
if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="Markdown 原文">${esc(value)}</textarea></div>`;
|
|
6449
|
+
if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
|
|
6191
6450
|
if (type === "item-list") {
|
|
6192
6451
|
const values = Array.isArray(value) && value.length ? value : [""];
|
|
6193
6452
|
return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
|
|
@@ -6357,6 +6616,8 @@ function commitRelationshipKeywordInputs(container) {
|
|
|
6357
6616
|
}
|
|
6358
6617
|
|
|
6359
6618
|
function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
6619
|
+
formDialogVditors.forEach(destroyVditorEditor);
|
|
6620
|
+
formDialogVditors = [];
|
|
6360
6621
|
void discardPendingMarkdownAttachments();
|
|
6361
6622
|
const dialog = $("#form-dialog");
|
|
6362
6623
|
const form = $("#dynamic-form");
|
|
@@ -6379,9 +6640,10 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
6379
6640
|
$("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
|
|
6380
6641
|
dialog.classList.toggle("wide-dialog", Boolean(options.wide));
|
|
6381
6642
|
dialog.classList.toggle("trace-dialog", Boolean(options.trace));
|
|
6643
|
+
dialog.classList.toggle("large-dialog", Boolean(options.large));
|
|
6382
6644
|
bindDynamicListControls($("#dialog-fields"));
|
|
6383
6645
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
6384
|
-
bindVditorEditors($("#dialog-fields"));
|
|
6646
|
+
formDialogVditors = bindVditorEditors($("#dialog-fields"));
|
|
6385
6647
|
form.onclick = null;
|
|
6386
6648
|
form.onkeydown = null;
|
|
6387
6649
|
dialog.oncancel = (event) => {
|
|
@@ -6962,6 +7224,107 @@ function toggleVditorLineNumbers(editor) {
|
|
|
6962
7224
|
const enabled = host.classList.toggle("show-line-numbers");
|
|
6963
7225
|
const button = host.querySelector(".vditor-line-number-button");
|
|
6964
7226
|
if (button) button.setAttribute("aria-pressed", String(enabled));
|
|
7227
|
+
updateVditorLineNumbers(editor, getVditorMarkdown(editor));
|
|
7228
|
+
}
|
|
7229
|
+
|
|
7230
|
+
function getVditorMarkdown(editor) {
|
|
7231
|
+
if (typeof editor?.getValue === "function") {
|
|
7232
|
+
try {
|
|
7233
|
+
return String(editor.getValue() ?? "");
|
|
7234
|
+
} catch {
|
|
7235
|
+
return "";
|
|
7236
|
+
}
|
|
7237
|
+
}
|
|
7238
|
+
const host = editor?.vditor?.element;
|
|
7239
|
+
const valueField = host?.closest("[data-vditor-editor-field], .setting-markdown-field, .character-markdown-editor")?.querySelector("[data-vditor-value]")
|
|
7240
|
+
?? host?.parentElement?.querySelector("[data-vditor-value]");
|
|
7241
|
+
return String(valueField?.value ?? "");
|
|
7242
|
+
}
|
|
7243
|
+
|
|
7244
|
+
function getVditorVisibleSurface(content) {
|
|
7245
|
+
const candidates = [
|
|
7246
|
+
content.querySelector(".vditor-ir .vditor-reset"),
|
|
7247
|
+
content.querySelector(".vditor-wysiwyg .vditor-reset"),
|
|
7248
|
+
content.querySelector(".vditor-sv textarea"),
|
|
7249
|
+
content.querySelector(".vditor-sv")
|
|
7250
|
+
];
|
|
7251
|
+
return candidates.find((surface) => {
|
|
7252
|
+
if (!surface) return false;
|
|
7253
|
+
const rect = surface.getBoundingClientRect();
|
|
7254
|
+
const style = getComputedStyle(surface);
|
|
7255
|
+
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
7256
|
+
}) ?? null;
|
|
7257
|
+
}
|
|
7258
|
+
|
|
7259
|
+
function getVditorLineHeight(surface) {
|
|
7260
|
+
const style = getComputedStyle(surface);
|
|
7261
|
+
return parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 24;
|
|
7262
|
+
}
|
|
7263
|
+
|
|
7264
|
+
function collectVditorVisualLineRects(surface) {
|
|
7265
|
+
const rects = [];
|
|
7266
|
+
const scrollTop = Number(surface.scrollTop) || 0;
|
|
7267
|
+
const visit = (node) => {
|
|
7268
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
7269
|
+
if (!node.textContent) return;
|
|
7270
|
+
const range = document.createRange();
|
|
7271
|
+
range.selectNodeContents(node);
|
|
7272
|
+
[...range.getClientRects()].forEach((rect) => {
|
|
7273
|
+
if (rect.height > 0) rects.push({ top: rect.top + scrollTop, height: rect.height });
|
|
7274
|
+
});
|
|
7275
|
+
return;
|
|
7276
|
+
}
|
|
7277
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
7278
|
+
if (node.tagName === "BR") {
|
|
7279
|
+
const rect = node.getBoundingClientRect();
|
|
7280
|
+
if (rect.height > 0) rects.push({ top: rect.top + scrollTop, height: rect.height });
|
|
7281
|
+
}
|
|
7282
|
+
[...node.childNodes].forEach(visit);
|
|
7283
|
+
};
|
|
7284
|
+
visit(surface);
|
|
7285
|
+
if (rects.length === 0) {
|
|
7286
|
+
const rect = surface.getBoundingClientRect();
|
|
7287
|
+
const style = getComputedStyle(surface);
|
|
7288
|
+
rects.push({ top: rect.top + scrollTop + (parseFloat(style.paddingTop) || 0), height: getVditorLineHeight(surface) });
|
|
7289
|
+
}
|
|
7290
|
+
return rects;
|
|
7291
|
+
}
|
|
7292
|
+
|
|
7293
|
+
function syncVditorLineNumberScroll(editor) {
|
|
7294
|
+
const surface = editor?.__vditorLineNumberSurface;
|
|
7295
|
+
const gutter = editor?.vditor?.element?.querySelector(".vditor-line-numbers");
|
|
7296
|
+
if (!surface || !gutter) return;
|
|
7297
|
+
gutter.style.transform = `translateY(${-surface.scrollTop}px)`;
|
|
7298
|
+
gutter.dataset.scrollTop = String(surface.scrollTop);
|
|
7299
|
+
}
|
|
7300
|
+
|
|
7301
|
+
function scheduleVditorLineNumbers(editor) {
|
|
7302
|
+
if (!editor || editor.__vditorLineNumberFrame) return;
|
|
7303
|
+
editor.__vditorLineNumberFrame = requestAnimationFrame(() => {
|
|
7304
|
+
editor.__vditorLineNumberFrame = null;
|
|
7305
|
+
updateVditorLineNumbers(editor, getVditorMarkdown(editor));
|
|
7306
|
+
});
|
|
7307
|
+
}
|
|
7308
|
+
|
|
7309
|
+
function setupVditorLineNumberSync(editor, content, surface) {
|
|
7310
|
+
if (editor.__vditorLineNumberSurface !== surface) {
|
|
7311
|
+
editor.__vditorLineNumberSurface?.removeEventListener("scroll", editor.__vditorLineNumberScrollHandler);
|
|
7312
|
+
editor.__vditorLineNumberSurface = surface;
|
|
7313
|
+
editor.__vditorLineNumberScrollHandler = () => syncVditorLineNumberScroll(editor);
|
|
7314
|
+
surface.addEventListener("scroll", editor.__vditorLineNumberScrollHandler, { passive: true });
|
|
7315
|
+
}
|
|
7316
|
+
if (!editor.__vditorLineNumberObserver) {
|
|
7317
|
+
editor.__vditorLineNumberObserver = new MutationObserver((mutations) => {
|
|
7318
|
+
if (mutations.some((mutation) => !(mutation.target instanceof Element) || !mutation.target.closest(".vditor-line-numbers"))) {
|
|
7319
|
+
scheduleVditorLineNumbers(editor);
|
|
7320
|
+
}
|
|
7321
|
+
});
|
|
7322
|
+
editor.__vditorLineNumberObserver.observe(content, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "style"] });
|
|
7323
|
+
}
|
|
7324
|
+
if (!editor.__vditorLineNumberResizeObserver && typeof ResizeObserver !== "undefined") {
|
|
7325
|
+
editor.__vditorLineNumberResizeObserver = new ResizeObserver(() => scheduleVditorLineNumbers(editor));
|
|
7326
|
+
editor.__vditorLineNumberResizeObserver.observe(content);
|
|
7327
|
+
}
|
|
6965
7328
|
}
|
|
6966
7329
|
|
|
6967
7330
|
function updateVditorLineNumbers(editor, markdown) {
|
|
@@ -6974,14 +7337,30 @@ function updateVditorLineNumbers(editor, markdown) {
|
|
|
6974
7337
|
gutter.setAttribute("aria-hidden", "true");
|
|
6975
7338
|
content.prepend(gutter);
|
|
6976
7339
|
}
|
|
6977
|
-
const
|
|
7340
|
+
const surface = getVditorVisibleSurface(content);
|
|
7341
|
+
if (!surface) return;
|
|
7342
|
+
setupVditorLineNumberSync(editor, content, surface);
|
|
7343
|
+
const contentRect = content.getBoundingClientRect();
|
|
7344
|
+
const lineHeight = getVditorLineHeight(surface);
|
|
7345
|
+
const normalizedMarkdown = String(markdown ?? "").replace(/\r\n?/gu, "\n");
|
|
7346
|
+
const blankLineCount = normalizedMarkdown.trim() === "" ? 0 : normalizedMarkdown.split("\n").filter((line) => line.trim() === "").length;
|
|
7347
|
+
const visualRows = buildVditorLineNumberRows(collectVditorVisualLineRects(surface), { lineHeight, blankLineCount });
|
|
7348
|
+
gutter.style.height = `${content.clientHeight}px`;
|
|
6978
7349
|
const fragment = document.createDocumentFragment();
|
|
6979
|
-
|
|
7350
|
+
visualRows.forEach((row, index) => {
|
|
6980
7351
|
const number = document.createElement("span");
|
|
6981
|
-
number.
|
|
7352
|
+
number.className = "vditor-line-number";
|
|
7353
|
+
number.textContent = String(index + 1);
|
|
7354
|
+
number.dataset.lineNumber = String(index + 1);
|
|
7355
|
+
number.dataset.blank = String(row.blank);
|
|
7356
|
+
number.style.top = `${row.top - contentRect.top}px`;
|
|
7357
|
+
number.style.height = `${row.height}px`;
|
|
7358
|
+
number.style.lineHeight = `${row.height}px`;
|
|
6982
7359
|
fragment.append(number);
|
|
6983
|
-
}
|
|
7360
|
+
});
|
|
6984
7361
|
gutter.replaceChildren(fragment);
|
|
7362
|
+
gutter.dataset.lineCount = String(visualRows.length);
|
|
7363
|
+
syncVditorLineNumberScroll(editor);
|
|
6985
7364
|
}
|
|
6986
7365
|
|
|
6987
7366
|
function updateVditorWordCount(editor, markdown) {
|
|
@@ -7026,12 +7405,19 @@ function ensureVditorIconScript() {
|
|
|
7026
7405
|
function destroyVditorEditor(editor) {
|
|
7027
7406
|
if (!editor) return;
|
|
7028
7407
|
editor.__attachmentObserver?.disconnect();
|
|
7408
|
+
editor.__vditorLineNumberObserver?.disconnect();
|
|
7409
|
+
editor.__vditorLineNumberResizeObserver?.disconnect();
|
|
7410
|
+
if (editor.__vditorLineNumberSurface && editor.__vditorLineNumberScrollHandler) {
|
|
7411
|
+
editor.__vditorLineNumberSurface.removeEventListener("scroll", editor.__vditorLineNumberScrollHandler);
|
|
7412
|
+
}
|
|
7413
|
+
if (editor.__vditorLineNumberFrame) cancelAnimationFrame(editor.__vditorLineNumberFrame);
|
|
7029
7414
|
const host = editor.vditor?.element;
|
|
7030
7415
|
editor.destroy();
|
|
7031
7416
|
if (host) delete host.__vditor;
|
|
7032
7417
|
}
|
|
7033
7418
|
|
|
7034
7419
|
function bindVditorEditors(container) {
|
|
7420
|
+
const editors = [];
|
|
7035
7421
|
container.querySelectorAll("[data-vditor-editor]").forEach((host) => {
|
|
7036
7422
|
const valueField = host.parentElement?.querySelector("[data-vditor-value]");
|
|
7037
7423
|
const editor = createVditorEditor(host, valueField?.value ?? "", {
|
|
@@ -7039,10 +7425,12 @@ function bindVditorEditors(container) {
|
|
|
7039
7425
|
if (valueField) valueField.value = markdown;
|
|
7040
7426
|
markEntityEditorDirty();
|
|
7041
7427
|
},
|
|
7042
|
-
placeholder: "",
|
|
7428
|
+
placeholder: host.dataset.placeholder ?? "",
|
|
7043
7429
|
readOnly: Boolean(valueField?.readOnly)
|
|
7044
7430
|
});
|
|
7431
|
+
if (editor) editors.push(editor);
|
|
7045
7432
|
});
|
|
7433
|
+
return editors;
|
|
7046
7434
|
}
|
|
7047
7435
|
|
|
7048
7436
|
function characterSectionImageLabel(file, fallback = "图片附件") {
|
|
@@ -7753,49 +8141,151 @@ function openOutlineDialog(item) {
|
|
|
7753
8141
|
}, "章节大纲");
|
|
7754
8142
|
}
|
|
7755
8143
|
|
|
8144
|
+
const foreshadowOccurrenceRoleOptions = [["setup", "埋设"], ["reminder", "提醒"], ["payoff", "回收"]];
|
|
8145
|
+
|
|
8146
|
+
function foreshadowOccurrenceRow(record, chapters, index) {
|
|
8147
|
+
const chapterOptions = [["", "暂不关联"], ...chapters];
|
|
8148
|
+
const occurrenceId = record?.id ?? "";
|
|
8149
|
+
const role = record?.role ?? "setup";
|
|
8150
|
+
const chapterId = record?.chapterId ?? "";
|
|
8151
|
+
const chapterLabel = chapters.find(([value]) => value === chapterId)?.[1] ?? "未关联章节";
|
|
8152
|
+
return `<fieldset class="foreshadow-occurrence-row" data-foreshadow-occurrence data-occurrence-id="${esc(occurrenceId)}" data-occurrence-role="${esc(role)}">
|
|
8153
|
+
<legend><span class="foreshadow-occurrence-role-badge" data-foreshadow-occurrence-role-label>${esc(occurrenceRoleLabel(role))}</span><strong data-foreshadow-occurrence-index>节点 ${index + 1}</strong><small data-foreshadow-occurrence-summary>${esc(chapterLabel)}</small></legend>
|
|
8154
|
+
<button class="foreshadow-occurrence-remove" type="button" data-foreshadow-occurrence-remove aria-label="删除第 ${index + 1} 个章节节点">删除节点</button>
|
|
8155
|
+
<div class="foreshadow-occurrence-grid">
|
|
8156
|
+
<label>节点类型<select name="occurrenceRole" data-foreshadow-occurrence-role aria-label="第 ${index + 1} 个节点类型">${foreshadowOccurrenceRoleOptions.map(([value, label]) => `<option value="${value}" ${value === role ? "selected" : ""}>${label}</option>`).join("")}</select></label>
|
|
8157
|
+
<label>关联章节<select name="occurrenceChapterId" data-foreshadow-occurrence-chapter aria-label="第 ${index + 1} 个关联章节">${chapterOptions.map(([value, label]) => `<option value="${esc(value)}" ${value === chapterId ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label>
|
|
8158
|
+
</div>
|
|
8159
|
+
<label>节点说明<textarea name="occurrenceNote" data-foreshadow-occurrence-note aria-label="第 ${index + 1} 个节点说明">${esc(record?.note ?? "")}</textarea></label>
|
|
8160
|
+
</fieldset>`;
|
|
8161
|
+
}
|
|
8162
|
+
|
|
8163
|
+
function foreshadowOccurrencesField(item, chapters) {
|
|
8164
|
+
const occurrences = item?.occurrences?.length ? item.occurrences : [null];
|
|
8165
|
+
return `<section class="form-field foreshadow-editor-section foreshadow-occurrence-field" data-foreshadow-occurrences aria-labelledby="foreshadow-occurrence-title">
|
|
8166
|
+
<div class="foreshadow-occurrence-toolbar"><div><h3 id="foreshadow-occurrence-title">章节节点</h3><p>每张卡片代表一次独立的埋设、提醒或回收,类型、章节和说明始终绑定在同一组内。</p></div><button type="button" data-foreshadow-occurrence-add>添加节点</button></div>
|
|
8167
|
+
<div class="foreshadow-occurrence-rows" data-foreshadow-occurrence-rows>${occurrences.map((record, index) => foreshadowOccurrenceRow(record, chapters, index)).join("")}</div>
|
|
8168
|
+
<p class="foreshadow-occurrence-empty hidden" data-foreshadow-occurrence-empty>尚未添加章节节点。</p>
|
|
8169
|
+
</section>`;
|
|
8170
|
+
}
|
|
8171
|
+
|
|
8172
|
+
function foreshadowOverviewField(item, options, firstPayoff) {
|
|
8173
|
+
return `<section class="foreshadow-editor-section foreshadow-overview-field" aria-labelledby="foreshadow-overview-title">
|
|
8174
|
+
<header><h3 id="foreshadow-overview-title">伏笔信息</h3><p>维护伏笔本身的内容、状态与计划回收位置。</p></header>
|
|
8175
|
+
<div class="foreshadow-overview-grid">
|
|
8176
|
+
${field("title", "伏笔名称", "text", item?.title)}
|
|
8177
|
+
${field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]])}
|
|
8178
|
+
${field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]])}
|
|
8179
|
+
${field("plannedPayoffChapterId", "计划回收章节", "select", item?.plannedPayoffChapterId ?? firstPayoff?.chapterId ?? "", options)}
|
|
8180
|
+
</div>
|
|
8181
|
+
<div class="foreshadow-overview-description">${field("description", "内容与预期作用", "textarea", item?.description)}</div>
|
|
8182
|
+
</section>`;
|
|
8183
|
+
}
|
|
8184
|
+
|
|
8185
|
+
function foreshadowResolutionField(item) {
|
|
8186
|
+
return `<section class="foreshadow-editor-section foreshadow-resolution-field" aria-labelledby="foreshadow-resolution-title">
|
|
8187
|
+
<header><h3 id="foreshadow-resolution-title">回收结果</h3><p>记录伏笔最终如何揭示或完成;它不属于上方任意单个章节节点。</p></header>
|
|
8188
|
+
${field("resolutionNote", "回收结论", "textarea", item?.resolutionNote)}
|
|
8189
|
+
</section>`;
|
|
8190
|
+
}
|
|
8191
|
+
|
|
8192
|
+
function updateForeshadowOccurrenceRowSummary(row) {
|
|
8193
|
+
const role = row.querySelector("[data-foreshadow-occurrence-role]").value;
|
|
8194
|
+
const chapter = row.querySelector("[data-foreshadow-occurrence-chapter]");
|
|
8195
|
+
row.dataset.occurrenceRole = role;
|
|
8196
|
+
row.querySelector("[data-foreshadow-occurrence-role-label]").textContent = occurrenceRoleLabel(role);
|
|
8197
|
+
row.querySelector("[data-foreshadow-occurrence-summary]").textContent = chapter.value ? chapter.selectedOptions[0]?.textContent ?? "已关联章节" : "未关联章节";
|
|
8198
|
+
}
|
|
8199
|
+
|
|
8200
|
+
function renumberForeshadowOccurrenceRows(section) {
|
|
8201
|
+
const rows = [...section.querySelectorAll("[data-foreshadow-occurrence]")];
|
|
8202
|
+
rows.forEach((row, index) => {
|
|
8203
|
+
const sequence = index + 1;
|
|
8204
|
+
row.querySelector("[data-foreshadow-occurrence-index]").textContent = `节点 ${sequence}`;
|
|
8205
|
+
row.querySelector("[data-foreshadow-occurrence-remove]").setAttribute("aria-label", `删除第 ${sequence} 个章节节点`);
|
|
8206
|
+
row.querySelector("[data-foreshadow-occurrence-role]").setAttribute("aria-label", `第 ${sequence} 个节点类型`);
|
|
8207
|
+
row.querySelector("[data-foreshadow-occurrence-chapter]").setAttribute("aria-label", `第 ${sequence} 个关联章节`);
|
|
8208
|
+
row.querySelector("[data-foreshadow-occurrence-note]").setAttribute("aria-label", `第 ${sequence} 个节点说明`);
|
|
8209
|
+
updateForeshadowOccurrenceRowSummary(row);
|
|
8210
|
+
});
|
|
8211
|
+
section.querySelector("[data-foreshadow-occurrence-empty]").classList.toggle("hidden", rows.length > 0);
|
|
8212
|
+
}
|
|
8213
|
+
|
|
8214
|
+
function bindForeshadowOccurrenceControls(container, chapters) {
|
|
8215
|
+
const section = container.querySelector("[data-foreshadow-occurrences]");
|
|
8216
|
+
if (!section) return;
|
|
8217
|
+
const rows = section.querySelector("[data-foreshadow-occurrence-rows]");
|
|
8218
|
+
section.addEventListener("change", (event) => {
|
|
8219
|
+
const row = event.target.closest("[data-foreshadow-occurrence]");
|
|
8220
|
+
if (row && event.target.matches("[data-foreshadow-occurrence-role], [data-foreshadow-occurrence-chapter]")) updateForeshadowOccurrenceRowSummary(row);
|
|
8221
|
+
});
|
|
8222
|
+
section.addEventListener("click", (event) => {
|
|
8223
|
+
if (event.target.closest("[data-foreshadow-occurrence-add]")) {
|
|
8224
|
+
const index = rows.querySelectorAll("[data-foreshadow-occurrence]").length;
|
|
8225
|
+
rows.insertAdjacentHTML("beforeend", foreshadowOccurrenceRow(null, chapters, index));
|
|
8226
|
+
renumberForeshadowOccurrenceRows(section);
|
|
8227
|
+
rows.lastElementChild?.querySelector("[data-foreshadow-occurrence-role]")?.focus();
|
|
8228
|
+
return;
|
|
8229
|
+
}
|
|
8230
|
+
const remove = event.target.closest("[data-foreshadow-occurrence-remove]");
|
|
8231
|
+
if (!remove) return;
|
|
8232
|
+
const row = remove.closest("[data-foreshadow-occurrence]");
|
|
8233
|
+
const nextFocus = row.nextElementSibling?.querySelector("[data-foreshadow-occurrence-role]")
|
|
8234
|
+
?? row.previousElementSibling?.querySelector("[data-foreshadow-occurrence-role]")
|
|
8235
|
+
?? section.querySelector("[data-foreshadow-occurrence-add]");
|
|
8236
|
+
row.remove();
|
|
8237
|
+
renumberForeshadowOccurrenceRows(section);
|
|
8238
|
+
nextFocus?.focus();
|
|
8239
|
+
});
|
|
8240
|
+
renumberForeshadowOccurrenceRows(section);
|
|
8241
|
+
}
|
|
8242
|
+
|
|
8243
|
+
function readForeshadowOccurrences(item) {
|
|
8244
|
+
const previousById = new Map((item?.occurrences ?? []).map((record) => [record.id, record]));
|
|
8245
|
+
const seen = new Map();
|
|
8246
|
+
return [...$("#dialog-fields").querySelectorAll("[data-foreshadow-occurrence]")].flatMap((row, index) => {
|
|
8247
|
+
const role = row.querySelector("[data-foreshadow-occurrence-role]").value;
|
|
8248
|
+
const chapterId = row.querySelector("[data-foreshadow-occurrence-chapter]").value;
|
|
8249
|
+
const note = row.querySelector("[data-foreshadow-occurrence-note]").value;
|
|
8250
|
+
if (!chapterId) {
|
|
8251
|
+
if (note.trim()) throw new Error(`第 ${index + 1} 个章节节点填写了说明,请选择关联章节`);
|
|
8252
|
+
return [];
|
|
8253
|
+
}
|
|
8254
|
+
const key = `${role}:${chapterId}`;
|
|
8255
|
+
const duplicateIndex = seen.get(key);
|
|
8256
|
+
if (duplicateIndex) throw new Error(`第 ${index + 1} 个章节节点与第 ${duplicateIndex} 个重复`);
|
|
8257
|
+
seen.set(key, index + 1);
|
|
8258
|
+
const previous = previousById.get(row.dataset.occurrenceId);
|
|
8259
|
+
return [{
|
|
8260
|
+
chapterId,
|
|
8261
|
+
role,
|
|
8262
|
+
note,
|
|
8263
|
+
evidence: previous?.chapterId === chapterId && previous?.role === role ? previous.evidence : []
|
|
8264
|
+
}];
|
|
8265
|
+
});
|
|
8266
|
+
}
|
|
8267
|
+
|
|
7756
8268
|
function openForeshadowDialog(item) {
|
|
7757
8269
|
const chapters = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
|
|
7758
8270
|
if (!chapters.length) return toast("请先创建章节", "error");
|
|
7759
8271
|
const options = [["", "暂不关联"], ...chapters];
|
|
7760
|
-
const
|
|
7761
|
-
const editableOccurrenceIds = new Set(["setup", "reminder", "payoff"].map((role) => occurrence(role)?.id).filter(Boolean));
|
|
7762
|
-
const preservedOccurrences = (item?.occurrences ?? [])
|
|
7763
|
-
.filter((record) => !editableOccurrenceIds.has(record.id))
|
|
7764
|
-
.map((record) => ({ chapterId: record.chapterId, role: record.role, note: record.note, evidence: record.evidence }));
|
|
8272
|
+
const firstPayoff = item?.occurrences?.find((record) => record.role === "payoff");
|
|
7765
8273
|
openDialog(item ? "编辑伏笔" : "新建伏笔",
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]]) +
|
|
7770
|
-
field("setupChapterId", "埋设章节", "select", occurrence("setup")?.chapterId ?? "", options) +
|
|
7771
|
-
field("setupNote", "埋设说明", "textarea", occurrence("setup")?.note ?? "") +
|
|
7772
|
-
field("reminderChapterId", "提醒章节", "select", occurrence("reminder")?.chapterId ?? "", options) +
|
|
7773
|
-
field("reminderNote", "提醒说明", "textarea", occurrence("reminder")?.note ?? "") +
|
|
7774
|
-
field("payoffChapterId", "回收章节", "select", occurrence("payoff")?.chapterId ?? item?.plannedPayoffChapterId ?? "", options) +
|
|
7775
|
-
field("payoffNote", "回收说明", "textarea", occurrence("payoff")?.note ?? "") +
|
|
7776
|
-
field("resolutionNote", "回收结论", "textarea", item?.resolutionNote),
|
|
8274
|
+
foreshadowOverviewField(item, options, firstPayoff) +
|
|
8275
|
+
foreshadowOccurrencesField(item, chapters) +
|
|
8276
|
+
foreshadowResolutionField(item),
|
|
7777
8277
|
async (form) => {
|
|
7778
|
-
const
|
|
7779
|
-
const chapterId = form.get(`${role}ChapterId`);
|
|
7780
|
-
if (!chapterId) return [];
|
|
7781
|
-
const previous = occurrence(role);
|
|
7782
|
-
return [{
|
|
7783
|
-
chapterId,
|
|
7784
|
-
role,
|
|
7785
|
-
note: form.get(`${role}Note`),
|
|
7786
|
-
evidence: previous?.chapterId === chapterId ? previous.evidence : []
|
|
7787
|
-
}];
|
|
7788
|
-
});
|
|
7789
|
-
const occurrences = [...preservedOccurrences, ...editedOccurrences];
|
|
8278
|
+
const occurrences = readForeshadowOccurrences(item);
|
|
7790
8279
|
const body = {
|
|
7791
8280
|
title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
|
|
7792
|
-
plannedPayoffChapterId: form.get("
|
|
8281
|
+
plannedPayoffChapterId: form.get("plannedPayoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
|
|
7793
8282
|
...(item ? { expectedVersionNo: item.versionNo } : {})
|
|
7794
8283
|
};
|
|
7795
8284
|
await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
|
|
7796
8285
|
await renderOutlines();
|
|
7797
8286
|
toast(item ? "伏笔已更新" : "伏笔已创建");
|
|
7798
|
-
}, item ? "伏笔管理" : "创作线索");
|
|
8287
|
+
}, item ? "伏笔管理" : "创作线索", { large: true, meta: "完整编辑伏笔信息、章节节点与回收结果" });
|
|
8288
|
+
bindForeshadowOccurrenceControls($("#dialog-fields"), chapters);
|
|
7799
8289
|
}
|
|
7800
8290
|
|
|
7801
8291
|
function openTimelineSplitDialog(item) {
|
|
@@ -8304,12 +8794,22 @@ async function streamChat(body) {
|
|
|
8304
8794
|
const message = document.createElement("div");
|
|
8305
8795
|
message.className = "assistant-message is-streaming";
|
|
8306
8796
|
message.dataset.testid = "ai-stream-message";
|
|
8307
|
-
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite"></div><div class="message-meta">正在连接模型流……</div>';
|
|
8797
|
+
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
|
|
8308
8798
|
attachMessageHeading(message, "助手 · 正在生成");
|
|
8309
8799
|
$("#ai-feed").append(message);
|
|
8310
8800
|
scrollAiFeedToBottom();
|
|
8311
8801
|
const content = message.querySelector(".message-body");
|
|
8312
8802
|
const meta = message.querySelector(".message-meta");
|
|
8803
|
+
const typewriter = createStreamTypewriter({
|
|
8804
|
+
onRender: (text, progress) => {
|
|
8805
|
+
content.innerHTML = renderMarkdown(text);
|
|
8806
|
+
const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
|
|
8807
|
+
meta.textContent = progress.pendingCharacters > 0
|
|
8808
|
+
? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
|
|
8809
|
+
: `正在生成 · ${progress.visibleCharacters} 字`;
|
|
8810
|
+
scrollAiFeedToBottom();
|
|
8811
|
+
}
|
|
8812
|
+
});
|
|
8313
8813
|
let streamedText = "";
|
|
8314
8814
|
let generatedMetadata = {};
|
|
8315
8815
|
let toolCalls = [];
|
|
@@ -8331,7 +8831,7 @@ async function streamChat(body) {
|
|
|
8331
8831
|
const decoder = new TextDecoder();
|
|
8332
8832
|
let buffer = "";
|
|
8333
8833
|
let streamError = null;
|
|
8334
|
-
const consume = (eventText) => {
|
|
8834
|
+
const consume = async (eventText) => {
|
|
8335
8835
|
let eventName = "message";
|
|
8336
8836
|
const dataLines = [];
|
|
8337
8837
|
for (const line of eventText.split(/\r?\n/)) {
|
|
@@ -8342,12 +8842,12 @@ async function streamChat(body) {
|
|
|
8342
8842
|
const payload = JSON.parse(dataLines.join("\n"));
|
|
8343
8843
|
if (eventName === "delta") {
|
|
8344
8844
|
const firstFinalDelta = streamedText.length === 0;
|
|
8345
|
-
|
|
8845
|
+
const delta = typeof payload.delta === "string" ? payload.delta : "";
|
|
8846
|
+
streamedText += delta;
|
|
8346
8847
|
if (streamedText.length > 0) finalAnswerStarted = true;
|
|
8347
|
-
|
|
8848
|
+
typewriter.append(delta);
|
|
8348
8849
|
if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
|
|
8349
|
-
meta.textContent =
|
|
8350
|
-
scrollAiFeedToBottom();
|
|
8850
|
+
meta.textContent = "正在生成回复……";
|
|
8351
8851
|
} else if (eventName === "process_step") {
|
|
8352
8852
|
const step = { ...payload };
|
|
8353
8853
|
const append = step.append === true;
|
|
@@ -8368,7 +8868,9 @@ async function streamChat(body) {
|
|
|
8368
8868
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
8369
8869
|
scrollAiFeedToBottom();
|
|
8370
8870
|
} else if (eventName === "complete") {
|
|
8871
|
+
await typewriter.finish();
|
|
8371
8872
|
message.classList.remove("is-streaming");
|
|
8873
|
+
content.setAttribute("aria-busy", "false");
|
|
8372
8874
|
message.querySelector(".message-heading > span").textContent = "助手";
|
|
8373
8875
|
toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
|
|
8374
8876
|
processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
|
|
@@ -8387,14 +8889,17 @@ async function streamChat(body) {
|
|
|
8387
8889
|
buffer += decoder.decode(chunk.value, { stream: !chunk.done });
|
|
8388
8890
|
const events = buffer.split(/\r?\n\r?\n/);
|
|
8389
8891
|
buffer = events.pop() ?? "";
|
|
8390
|
-
events
|
|
8892
|
+
for (const eventText of events) await consume(eventText);
|
|
8391
8893
|
if (chunk.done) break;
|
|
8392
8894
|
}
|
|
8393
|
-
if (buffer.trim()) consume(buffer);
|
|
8895
|
+
if (buffer.trim()) await consume(buffer);
|
|
8896
|
+
await typewriter.finish();
|
|
8394
8897
|
if (streamError) throw streamError;
|
|
8395
8898
|
return { content: streamedText, message, metadata: generatedMetadata };
|
|
8396
8899
|
} catch (error) {
|
|
8900
|
+
typewriter.reveal();
|
|
8397
8901
|
message.classList.remove("is-streaming");
|
|
8902
|
+
content.setAttribute("aria-busy", "false");
|
|
8398
8903
|
message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
|
|
8399
8904
|
renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
|
|
8400
8905
|
meta.textContent = "生成中断";
|
|
@@ -8715,18 +9220,22 @@ async function showChapterInsight() {
|
|
|
8715
9220
|
if (requestId !== chapterInsightRequestId || state.chapter?.id !== chapterId || $("#editor-view").classList.contains("hidden")) return;
|
|
8716
9221
|
const insight = insights.find((item) => item.chapterVersion === state.chapter.versionNo) ?? insights[0];
|
|
8717
9222
|
const region = $("#toast-region");
|
|
9223
|
+
const chapterTitle = state.chapter.title;
|
|
8718
9224
|
const element = document.createElement("section");
|
|
8719
9225
|
element.id = "chapter-insight-toast";
|
|
8720
9226
|
element.className = "toast chapter-insight-toast";
|
|
8721
9227
|
element.setAttribute("role", "region");
|
|
8722
|
-
element.setAttribute("aria-label",
|
|
9228
|
+
element.setAttribute("aria-label", `章节概览:${chapterTitle}`);
|
|
8723
9229
|
const header = document.createElement("header");
|
|
8724
9230
|
header.className = "chapter-insight-toast-header";
|
|
8725
9231
|
const heading = document.createElement("div");
|
|
8726
9232
|
heading.className = "chapter-insight-toast-heading";
|
|
8727
9233
|
const title = document.createElement("strong");
|
|
8728
9234
|
title.textContent = "章节概览";
|
|
8729
|
-
|
|
9235
|
+
const chapterName = document.createElement("span");
|
|
9236
|
+
chapterName.className = "chapter-insight-toast-chapter-title";
|
|
9237
|
+
chapterName.textContent = chapterTitle;
|
|
9238
|
+
heading.append(title, chapterName);
|
|
8730
9239
|
if (insight && insight.chapterVersion !== state.chapter.versionNo) {
|
|
8731
9240
|
const stale = document.createElement("span");
|
|
8732
9241
|
stale.textContent = `基于旧版本 v${insight.chapterVersion}`;
|
|
@@ -9369,6 +9878,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
|
|
|
9369
9878
|
body: {
|
|
9370
9879
|
toastPosition: $("#toast-position").value,
|
|
9371
9880
|
pageSizes: {
|
|
9881
|
+
drafts: Number($("#page-size-drafts").value),
|
|
9372
9882
|
settings: Number($("#page-size-settings").value),
|
|
9373
9883
|
characters: Number($("#page-size-characters").value),
|
|
9374
9884
|
organizations: Number($("#page-size-organizations").value),
|
|
@@ -9401,6 +9911,9 @@ $("#members-dialog").addEventListener("close", () => {
|
|
|
9401
9911
|
memberDialogDirectory = [];
|
|
9402
9912
|
});
|
|
9403
9913
|
$("#form-dialog").addEventListener("close", () => {
|
|
9914
|
+
formDialogVditors.forEach(destroyVditorEditor);
|
|
9915
|
+
formDialogVditors = [];
|
|
9916
|
+
void discardPendingMarkdownAttachments();
|
|
9404
9917
|
if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
|
|
9405
9918
|
});
|
|
9406
9919
|
$("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
|
|
@@ -9597,7 +10110,7 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
9597
10110
|
}
|
|
9598
10111
|
});
|
|
9599
10112
|
$("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
|
|
9600
|
-
$("#module-create-button").addEventListener("click", () => ({ settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
10113
|
+
$("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
9601
10114
|
$("#ai-prompt").addEventListener("input", async () => {
|
|
9602
10115
|
updateAiMentionMenu();
|
|
9603
10116
|
scheduleAiContextUsage();
|
|
@@ -9738,9 +10251,24 @@ $("#chapter-type-menu").addEventListener("click", async (event) => {
|
|
|
9738
10251
|
toast(error.message, "error");
|
|
9739
10252
|
}
|
|
9740
10253
|
});
|
|
10254
|
+
$("#markdown-table-wrap-toggle").addEventListener("click", () => {
|
|
10255
|
+
const table = markdownTableMenuTarget;
|
|
10256
|
+
if (!table?.isConnected) return closeMarkdownTableMenu();
|
|
10257
|
+
const wrapping = !table.classList.contains("is-wrapping");
|
|
10258
|
+
setMarkdownTableWrapping(table, wrapping);
|
|
10259
|
+
closeMarkdownTableMenu(true);
|
|
10260
|
+
toast(wrapping ? "表格已启用自动换行" : "表格已恢复横向滚动");
|
|
10261
|
+
});
|
|
10262
|
+
document.addEventListener("contextmenu", (event) => {
|
|
10263
|
+
const header = event.target.closest?.("[data-markdown-table-header]");
|
|
10264
|
+
if (!header) return;
|
|
10265
|
+
event.preventDefault();
|
|
10266
|
+
openMarkdownTableMenu(header, event.clientX, event.clientY);
|
|
10267
|
+
});
|
|
9741
10268
|
document.addEventListener("pointerdown", (event) => {
|
|
9742
10269
|
if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
|
|
9743
10270
|
if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
|
|
10271
|
+
if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
|
|
9744
10272
|
if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
|
|
9745
10273
|
if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
|
|
9746
10274
|
$("#account-menu").classList.add("hidden");
|
|
@@ -9752,9 +10280,16 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
9752
10280
|
}
|
|
9753
10281
|
});
|
|
9754
10282
|
document.addEventListener("keydown", (event) => {
|
|
10283
|
+
const header = event.target.closest?.("[data-markdown-table-header]");
|
|
10284
|
+
if (header && (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))) {
|
|
10285
|
+
event.preventDefault();
|
|
10286
|
+
openMarkdownTableMenu(header, 0, 0);
|
|
10287
|
+
return;
|
|
10288
|
+
}
|
|
9755
10289
|
if (event.key === "Escape") {
|
|
9756
10290
|
closeChapterTypeMenu();
|
|
9757
10291
|
closeLineCitationMenu();
|
|
10292
|
+
closeMarkdownTableMenu(true);
|
|
9758
10293
|
hideAiMentionMenu();
|
|
9759
10294
|
}
|
|
9760
10295
|
});
|