@musnows/scriverse 0.9.4 → 0.9.5
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-write-plans.js +2279 -0
- package/dist/ai-write-plans.js.map +1 -0
- package/dist/ai.js +1236 -91
- package/dist/ai.js.map +1 -1
- package/dist/app.js +190 -3
- package/dist/app.js.map +1 -1
- package/dist/chapter-annotation-anchor.js +327 -0
- package/dist/chapter-annotation-anchor.js.map +1 -0
- package/dist/database.js +338 -3
- package/dist/database.js.map +1 -1
- package/dist/public/ai-context-meter.js +3 -3
- package/dist/public/ai-interactive.js +483 -0
- package/dist/public/app.js +1179 -59
- package/dist/public/chapter-editor-behavior.js +40 -0
- package/dist/public/chapter-line-id-tracker.d.ts +19 -0
- package/dist/public/chapter-line-id-tracker.js +127 -0
- package/dist/public/index.html +59 -10
- package/dist/public/styles.css +230 -49
- package/dist/public/theme-init.js +25 -1
- package/dist/roleplay-memory.js +53 -0
- package/dist/roleplay-memory.js.map +1 -0
- package/dist/store.js +450 -19
- package/dist/store.js.map +1 -1
- package/dist/ui-module-preload.js +27 -0
- package/dist/ui-module-preload.js.map +1 -0
- package/dist/user-auth.js +22 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
package/dist/public/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.j
|
|
|
15
15
|
import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
|
|
16
16
|
import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
17
17
|
import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
|
|
18
|
+
import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
|
|
18
19
|
import {
|
|
19
20
|
FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
|
|
20
21
|
foreshadowReminderRequestTargetsState,
|
|
@@ -32,14 +33,26 @@ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/
|
|
|
32
33
|
import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
|
|
33
34
|
import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount } from "/ai-usage.js?v=20260821-ai-usage-pricing-v1";
|
|
34
35
|
import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
|
|
35
|
-
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=
|
|
36
|
+
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260828-context-output-usage-v1";
|
|
36
37
|
import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
|
|
37
38
|
import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
|
|
39
|
+
import {
|
|
40
|
+
AI_WRITE_TOOLS_META,
|
|
41
|
+
cacheAiQuestionView,
|
|
42
|
+
cacheAiWritePlanDetail,
|
|
43
|
+
createInteractiveToolCard,
|
|
44
|
+
parseInteractiveToolPayload,
|
|
45
|
+
renderApprovalCenterRows,
|
|
46
|
+
renderWritePlanDetailMarkup,
|
|
47
|
+
isInteractiveToolPending,
|
|
48
|
+
aiFormatDateTime
|
|
49
|
+
} from "/ai-interactive.js?v=20260829-question-tool-result-v5";
|
|
38
50
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
39
51
|
import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
|
|
40
52
|
import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
|
|
41
53
|
import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatImageFile, normalizeAiChatImageAttachments } from "/ai-image-attachments.js?v=20260820-ai-chat-image-attachments-v2";
|
|
42
54
|
import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
|
|
55
|
+
import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft } from "/chapter-line-id-tracker.js?v=20260828-stable-line-ids-v1";
|
|
43
56
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
44
57
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
45
58
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
|
|
@@ -274,6 +287,7 @@ let readingReturnRoute = null;
|
|
|
274
287
|
let readingPreviousFocus = null;
|
|
275
288
|
let readingPositionSaveTimer = null;
|
|
276
289
|
let readingResizeFrame = null;
|
|
290
|
+
const readingVolumeDirectoryConcurrency = 4;
|
|
277
291
|
|
|
278
292
|
let timelineMultiSelectEnabled = false;
|
|
279
293
|
let timelineActiveTrackId = null;
|
|
@@ -444,6 +458,10 @@ function canGlobalReplaceAny(work = state.work) {
|
|
|
444
458
|
return Boolean(work) && (canGlobalReplaceScope("prose", work) || canGlobalReplaceScope("settings", work));
|
|
445
459
|
}
|
|
446
460
|
|
|
461
|
+
function applyChapterEditorPreferences() {
|
|
462
|
+
$("#app").classList.toggle("editor-typewriter-mode", Boolean(state.work?.editorTypewriterModeEnabled));
|
|
463
|
+
}
|
|
464
|
+
|
|
447
465
|
function applyWorkAccessMode() {
|
|
448
466
|
const viewOnly = Boolean(state.work) && !canEditWork();
|
|
449
467
|
const proseReadOnly = Boolean(state.work) && !canEditProse();
|
|
@@ -456,6 +474,7 @@ function applyWorkAccessMode() {
|
|
|
456
474
|
$("#app").classList.toggle("prose-hidden-mode", proseHidden);
|
|
457
475
|
$("#app").classList.toggle("ai-hidden-mode", aiHidden);
|
|
458
476
|
document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
|
|
477
|
+
applyChapterEditorPreferences();
|
|
459
478
|
for (const item of WORK_PERMISSION_MODULES) {
|
|
460
479
|
if (!item.uiModule) continue;
|
|
461
480
|
const button = $(`#module-nav [data-module="${item.uiModule}"]`);
|
|
@@ -621,7 +640,10 @@ function assertAiRequestCurrent(request) {
|
|
|
621
640
|
}
|
|
622
641
|
|
|
623
642
|
function aiInteractionBusy() {
|
|
624
|
-
|
|
643
|
+
const activeTabId = activeAiChatTab()?.id;
|
|
644
|
+
return aiRequestManager.hasActive(activeTabId)
|
|
645
|
+
|| (activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false)
|
|
646
|
+
|| aiConversationNavigationPending !== null;
|
|
625
647
|
}
|
|
626
648
|
|
|
627
649
|
function aiSendButtonIconMarkup(stateName) {
|
|
@@ -631,12 +653,14 @@ function aiSendButtonIconMarkup(stateName) {
|
|
|
631
653
|
}
|
|
632
654
|
|
|
633
655
|
function syncAiRequestControls() {
|
|
634
|
-
const
|
|
656
|
+
const activeTabId = activeAiChatTab()?.id;
|
|
657
|
+
const sending = aiRequestManager.hasActive(activeTabId);
|
|
658
|
+
const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
|
|
635
659
|
const switching = aiConversationNavigationPending !== null;
|
|
636
660
|
const button = $("#ai-send");
|
|
637
|
-
const stateName = sending ? "stop" : switching ? "switching" : "send";
|
|
638
|
-
const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
|
|
639
|
-
button.disabled = switching;
|
|
661
|
+
const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
|
|
662
|
+
const label = sending ? "终止当前回复" : continuingQuestion ? "AI 正在根据回答继续处理" : switching ? "正在切换对话" : "发送消息";
|
|
663
|
+
button.disabled = switching || continuingQuestion;
|
|
640
664
|
button.dataset.state = stateName;
|
|
641
665
|
button.classList.toggle("is-stop", sending);
|
|
642
666
|
button.setAttribute("aria-label", label);
|
|
@@ -779,6 +803,8 @@ const aiConversationHistoryPageLimit = 20;
|
|
|
779
803
|
let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
780
804
|
let workScopedUiGeneration = 0;
|
|
781
805
|
let workSelectionRequestGeneration = 0;
|
|
806
|
+
let initialWorkDetail = null;
|
|
807
|
+
let initialReaderChapterRequest = null;
|
|
782
808
|
let chapterSelectionRequestGeneration = 0;
|
|
783
809
|
let aiConversationNavigationGeneration = 0;
|
|
784
810
|
let aiConversationNavigationPending = null;
|
|
@@ -1461,6 +1487,7 @@ let chapterLineNumberFrame = null;
|
|
|
1461
1487
|
let chapterLineNumberTimer = null;
|
|
1462
1488
|
let chapterLineLayout = null;
|
|
1463
1489
|
let chapterLineVirtualWindow = null;
|
|
1490
|
+
let chapterCaretScrollFrame = null;
|
|
1464
1491
|
let chapterLineSelection = null;
|
|
1465
1492
|
let chapterLineDrag = null;
|
|
1466
1493
|
let chapterWhitespaceVisible = true;
|
|
@@ -1468,6 +1495,8 @@ let chapterAutoSaveTimer = null;
|
|
|
1468
1495
|
let chapterSaveInFlight = null;
|
|
1469
1496
|
let chapterSaveGuardInFlight = null;
|
|
1470
1497
|
let lastSavedChapterSnapshot = null;
|
|
1498
|
+
let chapterDraftLineIdState = null;
|
|
1499
|
+
let chapterBeforeInputState = null;
|
|
1471
1500
|
let chapterSearchMatchIndex = -1;
|
|
1472
1501
|
let chapterSelectionRequestId = 0;
|
|
1473
1502
|
let chapterForeshadowReminderRequestId = 0;
|
|
@@ -1553,6 +1582,7 @@ let characterSectionEditorDirty = false;
|
|
|
1553
1582
|
let settingEditorVditor = null;
|
|
1554
1583
|
let knowledgeSectionVditor = null;
|
|
1555
1584
|
let characterSectionVditor = null;
|
|
1585
|
+
let vditorResourcesPromise = null;
|
|
1556
1586
|
const settingEditorDirtyTracker = createEditorDirtyTracker();
|
|
1557
1587
|
const characterSectionEditorDirtyTracker = createEditorDirtyTracker();
|
|
1558
1588
|
let formDialogVditors = [];
|
|
@@ -1562,17 +1592,22 @@ let moduleContentInteractionsBound = false;
|
|
|
1562
1592
|
function applyChapterEditorMode() {
|
|
1563
1593
|
const permissionBlocked = Boolean(state.work) && !canEditProse();
|
|
1564
1594
|
const viewOnly = permissionBlocked || chapterEditorReadOnly;
|
|
1595
|
+
const editButton = $("#chapter-edit-button");
|
|
1565
1596
|
$("#editor-view").classList.toggle("is-read-only", viewOnly);
|
|
1566
1597
|
$("#chapter-title").readOnly = viewOnly;
|
|
1567
1598
|
$("#chapter-content").readOnly = viewOnly;
|
|
1568
1599
|
$("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
|
|
1569
1600
|
$("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
|
|
1570
|
-
|
|
1571
|
-
|
|
1601
|
+
editButton.classList.toggle("hidden", permissionBlocked || !state.chapter);
|
|
1602
|
+
editButton.classList.toggle("primary-button", chapterEditorReadOnly);
|
|
1603
|
+
editButton.classList.toggle("ghost-button", !chapterEditorReadOnly);
|
|
1604
|
+
editButton.textContent = chapterEditorReadOnly ? "编辑" : "预览";
|
|
1605
|
+
editButton.setAttribute("aria-pressed", String(!chapterEditorReadOnly));
|
|
1606
|
+
editButton.setAttribute("aria-label", chapterEditorReadOnly ? "切换到编辑模式" : "切换到预览模式");
|
|
1572
1607
|
$("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
|
|
1573
|
-
$("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
|
|
1574
1608
|
syncChapterSearchControls();
|
|
1575
1609
|
if (viewOnly) cancelChapterAutoSave();
|
|
1610
|
+
syncMobileAiPanelSafeTop();
|
|
1576
1611
|
}
|
|
1577
1612
|
|
|
1578
1613
|
function enterChapterEditMode() {
|
|
@@ -1585,6 +1620,19 @@ function enterChapterEditMode() {
|
|
|
1585
1620
|
$("#chapter-content").focus();
|
|
1586
1621
|
}
|
|
1587
1622
|
|
|
1623
|
+
function toggleChapterEditPreviewMode() {
|
|
1624
|
+
if (!state.chapter || !canEditProse()) return;
|
|
1625
|
+
if (chapterEditorReadOnly) {
|
|
1626
|
+
enterChapterEditMode();
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
chapterEditorReadOnly = true;
|
|
1630
|
+
cancelChapterAutoSave();
|
|
1631
|
+
applyChapterEditorMode();
|
|
1632
|
+
setSaveState(state.dirty ? "预览中 · 有未保存修改" : "预览中", state.dirty);
|
|
1633
|
+
$("#chapter-edit-button").focus();
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1588
1636
|
function showEntityEditorPage(type, { readOnly = false } = {}) {
|
|
1589
1637
|
const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
|
|
1590
1638
|
const viewOnly = readOnly || !canEditModule(module);
|
|
@@ -1937,6 +1985,37 @@ function scheduleChapterLineNumbers(delay = 0) {
|
|
|
1937
1985
|
}, wait);
|
|
1938
1986
|
}
|
|
1939
1987
|
|
|
1988
|
+
function scheduleChapterCaretScroll() {
|
|
1989
|
+
if (!state.work?.editorTypewriterModeEnabled || chapterCaretScrollFrame !== null) return;
|
|
1990
|
+
chapterCaretScrollFrame = requestAnimationFrame(() => {
|
|
1991
|
+
chapterCaretScrollFrame = null;
|
|
1992
|
+
const input = $("#chapter-content");
|
|
1993
|
+
const measure = $("#chapter-line-measure");
|
|
1994
|
+
if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
|
|
1995
|
+
const style = getComputedStyle(input);
|
|
1996
|
+
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
1997
|
+
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
1998
|
+
const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
|
1999
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
|
|
2000
|
+
const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
|
|
2001
|
+
const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
|
|
2002
|
+
const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
|
|
2003
|
+
const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
|
|
2004
|
+
const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
|
|
2005
|
+
const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
|
|
2006
|
+
const nextScrollTop = calculateChapterCaretScroll({
|
|
2007
|
+
caretBottom,
|
|
2008
|
+
scrollTop: input.scrollTop,
|
|
2009
|
+
clientHeight: input.clientHeight,
|
|
2010
|
+
scrollHeight: input.scrollHeight
|
|
2011
|
+
});
|
|
2012
|
+
if (nextScrollTop === input.scrollTop) return;
|
|
2013
|
+
input.scrollTop = nextScrollTop;
|
|
2014
|
+
syncChapterLineNumberScroll();
|
|
2015
|
+
scheduleChapterLineNumbers();
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1940
2019
|
function lineIndexAtPointer(clientY) {
|
|
1941
2020
|
const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
|
|
1942
2021
|
if (!rows.length) return 0;
|
|
@@ -2696,6 +2775,8 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
2696
2775
|
recall_other: "回忆相识角色",
|
|
2697
2776
|
recall_known: "回忆知情设定",
|
|
2698
2777
|
recall_story: "回忆故事",
|
|
2778
|
+
recall_roleplay_memory: "回忆当前扮演线",
|
|
2779
|
+
remember_roleplay: "整理扮演记忆",
|
|
2699
2780
|
calculate_time: "计算日期"
|
|
2700
2781
|
};
|
|
2701
2782
|
|
|
@@ -2712,6 +2793,8 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
2712
2793
|
recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
|
|
2713
2794
|
recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
|
|
2714
2795
|
recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
|
|
2796
|
+
recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
|
|
2797
|
+
remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
|
|
2715
2798
|
calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
|
|
2716
2799
|
};
|
|
2717
2800
|
|
|
@@ -2875,6 +2958,10 @@ function openAiToolCallDetail(toolCall) {
|
|
|
2875
2958
|
|
|
2876
2959
|
function createAiToolCallButton(toolCall) {
|
|
2877
2960
|
const name = String(toolCall?.name ?? "unknown");
|
|
2961
|
+
if (name === "propose_write_plan" || name === "ask_user_question") {
|
|
2962
|
+
const card = createInteractiveToolCard(toolCall, AI_TOOL_CARD_ACTIONS);
|
|
2963
|
+
if (card) return card;
|
|
2964
|
+
}
|
|
2878
2965
|
const button = document.createElement("button");
|
|
2879
2966
|
button.type = "button";
|
|
2880
2967
|
button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
|
|
@@ -2885,6 +2972,392 @@ function createAiToolCallButton(toolCall) {
|
|
|
2885
2972
|
return button;
|
|
2886
2973
|
}
|
|
2887
2974
|
|
|
2975
|
+
// ---------------------------------------------------------------------------
|
|
2976
|
+
// AI 可写工具:审批卡片动作、修改计划详情、撤销与用户提问
|
|
2977
|
+
// ---------------------------------------------------------------------------
|
|
2978
|
+
|
|
2979
|
+
let aiWritePlanDialogPlanId = null;
|
|
2980
|
+
let aiWritePlanDialogBusy = false;
|
|
2981
|
+
let currentPlanDialogDetail = null;
|
|
2982
|
+
let currentPlanDialogFocusConfirm = false;
|
|
2983
|
+
let aiQuestionDialogQuestionId = null;
|
|
2984
|
+
let currentAiQuestionDialogView = null;
|
|
2985
|
+
let aiQuestionDialogBusy = false;
|
|
2986
|
+
const aiQuestionContinuationTabIds = new Set();
|
|
2987
|
+
let autoOpenedQuestionIds = new Set();
|
|
2988
|
+
const aiApprovalCenterState = { status: "" };
|
|
2989
|
+
|
|
2990
|
+
/** 消息流中交互工具卡片绑定的动作:全部先取服务端最新状态,再弹窗确认。 */
|
|
2991
|
+
const AI_TOOL_CARD_ACTIONS = {
|
|
2992
|
+
openPlanDetail(planId) {
|
|
2993
|
+
openAiWritePlanDetail(planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
|
|
2994
|
+
},
|
|
2995
|
+
confirmPlan(planId) {
|
|
2996
|
+
openAiWritePlanDetail(planId, { focusConfirm: true }).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
|
|
2997
|
+
},
|
|
2998
|
+
rejectPlan(planId) {
|
|
2999
|
+
decideAiWritePlan(planId, "reject").catch((error) => toast(`拒绝失败:${error.message}`, "error"));
|
|
3000
|
+
},
|
|
3001
|
+
openQuestionDialog(questionId) {
|
|
3002
|
+
openAiUserQuestionDialog(questionId).catch((error) => toast(`提问加载失败:${error.message}`, "error"));
|
|
3003
|
+
},
|
|
3004
|
+
rejectQuestion(questionId) {
|
|
3005
|
+
respondAiUserQuestion(questionId, { action: "reject" }).catch((error) => toast(`操作失败:${error.message}`, "error"));
|
|
3006
|
+
},
|
|
3007
|
+
openApprovalCenter() {
|
|
3008
|
+
openAiApprovalCenter();
|
|
3009
|
+
}
|
|
3010
|
+
};
|
|
3011
|
+
|
|
3012
|
+
/** 流式收到交互式可写工具调用时的提示与自动弹出提问。 */
|
|
3013
|
+
function handleInteractiveToolCallEvent(toolCall) {
|
|
3014
|
+
const name = String(toolCall?.name ?? "");
|
|
3015
|
+
if (name === "propose_write_plan") {
|
|
3016
|
+
if (toolCall.status === "failed") {
|
|
3017
|
+
const message = String(parseInteractiveToolPayload(toolCall)?.error?.message ?? "未知错误");
|
|
3018
|
+
toast(`AI 的写入审批提交失败:${message}`, "error");
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
const payload = parseInteractiveToolPayload(toolCall);
|
|
3022
|
+
const targets = Array.isArray(payload?.plan?.targets) ? payload.plan.targets.join("、") : "待确认操作";
|
|
3023
|
+
const summary = String(payload?.plan?.aiSummary ?? "");
|
|
3024
|
+
toast(`AI 提交了写入审批:${targets}${summary ? ` · ${summary}` : ""}`);
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (name !== "ask_user_question" || toolCall.status === "failed") return;
|
|
3028
|
+
const question = toolCall.result?.question;
|
|
3029
|
+
if (!question?.id) return;
|
|
3030
|
+
cacheAiQuestionView(question);
|
|
3031
|
+
const questionId = String(question.id);
|
|
3032
|
+
if (autoOpenedQuestionIds.has(questionId)) return;
|
|
3033
|
+
autoOpenedQuestionIds.add(questionId);
|
|
3034
|
+
// 直接弹出回答框等待作者选择;不会预填任何答案。
|
|
3035
|
+
openAiUserQuestionDialog(questionId).catch(() => undefined);
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
function questionsEndpoint(path) {
|
|
3039
|
+
return `/api/works/${state.work.id}/ai/questions${path}`;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
function plansEndpoint(path) {
|
|
3043
|
+
return `/api/works/${state.work.id}/ai/write-plans${path}`;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
async function fetchAiWritePlanDetail(planId) {
|
|
3047
|
+
const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}`));
|
|
3048
|
+
cacheAiWritePlanDetail(detail);
|
|
3049
|
+
return detail;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
async function decideAiWritePlan(planId, action) {
|
|
3053
|
+
const buttonLabel = action === "confirm" ? "确认执行" : "拒绝";
|
|
3054
|
+
const targetButton = $(action === "confirm" ? "#ai-write-plan-confirm" : "#ai-write-plan-reject");
|
|
3055
|
+
if (targetButton?.disabled || aiWritePlanDialogBusy) return;
|
|
3056
|
+
if (targetButton) targetButton.disabled = true;
|
|
3057
|
+
aiWritePlanDialogBusy = true;
|
|
3058
|
+
try {
|
|
3059
|
+
const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/${action}`), { method: "POST" });
|
|
3060
|
+
cacheAiWritePlanDetail(detail);
|
|
3061
|
+
toast(action === "confirm"
|
|
3062
|
+
? `已执行 AI 写入审批:${detail.aiSummary}`
|
|
3063
|
+
: `已拒绝该写入审批,未产生任何写入`);
|
|
3064
|
+
if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(detail.id)) {
|
|
3065
|
+
applyPlanDetailToDialog(detail);
|
|
3066
|
+
}
|
|
3067
|
+
if ($("#ai-approval-center-dialog").open) loadAiApprovalCenterPlans().catch(() => undefined);
|
|
3068
|
+
return detail;
|
|
3069
|
+
} catch (error) {
|
|
3070
|
+
// 已被处理(409)等冲突也需要刷新展示的明细状态。
|
|
3071
|
+
try {
|
|
3072
|
+
const fresh = await fetchAiWritePlanDetail(planId);
|
|
3073
|
+
if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(planId)) applyPlanDetailToDialog(fresh);
|
|
3074
|
+
else if (targetButton) targetButton.disabled = false;
|
|
3075
|
+
} catch {
|
|
3076
|
+
if (targetButton) targetButton.disabled = false;
|
|
3077
|
+
}
|
|
3078
|
+
throw new Error(`${buttonLabel}失败:${error.message}`);
|
|
3079
|
+
} finally {
|
|
3080
|
+
aiWritePlanDialogBusy = false;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
async function undoAiWritePlan(planId) {
|
|
3085
|
+
if (aiWritePlanDialogBusy) return;
|
|
3086
|
+
const button = $("#ai-write-plan-undo");
|
|
3087
|
+
button.disabled = true;
|
|
3088
|
+
try {
|
|
3089
|
+
const undoPlan = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/undo`), { method: "POST" });
|
|
3090
|
+
cacheAiWritePlanDetail(undoPlan);
|
|
3091
|
+
toast("已创建撤销审批,请在新弹窗中单独确认执行");
|
|
3092
|
+
await openAiWritePlanDetail(undoPlan.id);
|
|
3093
|
+
} catch (error) {
|
|
3094
|
+
toast(`创建撤销审批失败:${error.message}`, "error");
|
|
3095
|
+
button.disabled = false;
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function applyPlanDetailToDialog(detail) {
|
|
3100
|
+
currentPlanDialogDetail = detail;
|
|
3101
|
+
aiWritePlanDialogPlanId = String(detail.id);
|
|
3102
|
+
$("#ai-write-plan-body").innerHTML = renderWritePlanDetailMarkup(detail);
|
|
3103
|
+
const isPending = detail.status === "pending";
|
|
3104
|
+
const canUndo = Boolean(detail.undoAvailable) && detail.status === "executed";
|
|
3105
|
+
$("#ai-write-plan-confirm").classList.toggle("hidden", !isPending);
|
|
3106
|
+
$("#ai-write-plan-reject").classList.toggle("hidden", !isPending);
|
|
3107
|
+
$("#ai-write-plan-undo").classList.toggle("hidden", !canUndo);
|
|
3108
|
+
$("#ai-write-plan-confirm").disabled = false;
|
|
3109
|
+
$("#ai-write-plan-reject").disabled = false;
|
|
3110
|
+
$("#ai-write-plan-undo").disabled = false;
|
|
3111
|
+
if (isPending && currentPlanDialogFocusConfirm) {
|
|
3112
|
+
$("#ai-write-plan-dialog").querySelector(".card-actions")?.scrollIntoView({ block: "end" });
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
async function openAiWritePlanDetail(planId, options = {}) {
|
|
3117
|
+
currentPlanDialogFocusConfirm = options.focusConfirm === true;
|
|
3118
|
+
const dialog = $("#ai-write-plan-dialog");
|
|
3119
|
+
if (!dialog.open) dialog.showModal();
|
|
3120
|
+
$("#ai-write-plan-body").innerHTML = '<p class="usage-measurement-note">正在加载系统生成的完整修改明细……</p>';
|
|
3121
|
+
try {
|
|
3122
|
+
const detail = await fetchAiWritePlanDetail(planId);
|
|
3123
|
+
applyPlanDetailToDialog(detail);
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
$("#ai-write-plan-body").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
|
|
3126
|
+
throw error;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
async function loadAiApprovalCenterPlans() {
|
|
3131
|
+
const statusQuery = aiApprovalCenterState.status ? `?status=${encodeURIComponent(aiApprovalCenterState.status)}&limit=50` : "?limit=50";
|
|
3132
|
+
const [planPayload, questionPayload] = await Promise.all([
|
|
3133
|
+
api(plansEndpoint(statusQuery)),
|
|
3134
|
+
api(questionsEndpoint(statusQuery))
|
|
3135
|
+
]);
|
|
3136
|
+
const plans = Array.isArray(planPayload) ? planPayload : (Array.isArray(planPayload?.plans) ? planPayload.plans : []);
|
|
3137
|
+
const questions = Array.isArray(questionPayload) ? questionPayload : (Array.isArray(questionPayload?.questions) ? questionPayload.questions : []);
|
|
3138
|
+
$("#ai-approval-list-host").innerHTML = renderApprovalCenterRows(plans, questions);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function openAiApprovalCenter() {
|
|
3142
|
+
const dialog = $("#ai-approval-center-dialog");
|
|
3143
|
+
if (!dialog.open) dialog.showModal();
|
|
3144
|
+
$("#ai-approval-center-toggle").setAttribute("aria-expanded", "true");
|
|
3145
|
+
$("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
|
|
3146
|
+
loadAiApprovalCenterPlans().catch((error) => {
|
|
3147
|
+
$("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
async function fetchAiUserQuestion(questionId) {
|
|
3152
|
+
const question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}`));
|
|
3153
|
+
cacheAiQuestionView(question);
|
|
3154
|
+
return question;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function syncAiQuestionOptionPresentation() {
|
|
3158
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
3159
|
+
for (const label of document.querySelectorAll(".ai-question-option-item")) {
|
|
3160
|
+
const input = label.querySelector('input[name="ai-question-choice"]');
|
|
3161
|
+
const isSelected = Boolean(input && checked === input);
|
|
3162
|
+
label.classList.toggle("is-selected", isSelected);
|
|
3163
|
+
label.classList.toggle("is-recommended", label.dataset.recommended === "true" && (!checked || isSelected));
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
function syncAiQuestionAnswerCount() {
|
|
3168
|
+
const input = $("#ai-question-custom-answer");
|
|
3169
|
+
const maximum = Number(input.maxLength) || 3000;
|
|
3170
|
+
$("#ai-question-answer-count").textContent = `已填写 ${input.value.length} / ${maximum}`;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
function renderAiUserQuestionOptions(question) {
|
|
3174
|
+
const host = $("#ai-question-options");
|
|
3175
|
+
const customInput = $("#ai-question-custom-answer");
|
|
3176
|
+
const isPending = question.status === "pending";
|
|
3177
|
+
host.replaceChildren();
|
|
3178
|
+
for (const option of question.options ?? []) {
|
|
3179
|
+
const label = document.createElement("label");
|
|
3180
|
+
label.className = "ai-question-option-item";
|
|
3181
|
+
label.dataset.recommended = String(option.recommended === true);
|
|
3182
|
+
const input = document.createElement("input");
|
|
3183
|
+
input.type = "radio";
|
|
3184
|
+
input.name = "ai-question-choice";
|
|
3185
|
+
input.value = String(option.index);
|
|
3186
|
+
input.checked = question.selectedOption === option.index;
|
|
3187
|
+
input.disabled = !isPending;
|
|
3188
|
+
const span = document.createElement("span");
|
|
3189
|
+
span.textContent = `${option.recommended ? "(最推荐)" : ""}${option.label}`;
|
|
3190
|
+
label.append(input, span);
|
|
3191
|
+
host.append(label);
|
|
3192
|
+
}
|
|
3193
|
+
const customLabel = document.createElement("label");
|
|
3194
|
+
customLabel.className = "ai-question-option-item ai-question-custom-choice";
|
|
3195
|
+
const customRadio = document.createElement("input");
|
|
3196
|
+
customRadio.type = "radio";
|
|
3197
|
+
customRadio.name = "ai-question-choice";
|
|
3198
|
+
customRadio.value = "custom";
|
|
3199
|
+
customRadio.checked = question.selectedOption == null && question.isCustomAnswer === true;
|
|
3200
|
+
customRadio.disabled = !isPending;
|
|
3201
|
+
const customText = document.createElement("span");
|
|
3202
|
+
customText.textContent = "自定义回答";
|
|
3203
|
+
customLabel.append(customRadio, customText);
|
|
3204
|
+
host.append(customLabel);
|
|
3205
|
+
customInput.value = question.customAnswer ?? (question.selectedOption == null && question.isCustomAnswer ? (question.answerText ?? "") : "");
|
|
3206
|
+
customInput.disabled = !isPending;
|
|
3207
|
+
syncAiQuestionAnswerCount();
|
|
3208
|
+
syncAiQuestionOptionPresentation();
|
|
3209
|
+
// 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
|
|
3210
|
+
if (isPending) syncAiQuestionSubmitState();
|
|
3211
|
+
else $("#ai-question-submit").disabled = true;
|
|
3212
|
+
$("#ai-question-skip").disabled = !isPending;
|
|
3213
|
+
$("#ai-question-expiry").textContent = isPending
|
|
3214
|
+
? `请选择一个预设选项,或填写自定义回答后提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
|
|
3215
|
+
: `该问题当前状态:${question.statusLabel}${question.answerText ? ` · 回答:${question.answerText}` : ""}`;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
async function refreshAiQuestionDialog() {
|
|
3219
|
+
const question = await fetchAiUserQuestion(aiQuestionDialogQuestionId);
|
|
3220
|
+
currentAiQuestionDialogView = question;
|
|
3221
|
+
$("#ai-question-text").textContent = question.question;
|
|
3222
|
+
renderAiUserQuestionOptions(question);
|
|
3223
|
+
return question;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
async function openAiUserQuestionDialog(questionId) {
|
|
3227
|
+
aiQuestionDialogQuestionId = String(questionId);
|
|
3228
|
+
const dialog = $("#ai-question-dialog");
|
|
3229
|
+
if (!dialog.open) dialog.showModal();
|
|
3230
|
+
$("#ai-question-text").textContent = "";
|
|
3231
|
+
$("#ai-question-options").replaceChildren();
|
|
3232
|
+
$("#ai-question-custom-answer").value = "";
|
|
3233
|
+
syncAiQuestionAnswerCount();
|
|
3234
|
+
$("#ai-question-expiry").textContent = "正在加载问题……";
|
|
3235
|
+
try {
|
|
3236
|
+
return await refreshAiQuestionDialog();
|
|
3237
|
+
} catch (error) {
|
|
3238
|
+
$("#ai-question-expiry").textContent = `加载失败:${error.message}`;
|
|
3239
|
+
throw error;
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
function beginAiQuestionContinuationUi(conversationId, questionId) {
|
|
3244
|
+
const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
|
|
3245
|
+
if (!tab) return null;
|
|
3246
|
+
const card = questionId
|
|
3247
|
+
? tab.feed.querySelector(`.ai-question-card[data-question-id="${CSS.escape(String(questionId))}"]`)
|
|
3248
|
+
: null;
|
|
3249
|
+
const note = card?.querySelector(".ai-interactive-note") ?? null;
|
|
3250
|
+
const status = card?.querySelector(".ai-status-chip") ?? null;
|
|
3251
|
+
const previousNote = note?.textContent ?? "";
|
|
3252
|
+
const previousStatus = status?.textContent ?? "";
|
|
3253
|
+
const controlStates = card
|
|
3254
|
+
? [...card.querySelectorAll("button")].map((button) => ({ button, disabled: button.disabled }))
|
|
3255
|
+
: [];
|
|
3256
|
+
if (card) {
|
|
3257
|
+
card.classList.add("is-resuming");
|
|
3258
|
+
card.setAttribute("aria-busy", "true");
|
|
3259
|
+
controlStates.forEach(({ button }) => { button.disabled = true; });
|
|
3260
|
+
if (status) status.textContent = "处理中";
|
|
3261
|
+
if (note) note.textContent = "回答已作为工具结果提交,正在根据你的回答继续处理…";
|
|
3262
|
+
}
|
|
3263
|
+
aiQuestionContinuationTabIds.add(tab.id);
|
|
3264
|
+
setAiChatTabStatus(tab, "streaming");
|
|
3265
|
+
if (isActiveAiChatTab(tab)) syncAiRequestControls();
|
|
3266
|
+
scrollAiFeedToBottom(tab.feed);
|
|
3267
|
+
return {
|
|
3268
|
+
tab,
|
|
3269
|
+
card,
|
|
3270
|
+
note,
|
|
3271
|
+
status,
|
|
3272
|
+
previousNote,
|
|
3273
|
+
previousStatus,
|
|
3274
|
+
controlStates
|
|
3275
|
+
};
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
function finishAiQuestionContinuationUi(continuationUi, failed = false) {
|
|
3279
|
+
if (!continuationUi) return;
|
|
3280
|
+
aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
|
|
3281
|
+
if (continuationUi.card?.isConnected) {
|
|
3282
|
+
continuationUi.card.classList.remove("is-resuming");
|
|
3283
|
+
continuationUi.card.removeAttribute("aria-busy");
|
|
3284
|
+
continuationUi.controlStates.forEach(({ button, disabled }) => { button.disabled = disabled; });
|
|
3285
|
+
if (continuationUi.note) continuationUi.note.textContent = continuationUi.previousNote;
|
|
3286
|
+
if (continuationUi.status) continuationUi.status.textContent = continuationUi.previousStatus;
|
|
3287
|
+
}
|
|
3288
|
+
if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
|
|
3289
|
+
if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
async function reloadAiQuestionConversation(conversationId) {
|
|
3293
|
+
if (!conversationId) return null;
|
|
3294
|
+
const tab = aiChatTabManager.findByConversation(conversationId);
|
|
3295
|
+
if (!tab) return openAiConversation(conversationId);
|
|
3296
|
+
const conversation = await api(`/api/ai-conversations/${encodeURIComponent(conversationId)}?page=1&limit=100`);
|
|
3297
|
+
if (String(conversation.workId ?? "") !== String(state.work?.id ?? "")) throw new Error("AI 对话不属于当前作品");
|
|
3298
|
+
upsertAiConversationSummary(conversation);
|
|
3299
|
+
applyConversationToAiChatTab(tab, conversation);
|
|
3300
|
+
activateAiChatTab(tab.id, { persistCurrent: false, force: true });
|
|
3301
|
+
return conversation;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
async function respondAiUserQuestion(questionId, payload) {
|
|
3305
|
+
if (aiQuestionDialogBusy) return;
|
|
3306
|
+
aiQuestionDialogBusy = true;
|
|
3307
|
+
$("#ai-question-submit").disabled = true;
|
|
3308
|
+
$("#ai-question-skip").disabled = true;
|
|
3309
|
+
const questionDialog = $("#ai-question-dialog");
|
|
3310
|
+
const approvalCenterDialog = $("#ai-approval-center-dialog");
|
|
3311
|
+
let continuationUi = null;
|
|
3312
|
+
let continuationFailed = false;
|
|
3313
|
+
try {
|
|
3314
|
+
const knownQuestion = currentAiQuestionDialogView?.id === String(questionId)
|
|
3315
|
+
? currentAiQuestionDialogView
|
|
3316
|
+
: await fetchAiUserQuestion(questionId);
|
|
3317
|
+
const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
|
|
3318
|
+
if (questionDialog.open) questionDialog.close();
|
|
3319
|
+
if (approvalCenterDialog.open) approvalCenterDialog.close();
|
|
3320
|
+
continuationUi = beginAiQuestionContinuationUi(conversationId, questionId);
|
|
3321
|
+
let question;
|
|
3322
|
+
if (payload.action === "reject") {
|
|
3323
|
+
question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
|
|
3324
|
+
} else if (payload.action === "custom") {
|
|
3325
|
+
question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), { method: "POST", body: { customAnswer: payload.customAnswer } });
|
|
3326
|
+
} else {
|
|
3327
|
+
question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
|
|
3328
|
+
method: "POST",
|
|
3329
|
+
body: {
|
|
3330
|
+
selectedOption: payload.selectedOption,
|
|
3331
|
+
...(payload.customAnswer ? { customAnswer: payload.customAnswer } : {})
|
|
3332
|
+
}
|
|
3333
|
+
});
|
|
3334
|
+
}
|
|
3335
|
+
cacheAiQuestionView(question);
|
|
3336
|
+
currentAiQuestionDialogView = question;
|
|
3337
|
+
aiQuestionDialogQuestionId = String(question.id);
|
|
3338
|
+
await reloadAiQuestionConversation(question.conversationId ?? conversationId);
|
|
3339
|
+
toast(payload.action === "reject" ? "已跳过该问题,AI 已继续处理" : "回答已提交,AI 已继续处理");
|
|
3340
|
+
return question;
|
|
3341
|
+
} catch (error) {
|
|
3342
|
+
continuationFailed = true;
|
|
3343
|
+
const latestQuestion = await fetchAiUserQuestion(questionId).catch(() => null);
|
|
3344
|
+
if (latestQuestion) {
|
|
3345
|
+
currentAiQuestionDialogView = latestQuestion;
|
|
3346
|
+
cacheAiQuestionView(latestQuestion);
|
|
3347
|
+
if (latestQuestion.status === "pending") {
|
|
3348
|
+
$("#ai-question-text").textContent = latestQuestion.question;
|
|
3349
|
+
renderAiUserQuestionOptions(latestQuestion);
|
|
3350
|
+
if (!questionDialog.open) questionDialog.showModal();
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
throw error;
|
|
3354
|
+
} finally {
|
|
3355
|
+
finishAiQuestionContinuationUi(continuationUi, continuationFailed);
|
|
3356
|
+
aiQuestionDialogBusy = false;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
/** Convert a persisted tool call back into a process step for history rendering. */
|
|
2888
3361
|
function aiToolProcessStep(toolCall, round = 1) {
|
|
2889
3362
|
const normalizedToolCall = { ...toolCall };
|
|
2890
3363
|
delete normalizedToolCall.round;
|
|
@@ -2945,7 +3418,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
|
|
|
2945
3418
|
if (!renderableSteps.length) return;
|
|
2946
3419
|
const details = document.createElement("details");
|
|
2947
3420
|
details.className = "ai-process-details";
|
|
2948
|
-
|
|
3421
|
+
// 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
|
|
3422
|
+
details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
|
|
2949
3423
|
const summary = document.createElement("summary");
|
|
2950
3424
|
const title = document.createElement("span");
|
|
2951
3425
|
title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
|
|
@@ -3209,6 +3683,266 @@ function renderAiConversationHistory() {
|
|
|
3209
3683
|
}
|
|
3210
3684
|
}
|
|
3211
3685
|
|
|
3686
|
+
const roleplayMemoryCategoryLabels = Object.freeze({
|
|
3687
|
+
event: "事件",
|
|
3688
|
+
state: "状态",
|
|
3689
|
+
relationship: "关系",
|
|
3690
|
+
commitment: "承诺",
|
|
3691
|
+
knowledge: "知识",
|
|
3692
|
+
scene: "场景"
|
|
3693
|
+
});
|
|
3694
|
+
const roleplayMemoryImportanceLabels = Object.freeze({ low: "低重要度", medium: "中重要度", high: "高重要度" });
|
|
3695
|
+
const roleplayMemoryCertaintyLabels = Object.freeze({ experienced: "亲历", observed: "观察", heard: "听说", believed: "相信" });
|
|
3696
|
+
const roleplayMemoryStatusLabels = Object.freeze({ active: "生效中", superseded: "已取代", archived: "已删除" });
|
|
3697
|
+
let roleplayMemoryItems = [];
|
|
3698
|
+
let roleplayMemoryCharacter = null;
|
|
3699
|
+
let roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
|
|
3700
|
+
let roleplayMemoryCursorHistory = [0];
|
|
3701
|
+
let roleplayMemorySearchTimer = null;
|
|
3702
|
+
let roleplayMemoryLoaded = false;
|
|
3703
|
+
let roleplayMemoryLoading = false;
|
|
3704
|
+
|
|
3705
|
+
function roleplayMemoryReadOnly() {
|
|
3706
|
+
return Boolean(state.work) && !canEditModule("characters");
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
function roleplayMemoryQuery() {
|
|
3710
|
+
const parameters = new URLSearchParams({
|
|
3711
|
+
cursor: String(roleplayMemoryCursorHistory.at(-1) ?? 0),
|
|
3712
|
+
limit: "20"
|
|
3713
|
+
});
|
|
3714
|
+
const query = $("#roleplay-memory-search").value.trim();
|
|
3715
|
+
const category = $("#roleplay-memory-category").value;
|
|
3716
|
+
const status = $("#roleplay-memory-status").value;
|
|
3717
|
+
if (query) parameters.set("q", query);
|
|
3718
|
+
if (category) parameters.set("categories", category);
|
|
3719
|
+
if (status === "all") parameters.set("statuses", "active,superseded,archived");
|
|
3720
|
+
else parameters.set("statuses", status || "active");
|
|
3721
|
+
return parameters;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
function roleplayMemorySourceHtml(source) {
|
|
3725
|
+
const canOpen = source.canOpen === true && source.conversationId && source.messageId;
|
|
3726
|
+
const sourceLabel = source.role === "assistant" ? "角色回复" : "用户消息";
|
|
3727
|
+
const detail = source.restricted
|
|
3728
|
+
? "来自其他用户的角色扮演对话,无权查看原文"
|
|
3729
|
+
: source.evidence || "来源消息已删除,保留来源时间";
|
|
3730
|
+
return `<article class="roleplay-memory-source"><p><strong>${sourceLabel}</strong> · ${esc(formatDateTime(source.sourceAt))} · ${esc(detail)}</p>${canOpen ? `<button type="button" data-roleplay-memory-source-id="${esc(source.id)}" data-roleplay-memory-source-conversation="${esc(source.conversationId)}" data-roleplay-memory-source-message="${esc(source.messageId)}">查看来源</button>` : ""}</article>`;
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
function renderRoleplayMemoryList() {
|
|
3734
|
+
const host = $("#roleplay-memory-list");
|
|
3735
|
+
if (!host) return;
|
|
3736
|
+
$("#roleplay-memory-add").disabled = roleplayMemoryReadOnly();
|
|
3737
|
+
if (roleplayMemoryLoading) {
|
|
3738
|
+
host.innerHTML = '<p class="roleplay-memory-empty">正在读取角色扮演记忆……</p>';
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
if (!roleplayMemoryLoaded) {
|
|
3742
|
+
host.innerHTML = '<p class="roleplay-memory-empty">打开角色扮演记忆分区后读取该角色的共享记忆库。</p>';
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
if (!roleplayMemoryItems.length) {
|
|
3746
|
+
host.innerHTML = '<p class="roleplay-memory-empty">当前筛选下没有记忆。可以手工添加,AI 也会在成功回复后整理值得保留的扮演经历。</p>';
|
|
3747
|
+
} else {
|
|
3748
|
+
host.innerHTML = roleplayMemoryItems.map((memory) => {
|
|
3749
|
+
const sources = Array.isArray(memory.sources) ? memory.sources : [];
|
|
3750
|
+
const editable = !roleplayMemoryReadOnly();
|
|
3751
|
+
const pinAction = memory.isPinned ? "取消置顶" : "置顶";
|
|
3752
|
+
const actions = editable
|
|
3753
|
+
? memory.status === "archived"
|
|
3754
|
+
? `<button type="button" data-roleplay-memory-action="restore" data-memory-id="${esc(memory.id)}">恢复</button>`
|
|
3755
|
+
: `<button class="danger-button roleplay-memory-icon-action" type="button" data-roleplay-memory-action="archive" data-memory-id="${esc(memory.id)}" aria-label="删除角色扮演记忆" title="删除">${trashIconMarkup()}</button><button class="roleplay-memory-icon-action${memory.isPinned ? " is-pinned" : ""}" type="button" data-roleplay-memory-action="pin" data-memory-id="${esc(memory.id)}" aria-label="${pinAction}角色扮演记忆" aria-pressed="${memory.isPinned === true}" title="${pinAction}">${roleplayMemoryPinIconMarkup()}</button><button class="roleplay-memory-icon-action" type="button" data-roleplay-memory-action="edit" data-memory-id="${esc(memory.id)}" aria-label="编辑角色扮演记忆" title="编辑">${pencilIconMarkup()}</button>`
|
|
3756
|
+
: "";
|
|
3757
|
+
return `<article class="roleplay-memory-card${memory.status === "archived" ? " is-archived" : ""}" data-roleplay-memory-id="${esc(memory.id)}">
|
|
3758
|
+
<header class="roleplay-memory-card-header"><div class="roleplay-memory-card-badges"><span class="roleplay-memory-badge">${esc(roleplayMemoryCategoryLabels[memory.category] ?? memory.category)}</span><span class="roleplay-memory-badge">${esc(roleplayMemoryImportanceLabels[memory.importance] ?? memory.importance)}</span><span class="roleplay-memory-badge">${esc(roleplayMemoryCertaintyLabels[memory.certainty] ?? memory.certainty)}</span><span class="roleplay-memory-badge">${esc(roleplayMemoryStatusLabels[memory.status] ?? memory.status)}</span><span class="roleplay-memory-badge is-noncanonical">非正史</span>${memory.isPinned ? '<span class="roleplay-memory-badge is-pinned">已置顶</span>' : ""}</div><time datetime="${esc(memory.updatedAt)}">${esc(formatDateTime(memory.updatedAt))}</time></header>
|
|
3759
|
+
<p class="roleplay-memory-content">${esc(memory.content)}</p>
|
|
3760
|
+
<p class="roleplay-memory-card-meta">${memory.sourceType === "ai" ? "AI 整理" : "手工添加"} · 版本 ${Number(memory.versionNo ?? 1)} · 角色共享</p>
|
|
3761
|
+
${sources.length ? `<details class="roleplay-memory-sources"><summary>来源信息 · ${sources.length} 条</summary><div class="roleplay-memory-source-list">${sources.map(roleplayMemorySourceHtml).join("")}</div></details>` : ""}
|
|
3762
|
+
${actions ? `<div class="roleplay-memory-card-actions">${actions}</div>` : ""}
|
|
3763
|
+
</article>`;
|
|
3764
|
+
}).join("");
|
|
3765
|
+
}
|
|
3766
|
+
const page = roleplayMemoryCursorHistory.length;
|
|
3767
|
+
const hasPrevious = roleplayMemoryCursorHistory.length > 1;
|
|
3768
|
+
const hasNext = roleplayMemoryPagination.nextCursor !== null;
|
|
3769
|
+
$("#roleplay-memory-pagination").classList.toggle("hidden", !hasPrevious && !hasNext);
|
|
3770
|
+
$("#roleplay-memory-previous").disabled = !hasPrevious;
|
|
3771
|
+
$("#roleplay-memory-next").disabled = !hasNext;
|
|
3772
|
+
$("#roleplay-memory-page-label").textContent = `第 ${page} 页 · 共 ${Number(roleplayMemoryPagination.total ?? 0)} 条`;
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
async function loadRoleplayMemories({ resetCursor = false } = {}) {
|
|
3776
|
+
if (!roleplayMemoryCharacter?.id || roleplayMemoryLoading) return;
|
|
3777
|
+
if (resetCursor) roleplayMemoryCursorHistory = [0];
|
|
3778
|
+
const characterId = roleplayMemoryCharacter.id;
|
|
3779
|
+
roleplayMemoryLoading = true;
|
|
3780
|
+
renderRoleplayMemoryList();
|
|
3781
|
+
try {
|
|
3782
|
+
const result = await api(`/api/characters/${encodeURIComponent(characterId)}/roleplay-memories?${roleplayMemoryQuery()}`);
|
|
3783
|
+
if (roleplayMemoryCharacter?.id !== characterId) return;
|
|
3784
|
+
roleplayMemoryItems = Array.isArray(result.items) ? result.items : [];
|
|
3785
|
+
roleplayMemoryPagination = result.pagination ?? { cursor: 0, limit: 20, total: roleplayMemoryItems.length, nextCursor: null };
|
|
3786
|
+
if (result.character) roleplayMemoryCharacter = { ...roleplayMemoryCharacter, ...result.character };
|
|
3787
|
+
roleplayMemoryLoaded = true;
|
|
3788
|
+
} catch (error) {
|
|
3789
|
+
if (roleplayMemoryCharacter?.id === characterId) {
|
|
3790
|
+
roleplayMemoryItems = [];
|
|
3791
|
+
roleplayMemoryLoaded = false;
|
|
3792
|
+
}
|
|
3793
|
+
throw error;
|
|
3794
|
+
} finally {
|
|
3795
|
+
if (roleplayMemoryCharacter?.id === characterId) {
|
|
3796
|
+
roleplayMemoryLoading = false;
|
|
3797
|
+
renderRoleplayMemoryList();
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
function bindRoleplayMemorySurface(character) {
|
|
3803
|
+
const surface = $("#character-roleplay-memory-surface");
|
|
3804
|
+
if (!surface || !character?.id) return;
|
|
3805
|
+
roleplayMemoryCharacter = { id: character.id, name: character.name, workId: character.workId ?? state.work?.id };
|
|
3806
|
+
roleplayMemoryItems = [];
|
|
3807
|
+
roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
|
|
3808
|
+
roleplayMemoryCursorHistory = [0];
|
|
3809
|
+
roleplayMemoryLoaded = false;
|
|
3810
|
+
roleplayMemoryLoading = false;
|
|
3811
|
+
if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
|
|
3812
|
+
roleplayMemorySearchTimer = null;
|
|
3813
|
+
renderRoleplayMemoryList();
|
|
3814
|
+
$("#roleplay-memory-filter-toggle").addEventListener("click", (event) => {
|
|
3815
|
+
const panel = $("#roleplay-memory-filter-panel");
|
|
3816
|
+
const expanded = panel.classList.contains("hidden");
|
|
3817
|
+
panel.classList.toggle("hidden", !expanded);
|
|
3818
|
+
event.currentTarget.setAttribute("aria-expanded", String(expanded));
|
|
3819
|
+
if (expanded) $("#roleplay-memory-search").focus();
|
|
3820
|
+
});
|
|
3821
|
+
$("#roleplay-memory-add").addEventListener("click", () => openRoleplayMemoryEditor());
|
|
3822
|
+
$("#roleplay-memory-list").addEventListener("click", async (event) => {
|
|
3823
|
+
const sourceButton = event.target.closest("[data-roleplay-memory-source-conversation]");
|
|
3824
|
+
if (sourceButton) {
|
|
3825
|
+
try {
|
|
3826
|
+
if (!$("#character-editor-form").classList.contains("hidden")) await closeEntityEditor({ force: true });
|
|
3827
|
+
await openAiConversation(
|
|
3828
|
+
sourceButton.dataset.roleplayMemorySourceConversation,
|
|
3829
|
+
true,
|
|
3830
|
+
sourceButton.dataset.roleplayMemorySourceMessage,
|
|
3831
|
+
sourceButton.dataset.roleplayMemorySourceId
|
|
3832
|
+
);
|
|
3833
|
+
} catch (error) {
|
|
3834
|
+
toast(`来源消息打开失败:${error.message}`, "error");
|
|
3835
|
+
}
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3838
|
+
const actionButton = event.target.closest("[data-roleplay-memory-action]");
|
|
3839
|
+
if (!actionButton) return;
|
|
3840
|
+
const memory = roleplayMemoryItems.find((item) => item.id === actionButton.dataset.memoryId);
|
|
3841
|
+
actionButton.disabled = true;
|
|
3842
|
+
try {
|
|
3843
|
+
await updateRoleplayMemoryAction(memory, actionButton.dataset.roleplayMemoryAction);
|
|
3844
|
+
} catch (error) {
|
|
3845
|
+
toast(`记忆操作失败:${error.message}`, "error");
|
|
3846
|
+
} finally {
|
|
3847
|
+
if (actionButton.isConnected) actionButton.disabled = false;
|
|
3848
|
+
}
|
|
3849
|
+
});
|
|
3850
|
+
$("#roleplay-memory-search").addEventListener("input", () => {
|
|
3851
|
+
if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
|
|
3852
|
+
roleplayMemorySearchTimer = window.setTimeout(() => {
|
|
3853
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆搜索失败:${error.message}`, "error"));
|
|
3854
|
+
}, 250);
|
|
3855
|
+
});
|
|
3856
|
+
for (const select of [$("#roleplay-memory-category"), $("#roleplay-memory-status")]) {
|
|
3857
|
+
select.addEventListener("change", () => {
|
|
3858
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选失败:${error.message}`, "error"));
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
$("#roleplay-memory-filter-reset").addEventListener("click", () => {
|
|
3862
|
+
$("#roleplay-memory-search").value = "";
|
|
3863
|
+
$("#roleplay-memory-category").value = "";
|
|
3864
|
+
$("#roleplay-memory-status").value = "active";
|
|
3865
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选重置失败:${error.message}`, "error"));
|
|
3866
|
+
});
|
|
3867
|
+
$("#roleplay-memory-previous").addEventListener("click", () => {
|
|
3868
|
+
if (roleplayMemoryCursorHistory.length <= 1) return;
|
|
3869
|
+
roleplayMemoryCursorHistory.pop();
|
|
3870
|
+
void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
|
|
3871
|
+
});
|
|
3872
|
+
$("#roleplay-memory-next").addEventListener("click", () => {
|
|
3873
|
+
if (roleplayMemoryPagination.nextCursor === null) return;
|
|
3874
|
+
roleplayMemoryCursorHistory.push(roleplayMemoryPagination.nextCursor);
|
|
3875
|
+
void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
|
|
3876
|
+
});
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
function roleplayMemoryEditorFields(memory = null) {
|
|
3880
|
+
return field("category", "记忆类别", "select", memory?.category ?? "event", Object.entries(roleplayMemoryCategoryLabels))
|
|
3881
|
+
+ field("importance", "重要度", "select", memory?.importance ?? "medium", Object.entries(roleplayMemoryImportanceLabels))
|
|
3882
|
+
+ field("certainty", "可信状态", "select", memory?.certainty ?? "experienced", Object.entries(roleplayMemoryCertaintyLabels))
|
|
3883
|
+
+ field("isPinned", "置顶这条记忆", "checkbox", memory?.isPinned === true)
|
|
3884
|
+
+ field("content", "记忆内容", "textarea", memory?.content ?? "");
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
function openRoleplayMemoryEditor(memory = null) {
|
|
3888
|
+
openDialog(memory ? "编辑角色扮演记忆" : "手工添加角色扮演记忆", roleplayMemoryEditorFields(memory), async (form) => {
|
|
3889
|
+
const body = {
|
|
3890
|
+
category: String(form.get("category") ?? "event"),
|
|
3891
|
+
importance: String(form.get("importance") ?? "medium"),
|
|
3892
|
+
certainty: String(form.get("certainty") ?? "experienced"),
|
|
3893
|
+
isPinned: form.get("isPinned") === "on",
|
|
3894
|
+
content: String(form.get("content") ?? "").trim(),
|
|
3895
|
+
...(memory ? { expectedVersion: Number(memory.versionNo) } : {})
|
|
3896
|
+
};
|
|
3897
|
+
if (!body.content) throw new Error("请输入记忆内容");
|
|
3898
|
+
await api(memory
|
|
3899
|
+
? `/api/roleplay-memories/${encodeURIComponent(memory.id)}`
|
|
3900
|
+
: `/api/characters/${encodeURIComponent(roleplayMemoryCharacter.id)}/roleplay-memories`, {
|
|
3901
|
+
method: memory ? "PATCH" : "POST",
|
|
3902
|
+
body
|
|
3903
|
+
});
|
|
3904
|
+
await loadRoleplayMemories();
|
|
3905
|
+
toast(memory ? "角色扮演记忆已更新" : "角色扮演记忆已添加");
|
|
3906
|
+
}, memory ? "版本化编辑" : "非正史 · 手工添加", {
|
|
3907
|
+
submitLabel: memory ? "保存记忆" : "添加记忆",
|
|
3908
|
+
errorPrefix: "记忆保存失败:",
|
|
3909
|
+
meta: "记录该角色在作品内共享的非正史互动,不会写入正文、角色卡字段或设定库。"
|
|
3910
|
+
});
|
|
3911
|
+
const textarea = $("#dialog-fields textarea[name='content']");
|
|
3912
|
+
if (textarea) {
|
|
3913
|
+
textarea.maxLength = 2_000;
|
|
3914
|
+
textarea.rows = 7;
|
|
3915
|
+
textarea.focus();
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
async function updateRoleplayMemoryAction(memory, action) {
|
|
3920
|
+
if (!memory) return;
|
|
3921
|
+
if (action === "edit") return openRoleplayMemoryEditor(memory);
|
|
3922
|
+
if (action === "archive" && !await confirmToast("删除后 AI 不再召回这条记忆,可稍后从“已删除”筛选中恢复。", {
|
|
3923
|
+
title: "删除角色扮演记忆",
|
|
3924
|
+
confirmLabel: "确认删除"
|
|
3925
|
+
})) return;
|
|
3926
|
+
if (action === "pin") {
|
|
3927
|
+
await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
|
|
3928
|
+
method: "PATCH",
|
|
3929
|
+
body: { expectedVersion: Number(memory.versionNo), isPinned: memory.isPinned !== true }
|
|
3930
|
+
});
|
|
3931
|
+
} else if (action === "archive") {
|
|
3932
|
+
await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
|
|
3933
|
+
method: "DELETE",
|
|
3934
|
+
body: { expectedVersion: Number(memory.versionNo) }
|
|
3935
|
+
});
|
|
3936
|
+
} else if (action === "restore") {
|
|
3937
|
+
await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}/restore`, {
|
|
3938
|
+
method: "POST",
|
|
3939
|
+
body: { expectedVersion: Number(memory.versionNo) }
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
await loadRoleplayMemories();
|
|
3943
|
+
toast(action === "pin" ? (memory.isPinned ? "已取消置顶" : "记忆已置顶") : action === "archive" ? "记忆已删除" : "记忆已恢复");
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3212
3946
|
function defaultAiConversationTitle(prompt) {
|
|
3213
3947
|
const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
|
|
3214
3948
|
return Array.from(normalized).slice(0, 15).join("") || "新对话";
|
|
@@ -3297,7 +4031,7 @@ async function ensureAiConversationsLoaded() {
|
|
|
3297
4031
|
}
|
|
3298
4032
|
}
|
|
3299
4033
|
|
|
3300
|
-
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
|
|
4034
|
+
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null, roleplayMemorySourceId = null) {
|
|
3301
4035
|
if (!state.work) return null;
|
|
3302
4036
|
const existingTab = aiChatTabManager.findByConversation(conversationId);
|
|
3303
4037
|
const existingMessage = focusMessageId
|
|
@@ -3318,6 +4052,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
|
|
|
3318
4052
|
try {
|
|
3319
4053
|
const parameters = new URLSearchParams({ page: "1", limit: "100" });
|
|
3320
4054
|
if (focusMessageId) parameters.set("messageId", String(focusMessageId));
|
|
4055
|
+
if (roleplayMemorySourceId) parameters.set("roleplayMemorySourceId", String(roleplayMemorySourceId));
|
|
3321
4056
|
const [conversation] = await Promise.all([
|
|
3322
4057
|
api(`/api/ai-conversations/${conversationId}?${parameters}`),
|
|
3323
4058
|
ensureAiReferencesLoaded()
|
|
@@ -5617,12 +6352,44 @@ function setSaveState(text, dirty = false) {
|
|
|
5617
6352
|
});
|
|
5618
6353
|
}
|
|
5619
6354
|
|
|
6355
|
+
function resetChapterDraftLineIds(chapter = state.chapter) {
|
|
6356
|
+
chapterBeforeInputState = null;
|
|
6357
|
+
chapterDraftLineIdState = chapter ? {
|
|
6358
|
+
chapterId: chapter.id,
|
|
6359
|
+
content: String(chapter.content ?? ""),
|
|
6360
|
+
lineIds: normalizeChapterLineIdDraft(chapter.content, chapter.lineIds)
|
|
6361
|
+
} : null;
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
function syncChapterDraftLineIds(content, hint = null) {
|
|
6365
|
+
if (!state.chapter) return [];
|
|
6366
|
+
if (!chapterDraftLineIdState || chapterDraftLineIdState.chapterId !== state.chapter.id) {
|
|
6367
|
+
resetChapterDraftLineIds(state.chapter);
|
|
6368
|
+
}
|
|
6369
|
+
if (chapterDraftLineIdState.content !== content) {
|
|
6370
|
+
chapterDraftLineIdState = {
|
|
6371
|
+
chapterId: state.chapter.id,
|
|
6372
|
+
content,
|
|
6373
|
+
lineIds: reconcileChapterLineIdDraft(
|
|
6374
|
+
chapterDraftLineIdState.content,
|
|
6375
|
+
content,
|
|
6376
|
+
chapterDraftLineIdState.lineIds,
|
|
6377
|
+
hint
|
|
6378
|
+
)
|
|
6379
|
+
};
|
|
6380
|
+
}
|
|
6381
|
+
return chapterDraftLineIdState.lineIds;
|
|
6382
|
+
}
|
|
6383
|
+
|
|
5620
6384
|
function chapterDraftSnapshot() {
|
|
5621
6385
|
if (!state.chapter) return null;
|
|
6386
|
+
const content = $("#chapter-content").value;
|
|
6387
|
+
const lineIds = syncChapterDraftLineIds(content);
|
|
5622
6388
|
return {
|
|
5623
6389
|
chapterId: state.chapter.id,
|
|
5624
6390
|
title: $("#chapter-title").value.trim(),
|
|
5625
|
-
content
|
|
6391
|
+
content,
|
|
6392
|
+
...(lineIds.length <= MAX_CHAPTER_LINE_IDS ? { lineIds } : {})
|
|
5626
6393
|
};
|
|
5627
6394
|
}
|
|
5628
6395
|
|
|
@@ -5697,7 +6464,7 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5697
6464
|
const request = (async () => {
|
|
5698
6465
|
const chapter = await api(`/api/chapters/${draft.chapterId}`, {
|
|
5699
6466
|
method: "PATCH",
|
|
5700
|
-
body: { title: draft.title, content: draft.content, source: automatic ? "auto" : "manual" }
|
|
6467
|
+
body: { title: draft.title, content: draft.content, lineIds: draft.lineIds, source: automatic ? "auto" : "manual" }
|
|
5701
6468
|
});
|
|
5702
6469
|
const work = await api(`/api/works/${workId}`);
|
|
5703
6470
|
return { chapter, work };
|
|
@@ -5708,9 +6475,15 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5708
6475
|
if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
|
|
5709
6476
|
state.chapter = saved.chapter;
|
|
5710
6477
|
state.work = saved.work;
|
|
6478
|
+
resetChapterDraftLineIds(state.chapter);
|
|
5711
6479
|
lastSavedChapterSnapshot = draft;
|
|
5712
6480
|
renderTree();
|
|
5713
6481
|
updateChapterStats();
|
|
6482
|
+
try {
|
|
6483
|
+
await loadChapterAnnotationCounts(saved.chapter.id);
|
|
6484
|
+
} catch (error) {
|
|
6485
|
+
toast("正文评论位置已更新,但评论数量刷新失败,请稍后重试", "error");
|
|
6486
|
+
}
|
|
5714
6487
|
const currentDraft = chapterDraftSnapshot();
|
|
5715
6488
|
if (sameChapterSnapshot(currentDraft, draft)) {
|
|
5716
6489
|
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
@@ -5785,13 +6558,35 @@ function restoredSettingsReturnContext(route) {
|
|
|
5785
6558
|
}
|
|
5786
6559
|
|
|
5787
6560
|
async function initializePage() {
|
|
6561
|
+
const route = parsePageRoute(window.location.hash);
|
|
6562
|
+
const earlyReaderChapterRequest = window.__scriverseReaderChapterPrefetch;
|
|
6563
|
+
const earlyReaderWorksRequest = window.__scriverseReaderWorksPrefetch;
|
|
6564
|
+
const earlyReaderWorkRequest = window.__scriverseReaderWorkPrefetch;
|
|
5788
6565
|
const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
|
|
5789
6566
|
if (!authenticated) {
|
|
5790
6567
|
restoringPageRoute = false;
|
|
5791
6568
|
return;
|
|
5792
6569
|
}
|
|
5793
|
-
const
|
|
5794
|
-
|
|
6570
|
+
const requestedWorkDetailRequest = route.workId
|
|
6571
|
+
? earlyReaderWorkRequest?.workId === route.workId
|
|
6572
|
+
? earlyReaderWorkRequest.request.then((result) => result.work ?? api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null))
|
|
6573
|
+
: api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null)
|
|
6574
|
+
: Promise.resolve(null);
|
|
6575
|
+
initialReaderChapterRequest = route.view === "reader" && route.chapterId
|
|
6576
|
+
? earlyReaderChapterRequest?.chapterId === route.chapterId
|
|
6577
|
+
? earlyReaderChapterRequest
|
|
6578
|
+
: {
|
|
6579
|
+
chapterId: route.chapterId,
|
|
6580
|
+
request: api(`/api/chapters/${encodeURIComponent(route.chapterId)}`)
|
|
6581
|
+
.then((chapter) => ({ chapter }), (error) => ({ error }))
|
|
6582
|
+
}
|
|
6583
|
+
: null;
|
|
6584
|
+
const [worksPage, requestedWorkDetail] = await Promise.all([
|
|
6585
|
+
earlyReaderWorksRequest?.request.then((result) => result.works ?? apiPage("/api/works")) ?? apiPage("/api/works"),
|
|
6586
|
+
requestedWorkDetailRequest
|
|
6587
|
+
]);
|
|
6588
|
+
state.works = worksPage.items;
|
|
6589
|
+
initialWorkDetail = requestedWorkDetail;
|
|
5795
6590
|
try {
|
|
5796
6591
|
if (route.view === "shelf") {
|
|
5797
6592
|
showShelf();
|
|
@@ -5832,7 +6627,7 @@ async function initializePage() {
|
|
|
5832
6627
|
return;
|
|
5833
6628
|
}
|
|
5834
6629
|
const options = { readOnly: route.entityMode === "read" };
|
|
5835
|
-
if (route.entity === "setting") openSettingEditor(item, options);
|
|
6630
|
+
if (route.entity === "setting") await openSettingEditor(item, options);
|
|
5836
6631
|
else if (route.entity === "character") await openCharacterEditor(item, options);
|
|
5837
6632
|
else if (route.entity === "race") await openRaceDialog(item, options);
|
|
5838
6633
|
else if (route.entity === "organization") await openOrganizationDialog(item, options);
|
|
@@ -5863,7 +6658,14 @@ async function initializePage() {
|
|
|
5863
6658
|
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
5864
6659
|
}
|
|
5865
6660
|
} finally {
|
|
6661
|
+
delete window.__scriverseReaderChapterPrefetch;
|
|
6662
|
+
delete window.__scriverseReaderWorksPrefetch;
|
|
6663
|
+
delete window.__scriverseReaderWorkPrefetch;
|
|
6664
|
+
initialWorkDetail = null;
|
|
6665
|
+
initialReaderChapterRequest = null;
|
|
5866
6666
|
document.body.classList.remove("auth-pending");
|
|
6667
|
+
document.documentElement.removeAttribute("data-pending-view");
|
|
6668
|
+
document.documentElement.classList.remove("pending-shelf-mode");
|
|
5867
6669
|
restoringPageRoute = false;
|
|
5868
6670
|
replacePageRoute(currentPageRoute());
|
|
5869
6671
|
scheduleFirstUseOnboarding();
|
|
@@ -6971,6 +7773,7 @@ async function refreshWorkAfterGlobalReplace(route, result) {
|
|
|
6971
7773
|
const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
|
|
6972
7774
|
if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
|
|
6973
7775
|
state.chapter = chapter;
|
|
7776
|
+
resetChapterDraftLineIds(state.chapter);
|
|
6974
7777
|
mergeChapterDirectoryEntry(chapter);
|
|
6975
7778
|
lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
|
|
6976
7779
|
await loadVolumeChapters(chapter.volumeId);
|
|
@@ -7392,7 +8195,9 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
7392
8195
|
volumeChapterLoadingIds.clear();
|
|
7393
8196
|
volumeChapterRequests.clear();
|
|
7394
8197
|
}
|
|
7395
|
-
const
|
|
8198
|
+
const prefetchedWork = initialWorkDetail?.id === workId ? initialWorkDetail : null;
|
|
8199
|
+
initialWorkDetail = null;
|
|
8200
|
+
const nextWork = prefetchedWork ?? await api(`/api/works/${workId}?directory=volumes`);
|
|
7396
8201
|
if (selectionGeneration !== workSelectionRequestGeneration) return false;
|
|
7397
8202
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
7398
8203
|
showSystemStatus();
|
|
@@ -7462,9 +8267,12 @@ async function loadVolumeChapters(volumeId) {
|
|
|
7462
8267
|
async function loadAllVolumeChapters(workId) {
|
|
7463
8268
|
const generation = workScopedUiGeneration;
|
|
7464
8269
|
const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
|
|
7465
|
-
for (
|
|
7466
|
-
|
|
7467
|
-
await
|
|
8270
|
+
for (let index = 0; index < volumeIds.length; index += readingVolumeDirectoryConcurrency) {
|
|
8271
|
+
const batch = volumeIds.slice(index, index + readingVolumeDirectoryConcurrency);
|
|
8272
|
+
await Promise.all(batch.map(async (volumeId) => {
|
|
8273
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
8274
|
+
await loadVolumeChapters(volumeId);
|
|
8275
|
+
}));
|
|
7468
8276
|
}
|
|
7469
8277
|
}
|
|
7470
8278
|
|
|
@@ -7837,9 +8645,14 @@ function currentChapterForeshadowReminder() {
|
|
|
7837
8645
|
|
|
7838
8646
|
function syncMobileAiPanelSafeTop() {
|
|
7839
8647
|
const container = $("#chapter-foreshadow-reminder");
|
|
7840
|
-
const
|
|
8648
|
+
const toolbar = $("#editor-view .editor-toolbar");
|
|
8649
|
+
const reminderBottom = container.classList.contains("hidden")
|
|
7841
8650
|
? 0
|
|
7842
|
-
:
|
|
8651
|
+
: container.getBoundingClientRect().bottom;
|
|
8652
|
+
const toolbarBottom = $("#editor-view").classList.contains("hidden")
|
|
8653
|
+
? 0
|
|
8654
|
+
: toolbar.getBoundingClientRect().bottom;
|
|
8655
|
+
const safeTop = Math.ceil(Math.max(reminderBottom, toolbarBottom));
|
|
7843
8656
|
$("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
|
|
7844
8657
|
}
|
|
7845
8658
|
|
|
@@ -8061,6 +8874,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
8061
8874
|
$("#chapter-path").title = chapterPath;
|
|
8062
8875
|
$("#chapter-title").value = state.chapter.title;
|
|
8063
8876
|
$("#chapter-content").value = state.chapter.content;
|
|
8877
|
+
resetChapterDraftLineIds(state.chapter);
|
|
8064
8878
|
chapterAnnotationCounts = new Map();
|
|
8065
8879
|
clearChapterLineSelection();
|
|
8066
8880
|
scheduleChapterLineNumbers();
|
|
@@ -8183,6 +8997,7 @@ function renderReadingNavigation() {
|
|
|
8183
8997
|
$("#reader-next").disabled = !next || readingLoading;
|
|
8184
8998
|
$("#reader-continue").disabled = !next || readingLoading;
|
|
8185
8999
|
$("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
|
|
9000
|
+
$("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
|
|
8186
9001
|
const paged = readingPreferences.mode === "paged";
|
|
8187
9002
|
const previousPage = current && paged ? resolvePagedReadingStep({
|
|
8188
9003
|
sequence: readingSequence,
|
|
@@ -8220,7 +9035,7 @@ function applyReadingPreferences() {
|
|
|
8220
9035
|
$("#reader-font-size").value = String(readingPreferences.fontSize);
|
|
8221
9036
|
$("#reader-line-height").value = String(readingPreferences.lineHeight);
|
|
8222
9037
|
$("#reader-theme").value = readingPreferences.theme;
|
|
8223
|
-
$("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
|
|
9038
|
+
$("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
|
|
8224
9039
|
$("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
|
|
8225
9040
|
$("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
|
|
8226
9041
|
}
|
|
@@ -8355,7 +9170,13 @@ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = n
|
|
|
8355
9170
|
renderReadingStatus("正在载入章节……");
|
|
8356
9171
|
replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
|
|
8357
9172
|
try {
|
|
8358
|
-
const
|
|
9173
|
+
const initialRequest = initialReaderChapterRequest?.chapterId === target.id
|
|
9174
|
+
? initialReaderChapterRequest.request
|
|
9175
|
+
: null;
|
|
9176
|
+
if (initialRequest) initialReaderChapterRequest = null;
|
|
9177
|
+
const prefetchedResult = initialRequest ? await initialRequest : null;
|
|
9178
|
+
if (prefetchedResult?.error) throw prefetchedResult.error;
|
|
9179
|
+
const chapter = prefetchedResult?.chapter ?? await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
|
|
8359
9180
|
if (!readingRequestGate.isCurrent(request)) return false;
|
|
8360
9181
|
if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
|
|
8361
9182
|
throw new Error("章节响应与当前作品不匹配");
|
|
@@ -8509,7 +9330,7 @@ function closeReadingPreview() {
|
|
|
8509
9330
|
replacePageRoute(returnRoute);
|
|
8510
9331
|
const focus = readingPreviousFocus;
|
|
8511
9332
|
readingPreviousFocus = null;
|
|
8512
|
-
const focusCandidates = [focus, $("#
|
|
9333
|
+
const focusCandidates = [focus, $("#reader-open-button"), $("#home-button")];
|
|
8513
9334
|
for (const candidate of focusCandidates) {
|
|
8514
9335
|
if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
|
|
8515
9336
|
const rect = candidate.getBoundingClientRect();
|
|
@@ -8748,6 +9569,14 @@ function pencilIconMarkup() {
|
|
|
8748
9569
|
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"></path><path d="m16.5 3.5 1.4-1.4a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4L16.5 3.5Z"></path></svg>';
|
|
8749
9570
|
}
|
|
8750
9571
|
|
|
9572
|
+
function trashIconMarkup() {
|
|
9573
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16"></path><path d="M9 7V4h6v3M6.5 7l.8 13h9.4l.8-13M10 11v5M14 11v5"></path></svg>';
|
|
9574
|
+
}
|
|
9575
|
+
|
|
9576
|
+
function roleplayMemoryPinIconMarkup() {
|
|
9577
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 4h6"></path><path d="M10 4v5l-3 4h10l-3-4V4"></path><path d="M12 13v7"></path></svg>';
|
|
9578
|
+
}
|
|
9579
|
+
|
|
8751
9580
|
function characterFavoriteIconMarkup() {
|
|
8752
9581
|
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"></path></svg>';
|
|
8753
9582
|
}
|
|
@@ -12432,15 +13261,19 @@ async function renderBookAiSettings() {
|
|
|
12432
13261
|
clearTimeout(relationshipSearchIndexRefreshTimer);
|
|
12433
13262
|
relationshipSearchIndexRefreshTimer = null;
|
|
12434
13263
|
}
|
|
12435
|
-
const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
|
|
13264
|
+
const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions, writeTools] = await Promise.all([
|
|
12436
13265
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
12437
13266
|
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
12438
13267
|
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
12439
13268
|
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
12440
13269
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
|
|
12441
13270
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
|
|
12442
|
-
moduleApi("ai-settings", "/api/platform/ai/protocols")
|
|
13271
|
+
moduleApi("ai-settings", "/api/platform/ai/protocols"),
|
|
13272
|
+
// 可写工具开关独立于 ai-settings 存储;加载失败时仍可展示其余配置。
|
|
13273
|
+
api(`/api/works/${state.work.id}/ai/tools`).catch(() => null)
|
|
12443
13274
|
]);
|
|
13275
|
+
const writeToolsState = writeTools?.tools ?? null;
|
|
13276
|
+
const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
|
|
12444
13277
|
const host = $("#module-content");
|
|
12445
13278
|
platformAiProtocolOptions = protocolOptions;
|
|
12446
13279
|
const workId = String(state.work.id);
|
|
@@ -12490,6 +13323,7 @@ async function renderBookAiSettings() {
|
|
|
12490
13323
|
host.querySelectorAll(".config-section").forEach((section) => {
|
|
12491
13324
|
if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
|
|
12492
13325
|
});
|
|
13326
|
+
host.insertAdjacentHTML("beforeend", `<section class="config-section"><div class="config-section-header"><div><h2>AI 可写工具</h2><p>默认全部关闭:逐项开启后,侧边栏 AI 才能在对应模块提交修改计划。计划只包含操作描述与 AI 简述;确认前系统会按当前数据库生成字段级明细(含修改前后值),执行时整体原子完成并再次校验权限、开关与目标版本,全程可在「AI 操作审批中心」追溯。AI 不能删除任何条目,也不能改写正文。</p></div><div class="card-actions"><button id="open-ai-approval-center-from-settings" class="ghost-button" type="button">打开 AI 操作审批中心</button></div></div><div class="ai-agent-tools ai-write-tools">${AI_WRITE_TOOLS_META.map((tool) => `<label><input name="ai-write-tool" type="checkbox" value="${esc(tool.id)}" ${writeToolsState?.[tool.id] === true ? "checked" : ""}><span><strong>${esc(tool.label)}</strong><small>${esc(tool.description)}</small></span></label>`).join("")}</div><p class="usage-measurement-note">${writeTools ? `当前单次审批最多 ${writeToolsMaxOperations} 个操作,可通过环境变量 AI_WRITE_PLAN_MAX_OPERATIONS 调整。` : "工具开关状态暂时无法加载,显示的勾选可能不是最新值。"}</p><div class="card-actions"><button id="save-ai-write-tools" class="ghost-button config-save-button" type="button">保存开关设置</button></div></section>`);
|
|
12493
13327
|
bindUsageCalendarInteractions(host);
|
|
12494
13328
|
scrollUsageCalendarsToLatest(host);
|
|
12495
13329
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
@@ -12748,6 +13582,23 @@ async function renderBookAiSettings() {
|
|
|
12748
13582
|
button.disabled = false;
|
|
12749
13583
|
}
|
|
12750
13584
|
});
|
|
13585
|
+
$("#open-ai-approval-center-from-settings").addEventListener("click", () => openAiApprovalCenter());
|
|
13586
|
+
$("#save-ai-write-tools").addEventListener("click", async () => {
|
|
13587
|
+
const button = $("#save-ai-write-tools");
|
|
13588
|
+
button.disabled = true;
|
|
13589
|
+
try {
|
|
13590
|
+
const tools = {};
|
|
13591
|
+
host.querySelectorAll('input[name="ai-write-tool"]').forEach((input) => {
|
|
13592
|
+
tools[input.value] = input.checked;
|
|
13593
|
+
});
|
|
13594
|
+
await api(`/api/works/${state.work.id}/ai/tools`, { method: "PUT", body: { tools } });
|
|
13595
|
+
toast("AI 可写工具开关已保存");
|
|
13596
|
+
} catch (error) {
|
|
13597
|
+
toast(error.message, "error");
|
|
13598
|
+
} finally {
|
|
13599
|
+
button.disabled = false;
|
|
13600
|
+
}
|
|
13601
|
+
});
|
|
12751
13602
|
host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
|
|
12752
13603
|
const select = event.currentTarget;
|
|
12753
13604
|
select.disabled = true;
|
|
@@ -12886,7 +13737,7 @@ function renderAiContextDistribution(usage) {
|
|
|
12886
13737
|
const description = document.createElement("small");
|
|
12887
13738
|
description.textContent = item.key === "skills"
|
|
12888
13739
|
? "待加入"
|
|
12889
|
-
: item.key === "input" ? "用户和 agent 的交互" : "
|
|
13740
|
+
: item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
|
|
12890
13741
|
title.append(" ", description);
|
|
12891
13742
|
}
|
|
12892
13743
|
const value = document.createElement("strong");
|
|
@@ -13205,7 +14056,7 @@ function commitRelationshipKeywordInputs(container) {
|
|
|
13205
14056
|
container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
|
|
13206
14057
|
}
|
|
13207
14058
|
|
|
13208
|
-
function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
14059
|
+
async function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
13209
14060
|
formDialogVditors.forEach(destroyVditorEditor);
|
|
13210
14061
|
formDialogVditors = [];
|
|
13211
14062
|
void discardPendingMarkdownAttachments();
|
|
@@ -13255,6 +14106,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
13255
14106
|
dialog.classList.toggle("editor-dialog", Boolean(options.editor));
|
|
13256
14107
|
bindDynamicListControls($("#dialog-fields"));
|
|
13257
14108
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
14109
|
+
if ($("#dialog-fields").querySelector("[data-vditor-editor]") && !(await loadVditorResources())) return;
|
|
13258
14110
|
formDialogVditors = bindVditorEditors($("#dialog-fields"));
|
|
13259
14111
|
form.onclick = null;
|
|
13260
14112
|
form.onkeydown = null;
|
|
@@ -13498,18 +14350,36 @@ function openWorkSettingsDialog(work) {
|
|
|
13498
14350
|
<div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
|
|
13499
14351
|
<button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
|
|
13500
14352
|
</section>` : "";
|
|
14353
|
+
const editorPreferencesField = `<section class="work-access-field work-editor-preferences-field" aria-labelledby="work-editor-preferences-title">
|
|
14354
|
+
<div><strong id="work-editor-preferences-title">正文编辑辅助</strong><small>仅对当前作品生效。新建作品默认关闭,不影响其他作品或系统设置。</small></div>
|
|
14355
|
+
<div class="work-editor-preference-options" role="group" aria-labelledby="work-editor-preferences-title">
|
|
14356
|
+
<label class="work-editor-preference-option"><input name="editorAutoIndentEnabled" type="checkbox" ${work.editorAutoIndentEnabled ? "checked" : ""}><span><b>自动空两字</b><small>按 Enter 新建段落时自动插入两个全角空格。</small></span></label>
|
|
14357
|
+
<label class="work-editor-preference-option"><input name="editorTypewriterModeEnabled" type="checkbox" ${work.editorTypewriterModeEnabled ? "checked" : ""}><span><b>打字机模式</b><small>输入位置超过页面六成后,将当前行保持在页面中部。</small></span></label>
|
|
14358
|
+
</div>
|
|
14359
|
+
</section>`;
|
|
13501
14360
|
openDialog("作品信息",
|
|
13502
|
-
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
|
|
14361
|
+
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + editorPreferencesField + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
|
|
13503
14362
|
async (form) => {
|
|
13504
|
-
await api(`/api/works/${work.id}`, { method: "PATCH", body: {
|
|
14363
|
+
await api(`/api/works/${work.id}`, { method: "PATCH", body: {
|
|
14364
|
+
title: form.get("title"),
|
|
14365
|
+
author: form.get("author"),
|
|
14366
|
+
description: form.get("description"),
|
|
14367
|
+
editorAutoIndentEnabled: form.has("editorAutoIndentEnabled"),
|
|
14368
|
+
editorTypewriterModeEnabled: form.has("editorTypewriterModeEnabled")
|
|
14369
|
+
} });
|
|
13505
14370
|
state.works = (await apiPage("/api/works")).items;
|
|
13506
14371
|
const updated = state.works.find((item) => item.id === work.id);
|
|
13507
14372
|
if (updated) Object.assign(work, updated);
|
|
13508
14373
|
if (state.work?.id === work.id) {
|
|
13509
|
-
|
|
13510
|
-
|
|
13511
|
-
|
|
13512
|
-
|
|
14374
|
+
if (updated) Object.assign(state.work, updated);
|
|
14375
|
+
else {
|
|
14376
|
+
state.work.title = String(form.get("title") ?? state.work.title);
|
|
14377
|
+
state.work.author = String(form.get("author") ?? state.work.author);
|
|
14378
|
+
state.work.description = String(form.get("description") ?? state.work.description);
|
|
14379
|
+
state.work.editorAutoIndentEnabled = form.has("editorAutoIndentEnabled");
|
|
14380
|
+
state.work.editorTypewriterModeEnabled = form.has("editorTypewriterModeEnabled");
|
|
14381
|
+
}
|
|
14382
|
+
applyChapterEditorPreferences();
|
|
13513
14383
|
updateDocumentTitle(state.work);
|
|
13514
14384
|
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
13515
14385
|
}
|
|
@@ -13673,7 +14543,8 @@ function syncSettingEditorDirty(markdown = null) {
|
|
|
13673
14543
|
entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
|
|
13674
14544
|
}
|
|
13675
14545
|
|
|
13676
|
-
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
14546
|
+
async function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
14547
|
+
if (!(await loadVditorResources())) return;
|
|
13677
14548
|
entityEditorReadOnly = readOnly;
|
|
13678
14549
|
destroyVditorEditor(settingEditorVditor);
|
|
13679
14550
|
settingEditorVditor = null;
|
|
@@ -13688,7 +14559,7 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
13688
14559
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
13689
14560
|
const editButton = $("#setting-editor-edit");
|
|
13690
14561
|
editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
|
|
13691
|
-
editButton.onclick = () => openSettingEditor(settingEditorItem);
|
|
14562
|
+
editButton.onclick = () => { void openSettingEditor(settingEditorItem); };
|
|
13692
14563
|
bindEntityDetailPinButton($("#setting-editor-pin"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
|
|
13693
14564
|
settingEditorItem = updated;
|
|
13694
14565
|
state.settings = upsertEntityCollection(state.settings, updated);
|
|
@@ -13801,9 +14672,13 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
13801
14672
|
(readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
|
|
13802
14673
|
}
|
|
13803
14674
|
|
|
13804
|
-
function characterEditorSection(key, title, description, content) {
|
|
13805
|
-
|
|
13806
|
-
|
|
14675
|
+
function characterEditorSection(key, title, description, content, headerActions = "") {
|
|
14676
|
+
const hasHeaderActions = Boolean(headerActions);
|
|
14677
|
+
const header = hasHeaderActions
|
|
14678
|
+
? `<header><div class="character-editor-section-header-copy"><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3><p>${esc(description)}</p></div>${headerActions}</header>`
|
|
14679
|
+
: `<header><div><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3></div><p>${esc(description)}</p></header>`;
|
|
14680
|
+
return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}${hasHeaderActions ? " has-header-actions" : ""}" data-character-editor-panel="${esc(key)}" role="tabpanel">
|
|
14681
|
+
${header}
|
|
13807
14682
|
<div class="character-editor-section-fields">${content}</div>
|
|
13808
14683
|
</section>`;
|
|
13809
14684
|
}
|
|
@@ -13824,6 +14699,14 @@ function activateCharacterEditorTab(key) {
|
|
|
13824
14699
|
) {
|
|
13825
14700
|
void loadCharacterEditorRelationships(characterEditorItem.id);
|
|
13826
14701
|
}
|
|
14702
|
+
if (
|
|
14703
|
+
key === "roleplay-memory"
|
|
14704
|
+
&& characterEditorItem?.id
|
|
14705
|
+
&& !roleplayMemoryLoaded
|
|
14706
|
+
&& !roleplayMemoryLoading
|
|
14707
|
+
) {
|
|
14708
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`角色扮演记忆加载失败:${error.message}`, "error"));
|
|
14709
|
+
}
|
|
13827
14710
|
}
|
|
13828
14711
|
|
|
13829
14712
|
function setCharacterHistoryVisible(visible) {
|
|
@@ -14052,12 +14935,69 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
|
|
|
14052
14935
|
};
|
|
14053
14936
|
}
|
|
14054
14937
|
|
|
14938
|
+
function loadVditorStylesheet() {
|
|
14939
|
+
const existing = document.getElementById("vditorStylesheet");
|
|
14940
|
+
if (existing?.dataset.loaded === "true") return Promise.resolve();
|
|
14941
|
+
return new Promise((resolve, reject) => {
|
|
14942
|
+
const link = existing ?? document.createElement("link");
|
|
14943
|
+
link.id = "vditorStylesheet";
|
|
14944
|
+
link.rel = "stylesheet";
|
|
14945
|
+
link.href = "/vendor/vditor/dist/index.css?v=3.11.2";
|
|
14946
|
+
link.addEventListener("load", () => {
|
|
14947
|
+
link.dataset.loaded = "true";
|
|
14948
|
+
resolve();
|
|
14949
|
+
}, { once: true });
|
|
14950
|
+
link.addEventListener("error", () => {
|
|
14951
|
+
link.remove();
|
|
14952
|
+
reject(new Error("Vditor stylesheet failed to load"));
|
|
14953
|
+
}, { once: true });
|
|
14954
|
+
if (!existing) document.head.append(link);
|
|
14955
|
+
});
|
|
14956
|
+
}
|
|
14957
|
+
|
|
14958
|
+
function loadVditorScript(id, src) {
|
|
14959
|
+
const existing = document.getElementById(id);
|
|
14960
|
+
if (existing?.dataset.loaded === "true") return Promise.resolve();
|
|
14961
|
+
return new Promise((resolve, reject) => {
|
|
14962
|
+
const script = existing ?? document.createElement("script");
|
|
14963
|
+
script.id = id;
|
|
14964
|
+
script.src = src;
|
|
14965
|
+
script.addEventListener("load", () => {
|
|
14966
|
+
script.dataset.loaded = "true";
|
|
14967
|
+
resolve();
|
|
14968
|
+
}, { once: true });
|
|
14969
|
+
script.addEventListener("error", () => {
|
|
14970
|
+
script.remove();
|
|
14971
|
+
reject(new Error(`Vditor script failed to load: ${id}`));
|
|
14972
|
+
}, { once: true });
|
|
14973
|
+
if (!existing) document.body.append(script);
|
|
14974
|
+
});
|
|
14975
|
+
}
|
|
14976
|
+
|
|
14977
|
+
async function loadVditorResources() {
|
|
14978
|
+
if (window.Vditor && document.getElementById("vditorStylesheet")) return true;
|
|
14979
|
+
if (!vditorResourcesPromise) {
|
|
14980
|
+
vditorResourcesPromise = Promise.all([
|
|
14981
|
+
loadVditorStylesheet(),
|
|
14982
|
+
loadVditorScript("vditorIconScript", "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"),
|
|
14983
|
+
loadVditorScript("vditorMainScript", "/vendor/vditor/dist/index.min.js?v=3.11.2")
|
|
14984
|
+
]).then(() => {
|
|
14985
|
+
if (!window.Vditor) throw new Error("Vditor constructor is unavailable");
|
|
14986
|
+
return true;
|
|
14987
|
+
}).catch(() => {
|
|
14988
|
+
vditorResourcesPromise = null;
|
|
14989
|
+
toast("Markdown 编辑器资源加载失败,请检查网络后重试", "error");
|
|
14990
|
+
return false;
|
|
14991
|
+
});
|
|
14992
|
+
}
|
|
14993
|
+
return vditorResourcesPromise;
|
|
14994
|
+
}
|
|
14995
|
+
|
|
14055
14996
|
function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
|
|
14056
14997
|
if (!window.Vditor) {
|
|
14057
14998
|
toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
|
|
14058
14999
|
return null;
|
|
14059
15000
|
}
|
|
14060
|
-
ensureVditorIconScript();
|
|
14061
15001
|
let editor = null;
|
|
14062
15002
|
editor = new window.Vditor(host, {
|
|
14063
15003
|
cdn: "/vendor/vditor",
|
|
@@ -14078,7 +15018,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
14078
15018
|
className: "vditor-line-number-button",
|
|
14079
15019
|
icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h3M4 9h3M4 13h3M4 17h3M10 5h10M10 9h10M10 13h10M10 17h10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/></svg>',
|
|
14080
15020
|
click: () => toggleVditorLineNumbers(editor)
|
|
14081
|
-
}, "edit-mode"
|
|
15021
|
+
}, "edit-mode"],
|
|
14082
15022
|
upload: {
|
|
14083
15023
|
accept: "image/*",
|
|
14084
15024
|
max: 10 * 1024 * 1024,
|
|
@@ -14291,14 +15231,6 @@ function normalizeVditorAttachmentImages(editor) {
|
|
|
14291
15231
|
});
|
|
14292
15232
|
}
|
|
14293
15233
|
|
|
14294
|
-
function ensureVditorIconScript() {
|
|
14295
|
-
if (document.getElementById("vditorIconScript")) return;
|
|
14296
|
-
const script = document.createElement("script");
|
|
14297
|
-
script.id = "vditorIconScript";
|
|
14298
|
-
script.src = "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2";
|
|
14299
|
-
document.body.appendChild(script);
|
|
14300
|
-
}
|
|
14301
|
-
|
|
14302
15234
|
function destroyVditorEditor(editor) {
|
|
14303
15235
|
if (!editor) return;
|
|
14304
15236
|
editor.__attachmentObserver?.disconnect();
|
|
@@ -14407,6 +15339,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
|
|
|
14407
15339
|
|
|
14408
15340
|
async function openKnowledgeSectionEditor(index = null) {
|
|
14409
15341
|
if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
|
|
15342
|
+
if (!(await loadVditorResources())) return;
|
|
14410
15343
|
const label = knowledgeEditorKind === "race" ? "种族" : "组织";
|
|
14411
15344
|
destroyVditorEditor(knowledgeSectionVditor);
|
|
14412
15345
|
knowledgeSectionVditor = null;
|
|
@@ -14519,6 +15452,7 @@ function syncCharacterSectionEditorDirty(markdown = null) {
|
|
|
14519
15452
|
}
|
|
14520
15453
|
|
|
14521
15454
|
async function openCharacterSectionEditor(section = null) {
|
|
15455
|
+
if (!(await loadVditorResources())) return;
|
|
14522
15456
|
await discardPendingCharacterAttachments();
|
|
14523
15457
|
destroyVditorEditor(characterSectionVditor);
|
|
14524
15458
|
characterSectionVditor = null;
|
|
@@ -14699,6 +15633,30 @@ function renderCharacterAvatar(item) {
|
|
|
14699
15633
|
}
|
|
14700
15634
|
}
|
|
14701
15635
|
|
|
15636
|
+
function roleplayMemoryToolbarMarkup() {
|
|
15637
|
+
return `<div class="roleplay-memory-toolbar">
|
|
15638
|
+
<button id="roleplay-memory-filter-toggle" class="module-filter-toggle" type="button" aria-label="筛选角色扮演记忆" aria-controls="roleplay-memory-filter-panel" aria-expanded="false" title="筛选角色扮演记忆"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>
|
|
15639
|
+
<button id="roleplay-memory-add" class="primary-button" type="button">手工添加</button>
|
|
15640
|
+
</div>`;
|
|
15641
|
+
}
|
|
15642
|
+
|
|
15643
|
+
function roleplayMemorySurfaceMarkup() {
|
|
15644
|
+
return `<div id="character-roleplay-memory-surface" class="character-roleplay-memory-surface">
|
|
15645
|
+
<section id="roleplay-memory-filter-panel" class="roleplay-memory-filter-panel hidden" aria-label="角色扮演记忆筛选">
|
|
15646
|
+
<label for="roleplay-memory-search">搜索<input id="roleplay-memory-search" type="search" maxlength="200" placeholder="搜索事件、承诺、场景或角色状态"></label>
|
|
15647
|
+
<label for="roleplay-memory-category">类别<select id="roleplay-memory-category"><option value="">全部类别</option><option value="event">事件</option><option value="state">状态</option><option value="relationship">关系</option><option value="commitment">承诺</option><option value="knowledge">知识</option><option value="scene">场景</option></select></label>
|
|
15648
|
+
<label for="roleplay-memory-status">状态<select id="roleplay-memory-status"><option value="active">生效中</option><option value="superseded">已取代</option><option value="archived">已删除</option><option value="all">全部状态</option></select></label>
|
|
15649
|
+
<button id="roleplay-memory-filter-reset" class="ghost-button" type="button">重置筛选</button>
|
|
15650
|
+
</section>
|
|
15651
|
+
<div id="roleplay-memory-list" class="roleplay-memory-list" aria-live="polite"></div>
|
|
15652
|
+
<nav id="roleplay-memory-pagination" class="module-pagination roleplay-memory-pagination hidden" aria-label="角色扮演记忆分页">
|
|
15653
|
+
<button id="roleplay-memory-previous" type="button" disabled>上一页</button>
|
|
15654
|
+
<span id="roleplay-memory-page-label">第 1 页</span>
|
|
15655
|
+
<button id="roleplay-memory-next" type="button" disabled>下一页</button>
|
|
15656
|
+
</nav>
|
|
15657
|
+
</div>`;
|
|
15658
|
+
}
|
|
15659
|
+
|
|
14702
15660
|
function renderCharacterEditorFields(item) {
|
|
14703
15661
|
const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
|
|
14704
15662
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
@@ -14747,7 +15705,12 @@ function renderCharacterEditorFields(item) {
|
|
|
14747
15705
|
'<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
|
|
14748
15706
|
field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
|
|
14749
15707
|
characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
|
|
14750
|
-
'<div id="character-editor-relationships" class="character-editor-relationships-field"></div>')
|
|
15708
|
+
'<div id="character-editor-relationships" class="character-editor-relationships-field"></div>'),
|
|
15709
|
+
characterEditorSection("roleplay-memory", "角色扮演记忆", "该角色在作品内唯一、所有有权用户共享的非正史角色扮演记忆库。",
|
|
15710
|
+
item?.id
|
|
15711
|
+
? roleplayMemorySurfaceMarkup()
|
|
15712
|
+
: '<div class="character-editor-empty-field"><b>角色扮演记忆</b><span>保存角色卡后即可管理该角色的共享记忆库。</span></div>',
|
|
15713
|
+
item?.id ? roleplayMemoryToolbarMarkup() : "")
|
|
14751
15714
|
].join("");
|
|
14752
15715
|
const name = $("#character-editor-fields [name='name']");
|
|
14753
15716
|
if (name) name.required = true;
|
|
@@ -14755,6 +15718,7 @@ function renderCharacterEditorFields(item) {
|
|
|
14755
15718
|
renderCharacterAvatar(item);
|
|
14756
15719
|
renderCharacterEditorRelationships();
|
|
14757
15720
|
renderCharacterMarkdownSections();
|
|
15721
|
+
bindRoleplayMemorySurface(item);
|
|
14758
15722
|
activateCharacterEditorTab("basic");
|
|
14759
15723
|
}
|
|
14760
15724
|
|
|
@@ -14923,6 +15887,10 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14923
15887
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
14924
15888
|
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
14925
15889
|
$("#character-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
15890
|
+
if (item) {
|
|
15891
|
+
$("#character-roleplay-memory-surface")?.querySelectorAll("button, input, select").forEach((control) => { control.disabled = false; });
|
|
15892
|
+
renderRoleplayMemoryList();
|
|
15893
|
+
}
|
|
14926
15894
|
}
|
|
14927
15895
|
$("#character-change-note").readOnly = viewOnly;
|
|
14928
15896
|
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
@@ -14949,6 +15917,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14949
15917
|
const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
|
|
14950
15918
|
relationshipTab.disabled = !item || !canReadModule("relationships");
|
|
14951
15919
|
relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
|
|
15920
|
+
const roleplayMemoryTab = document.querySelector("[data-character-editor-tab='roleplay-memory']");
|
|
15921
|
+
roleplayMemoryTab.disabled = !item;
|
|
15922
|
+
roleplayMemoryTab.title = item ? "查看和管理该角色的共享角色扮演记忆" : "创建人物档案后即可管理角色扮演记忆";
|
|
14952
15923
|
const form = $("#character-editor-form");
|
|
14953
15924
|
form.onsubmit = async (event) => {
|
|
14954
15925
|
event.preventDefault();
|
|
@@ -16246,6 +17217,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16246
17217
|
const tab = activeAiChatTab();
|
|
16247
17218
|
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
16248
17219
|
if (aiRequestManager.hasActive(tab.id)) return;
|
|
17220
|
+
if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
|
|
16249
17221
|
const composerSnapshot = captureAiPromptComposer();
|
|
16250
17222
|
const requestComposerSnapshot = retry
|
|
16251
17223
|
? {
|
|
@@ -16727,6 +17699,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16727
17699
|
toolCalls.push(toolCall);
|
|
16728
17700
|
processSteps.push(aiToolProcessStep(toolCall, round));
|
|
16729
17701
|
renderStreamingProcessSteps(false, elapsedProcessTime());
|
|
17702
|
+
handleInteractiveToolCallEvent(toolCall);
|
|
16730
17703
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
16731
17704
|
scrollAiFeedToBottom(feed);
|
|
16732
17705
|
} else if (eventName === "context_compacted") {
|
|
@@ -16986,6 +17959,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null, option
|
|
|
16986
17959
|
try {
|
|
16987
17960
|
const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
|
|
16988
17961
|
state.chapter = result.chapter;
|
|
17962
|
+
resetChapterDraftLineIds(state.chapter);
|
|
16989
17963
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
16990
17964
|
$("#chapter-content").value = state.chapter.content;
|
|
16991
17965
|
scheduleChapterLineNumbers();
|
|
@@ -17079,6 +18053,7 @@ async function showVersions() {
|
|
|
17079
18053
|
}
|
|
17080
18054
|
try {
|
|
17081
18055
|
state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
|
|
18056
|
+
resetChapterDraftLineIds(state.chapter);
|
|
17082
18057
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
17083
18058
|
$("#chapter-title").value = state.chapter.title;
|
|
17084
18059
|
$("#chapter-content").value = state.chapter.content;
|
|
@@ -18436,10 +19411,7 @@ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
|
|
|
18436
19411
|
renderChapterForeshadowReminder();
|
|
18437
19412
|
$("#chapter-foreshadow-reminder-details-button").focus();
|
|
18438
19413
|
});
|
|
18439
|
-
$("#chapter-
|
|
18440
|
-
if (state.chapter) void deleteChapter(state.chapter.id);
|
|
18441
|
-
});
|
|
18442
|
-
$("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
|
|
19414
|
+
$("#chapter-edit-button").addEventListener("click", toggleChapterEditPreviewMode);
|
|
18443
19415
|
$("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
|
|
18444
19416
|
$("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
18445
19417
|
$("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
|
|
@@ -18552,11 +19524,59 @@ $("#appearance-form").addEventListener("submit", (event) => {
|
|
|
18552
19524
|
toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
|
|
18553
19525
|
});
|
|
18554
19526
|
$("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
|
|
18555
|
-
$("#chapter-content").addEventListener("
|
|
19527
|
+
$("#chapter-content").addEventListener("beforeinput", (event) => {
|
|
19528
|
+
if (!state.chapter) return;
|
|
19529
|
+
const input = event.currentTarget;
|
|
19530
|
+
syncChapterDraftLineIds(input.value);
|
|
19531
|
+
chapterBeforeInputState = {
|
|
19532
|
+
chapterId: state.chapter.id,
|
|
19533
|
+
content: input.value,
|
|
19534
|
+
selectionStart: input.selectionStart,
|
|
19535
|
+
selectionEnd: input.selectionEnd,
|
|
19536
|
+
inputType: event.inputType
|
|
19537
|
+
};
|
|
19538
|
+
});
|
|
19539
|
+
$("#chapter-content").addEventListener("keydown", (event) => {
|
|
19540
|
+
const input = event.currentTarget;
|
|
19541
|
+
if (
|
|
19542
|
+
event.key !== "Enter"
|
|
19543
|
+
|| !state.work?.editorAutoIndentEnabled
|
|
19544
|
+
|| event.isComposing
|
|
19545
|
+
|| event.altKey
|
|
19546
|
+
|| event.ctrlKey
|
|
19547
|
+
|| event.metaKey
|
|
19548
|
+
|| !state.chapter
|
|
19549
|
+
|| input.readOnly
|
|
19550
|
+
) return;
|
|
19551
|
+
event.preventDefault();
|
|
19552
|
+
syncChapterDraftLineIds(input.value);
|
|
19553
|
+
chapterBeforeInputState = {
|
|
19554
|
+
chapterId: state.chapter.id,
|
|
19555
|
+
content: input.value,
|
|
19556
|
+
selectionStart: input.selectionStart,
|
|
19557
|
+
selectionEnd: input.selectionEnd,
|
|
19558
|
+
inputType: "insertLineBreak"
|
|
19559
|
+
};
|
|
19560
|
+
const next = insertIndentedParagraph(input.value, input.selectionStart, input.selectionEnd);
|
|
19561
|
+
input.setRangeText(`\n${CHAPTER_PARAGRAPH_INDENT}`, input.selectionStart, input.selectionEnd, "end");
|
|
19562
|
+
input.setSelectionRange(next.selectionStart, next.selectionEnd);
|
|
19563
|
+
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertLineBreak" }));
|
|
19564
|
+
});
|
|
19565
|
+
$("#chapter-content").addEventListener("input", (event) => {
|
|
19566
|
+
const input = event.currentTarget;
|
|
19567
|
+
const beforeInput = chapterBeforeInputState;
|
|
19568
|
+
const hint = beforeInput
|
|
19569
|
+
&& beforeInput.chapterId === state.chapter?.id
|
|
19570
|
+
&& chapterDraftLineIdState?.content === beforeInput.content
|
|
19571
|
+
? beforeInput
|
|
19572
|
+
: null;
|
|
19573
|
+
syncChapterDraftLineIds(input.value, hint);
|
|
19574
|
+
chapterBeforeInputState = null;
|
|
18556
19575
|
updateChapterStats();
|
|
18557
19576
|
scheduleChapterAutoSave();
|
|
18558
19577
|
clearChapterLineSelection();
|
|
18559
19578
|
scheduleChapterLineNumbers(chapterLineInputRenderDelay);
|
|
19579
|
+
scheduleChapterCaretScroll();
|
|
18560
19580
|
setAiContextMeter(null);
|
|
18561
19581
|
});
|
|
18562
19582
|
$("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
|
|
@@ -19279,6 +20299,109 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
|
|
|
19279
20299
|
if (label) label.textContent = "导出 Markdown";
|
|
19280
20300
|
}
|
|
19281
20301
|
});
|
|
20302
|
+
// --- AI 操作审批中心与写入审批 / 提问弹窗 ---
|
|
20303
|
+
$("#ai-approval-center-toggle").addEventListener("click", () => {
|
|
20304
|
+
const dialog = $("#ai-approval-center-dialog");
|
|
20305
|
+
if (dialog.open) {
|
|
20306
|
+
dialog.close();
|
|
20307
|
+
return;
|
|
20308
|
+
}
|
|
20309
|
+
openAiApprovalCenter();
|
|
20310
|
+
});
|
|
20311
|
+
$("#ai-approval-center-close").addEventListener("click", () => $("#ai-approval-center-dialog").close());
|
|
20312
|
+
$("#ai-approval-center-dialog").addEventListener("close", () => {
|
|
20313
|
+
$("#ai-approval-center-toggle").setAttribute("aria-expanded", "false");
|
|
20314
|
+
});
|
|
20315
|
+
$("#ai-approval-center-dialog .ai-approval-filters").addEventListener("click", (event) => {
|
|
20316
|
+
const chip = event.target.closest("[data-status-filter]");
|
|
20317
|
+
if (!chip) return;
|
|
20318
|
+
aiApprovalCenterState.status = String(chip.dataset.statusFilter ?? "");
|
|
20319
|
+
document.querySelectorAll("#ai-approval-center-dialog [data-status-filter]").forEach((item) => {
|
|
20320
|
+
item.setAttribute("aria-pressed", String(item === chip));
|
|
20321
|
+
});
|
|
20322
|
+
$("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
|
|
20323
|
+
loadAiApprovalCenterPlans().catch((error) => {
|
|
20324
|
+
$("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
|
|
20325
|
+
});
|
|
20326
|
+
});
|
|
20327
|
+
$("#ai-approval-list-host").addEventListener("click", (event) => {
|
|
20328
|
+
const row = event.target.closest("[data-plan-id]");
|
|
20329
|
+
if (row) {
|
|
20330
|
+
openAiWritePlanDetail(row.dataset.planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
|
|
20331
|
+
return;
|
|
20332
|
+
}
|
|
20333
|
+
const questionRow = event.target.closest("[data-question-id]");
|
|
20334
|
+
if (questionRow) openAiUserQuestionDialog(questionRow.dataset.questionId).catch((error) => toast(`提问详情加载失败:${error.message}`, "error"));
|
|
20335
|
+
});
|
|
20336
|
+
$("#ai-write-plan-close").addEventListener("click", () => $("#ai-write-plan-dialog").close());
|
|
20337
|
+
$("#ai-write-plan-refresh").addEventListener("click", () => {
|
|
20338
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20339
|
+
openAiWritePlanDetail(aiWritePlanDialogPlanId).catch((error) => toast(`状态刷新失败:${error.message}`, "error"));
|
|
20340
|
+
});
|
|
20341
|
+
$("#ai-write-plan-confirm").addEventListener("click", () => {
|
|
20342
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20343
|
+
decideAiWritePlan(aiWritePlanDialogPlanId, "confirm").catch((error) => toast(error.message, "error"));
|
|
20344
|
+
});
|
|
20345
|
+
$("#ai-write-plan-reject").addEventListener("click", () => {
|
|
20346
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20347
|
+
decideAiWritePlan(aiWritePlanDialogPlanId, "reject").catch((error) => toast(error.message, "error"));
|
|
20348
|
+
});
|
|
20349
|
+
$("#ai-write-plan-undo").addEventListener("click", () => {
|
|
20350
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20351
|
+
undoAiWritePlan(aiWritePlanDialogPlanId);
|
|
20352
|
+
});
|
|
20353
|
+
$("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
|
|
20354
|
+
|
|
20355
|
+
function syncAiQuestionSubmitState() {
|
|
20356
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
20357
|
+
const customInput = $("#ai-question-custom-answer");
|
|
20358
|
+
const submit = $("#ai-question-submit");
|
|
20359
|
+
const disabled = !checked
|
|
20360
|
+
|| checked.disabled
|
|
20361
|
+
|| (checked.value === "custom" && !String(customInput.value).trim());
|
|
20362
|
+
submit.disabled = disabled;
|
|
20363
|
+
submit.title = disabled ? "请选择一个预设选项,或填写自定义回答后提交" : "";
|
|
20364
|
+
}
|
|
20365
|
+
$("#ai-question-form").addEventListener("change", (event) => {
|
|
20366
|
+
const control = event.target;
|
|
20367
|
+
if (control?.name !== "ai-question-choice") return;
|
|
20368
|
+
const isCustom = control.value === "custom";
|
|
20369
|
+
const customInput = $("#ai-question-custom-answer");
|
|
20370
|
+
if (isCustom && !control.disabled) customInput.focus();
|
|
20371
|
+
syncAiQuestionOptionPresentation();
|
|
20372
|
+
syncAiQuestionSubmitState();
|
|
20373
|
+
});
|
|
20374
|
+
$("#ai-question-custom-answer").addEventListener("input", () => {
|
|
20375
|
+
const customInput = $("#ai-question-custom-answer");
|
|
20376
|
+
syncAiQuestionAnswerCount();
|
|
20377
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
20378
|
+
if (!checked && String(customInput.value).trim()) {
|
|
20379
|
+
const customRadio = document.querySelector('input[name="ai-question-choice"][value="custom"]');
|
|
20380
|
+
if (customRadio && !customRadio.disabled) customRadio.checked = true;
|
|
20381
|
+
}
|
|
20382
|
+
syncAiQuestionOptionPresentation();
|
|
20383
|
+
syncAiQuestionSubmitState();
|
|
20384
|
+
});
|
|
20385
|
+
$("#ai-question-submit").addEventListener("click", () => {
|
|
20386
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
20387
|
+
if (!checked || aiQuestionDialogQuestionId == null) return;
|
|
20388
|
+
if (checked.value === "custom") {
|
|
20389
|
+
const text = String($("#ai-question-custom-answer").value).trim();
|
|
20390
|
+
if (!text) return;
|
|
20391
|
+
respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "custom", customAnswer: text }).catch((error) => toast(error.message, "error"));
|
|
20392
|
+
return;
|
|
20393
|
+
}
|
|
20394
|
+
const supplementalAnswer = String($("#ai-question-custom-answer").value).trim();
|
|
20395
|
+
respondAiUserQuestion(aiQuestionDialogQuestionId, {
|
|
20396
|
+
action: "option",
|
|
20397
|
+
selectedOption: Number(checked.value),
|
|
20398
|
+
...(supplementalAnswer ? { customAnswer: supplementalAnswer } : {})
|
|
20399
|
+
}).catch((error) => toast(error.message, "error"));
|
|
20400
|
+
});
|
|
20401
|
+
$("#ai-question-skip").addEventListener("click", () => {
|
|
20402
|
+
if (aiQuestionDialogQuestionId == null) return;
|
|
20403
|
+
respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "reject" }).catch((error) => toast(error.message, "error"));
|
|
20404
|
+
});
|
|
19282
20405
|
$("#ai-prompt").addEventListener("keydown", (event) => {
|
|
19283
20406
|
const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
|
|
19284
20407
|
if (mentionMenuVisible) {
|
|
@@ -19373,9 +20496,6 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
|
|
|
19373
20496
|
$("#reader-open-button").addEventListener("click", () => {
|
|
19374
20497
|
void openReadingPreview({ restorePosition: true });
|
|
19375
20498
|
});
|
|
19376
|
-
$("#chapter-reader-button").addEventListener("click", () => {
|
|
19377
|
-
void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
|
|
19378
|
-
});
|
|
19379
20499
|
$("#reader-close").addEventListener("click", closeReadingPreview);
|
|
19380
20500
|
$("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
|
|
19381
20501
|
$("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));
|