@musnows/scriverse 0.9.3 → 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 +10 -5
- package/dist/public/ai-interactive.js +483 -0
- package/dist/public/app.js +1187 -86
- 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 +237 -54
- 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 +454 -24
- 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/utils.js +0 -8
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
package/dist/public/app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
|
|
2
|
-
import {
|
|
2
|
+
import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
3
|
import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
|
|
4
4
|
import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
|
|
5
5
|
import {
|
|
@@ -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,17 +1985,35 @@ function scheduleChapterLineNumbers(delay = 0) {
|
|
|
1937
1985
|
}, wait);
|
|
1938
1986
|
}
|
|
1939
1987
|
|
|
1940
|
-
function
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
+
});
|
|
1951
2017
|
}
|
|
1952
2018
|
|
|
1953
2019
|
function lineIndexAtPointer(clientY) {
|
|
@@ -2709,6 +2775,8 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
2709
2775
|
recall_other: "回忆相识角色",
|
|
2710
2776
|
recall_known: "回忆知情设定",
|
|
2711
2777
|
recall_story: "回忆故事",
|
|
2778
|
+
recall_roleplay_memory: "回忆当前扮演线",
|
|
2779
|
+
remember_roleplay: "整理扮演记忆",
|
|
2712
2780
|
calculate_time: "计算日期"
|
|
2713
2781
|
};
|
|
2714
2782
|
|
|
@@ -2725,6 +2793,8 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
2725
2793
|
recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
|
|
2726
2794
|
recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
|
|
2727
2795
|
recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
|
|
2796
|
+
recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
|
|
2797
|
+
remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
|
|
2728
2798
|
calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
|
|
2729
2799
|
};
|
|
2730
2800
|
|
|
@@ -2888,6 +2958,10 @@ function openAiToolCallDetail(toolCall) {
|
|
|
2888
2958
|
|
|
2889
2959
|
function createAiToolCallButton(toolCall) {
|
|
2890
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
|
+
}
|
|
2891
2965
|
const button = document.createElement("button");
|
|
2892
2966
|
button.type = "button";
|
|
2893
2967
|
button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
|
|
@@ -2898,6 +2972,392 @@ function createAiToolCallButton(toolCall) {
|
|
|
2898
2972
|
return button;
|
|
2899
2973
|
}
|
|
2900
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. */
|
|
2901
3361
|
function aiToolProcessStep(toolCall, round = 1) {
|
|
2902
3362
|
const normalizedToolCall = { ...toolCall };
|
|
2903
3363
|
delete normalizedToolCall.round;
|
|
@@ -2958,7 +3418,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
|
|
|
2958
3418
|
if (!renderableSteps.length) return;
|
|
2959
3419
|
const details = document.createElement("details");
|
|
2960
3420
|
details.className = "ai-process-details";
|
|
2961
|
-
|
|
3421
|
+
// 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
|
|
3422
|
+
details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
|
|
2962
3423
|
const summary = document.createElement("summary");
|
|
2963
3424
|
const title = document.createElement("span");
|
|
2964
3425
|
title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
|
|
@@ -3222,6 +3683,266 @@ function renderAiConversationHistory() {
|
|
|
3222
3683
|
}
|
|
3223
3684
|
}
|
|
3224
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
|
+
|
|
3225
3946
|
function defaultAiConversationTitle(prompt) {
|
|
3226
3947
|
const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
|
|
3227
3948
|
return Array.from(normalized).slice(0, 15).join("") || "新对话";
|
|
@@ -3310,7 +4031,7 @@ async function ensureAiConversationsLoaded() {
|
|
|
3310
4031
|
}
|
|
3311
4032
|
}
|
|
3312
4033
|
|
|
3313
|
-
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
|
|
4034
|
+
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null, roleplayMemorySourceId = null) {
|
|
3314
4035
|
if (!state.work) return null;
|
|
3315
4036
|
const existingTab = aiChatTabManager.findByConversation(conversationId);
|
|
3316
4037
|
const existingMessage = focusMessageId
|
|
@@ -3331,6 +4052,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
|
|
|
3331
4052
|
try {
|
|
3332
4053
|
const parameters = new URLSearchParams({ page: "1", limit: "100" });
|
|
3333
4054
|
if (focusMessageId) parameters.set("messageId", String(focusMessageId));
|
|
4055
|
+
if (roleplayMemorySourceId) parameters.set("roleplayMemorySourceId", String(roleplayMemorySourceId));
|
|
3334
4056
|
const [conversation] = await Promise.all([
|
|
3335
4057
|
api(`/api/ai-conversations/${conversationId}?${parameters}`),
|
|
3336
4058
|
ensureAiReferencesLoaded()
|
|
@@ -5630,12 +6352,44 @@ function setSaveState(text, dirty = false) {
|
|
|
5630
6352
|
});
|
|
5631
6353
|
}
|
|
5632
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
|
+
|
|
5633
6384
|
function chapterDraftSnapshot() {
|
|
5634
6385
|
if (!state.chapter) return null;
|
|
6386
|
+
const content = $("#chapter-content").value;
|
|
6387
|
+
const lineIds = syncChapterDraftLineIds(content);
|
|
5635
6388
|
return {
|
|
5636
6389
|
chapterId: state.chapter.id,
|
|
5637
6390
|
title: $("#chapter-title").value.trim(),
|
|
5638
|
-
content
|
|
6391
|
+
content,
|
|
6392
|
+
...(lineIds.length <= MAX_CHAPTER_LINE_IDS ? { lineIds } : {})
|
|
5639
6393
|
};
|
|
5640
6394
|
}
|
|
5641
6395
|
|
|
@@ -5692,11 +6446,6 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5692
6446
|
if (!automatic) toast("章节标题不能为空", "error");
|
|
5693
6447
|
return null;
|
|
5694
6448
|
}
|
|
5695
|
-
const input = $("#chapter-content");
|
|
5696
|
-
if (input.value !== draft.content) {
|
|
5697
|
-
input.value = draft.content;
|
|
5698
|
-
scheduleChapterLineNumbers();
|
|
5699
|
-
}
|
|
5700
6449
|
if (sameChapterSnapshot(draft, lastSavedChapterSnapshot)) {
|
|
5701
6450
|
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
5702
6451
|
if (!automatic) await loadChapterForeshadowReminders();
|
|
@@ -5715,7 +6464,7 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5715
6464
|
const request = (async () => {
|
|
5716
6465
|
const chapter = await api(`/api/chapters/${draft.chapterId}`, {
|
|
5717
6466
|
method: "PATCH",
|
|
5718
|
-
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" }
|
|
5719
6468
|
});
|
|
5720
6469
|
const work = await api(`/api/works/${workId}`);
|
|
5721
6470
|
return { chapter, work };
|
|
@@ -5726,9 +6475,15 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5726
6475
|
if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
|
|
5727
6476
|
state.chapter = saved.chapter;
|
|
5728
6477
|
state.work = saved.work;
|
|
6478
|
+
resetChapterDraftLineIds(state.chapter);
|
|
5729
6479
|
lastSavedChapterSnapshot = draft;
|
|
5730
6480
|
renderTree();
|
|
5731
6481
|
updateChapterStats();
|
|
6482
|
+
try {
|
|
6483
|
+
await loadChapterAnnotationCounts(saved.chapter.id);
|
|
6484
|
+
} catch (error) {
|
|
6485
|
+
toast("正文评论位置已更新,但评论数量刷新失败,请稍后重试", "error");
|
|
6486
|
+
}
|
|
5732
6487
|
const currentDraft = chapterDraftSnapshot();
|
|
5733
6488
|
if (sameChapterSnapshot(currentDraft, draft)) {
|
|
5734
6489
|
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
@@ -5803,13 +6558,35 @@ function restoredSettingsReturnContext(route) {
|
|
|
5803
6558
|
}
|
|
5804
6559
|
|
|
5805
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;
|
|
5806
6565
|
const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
|
|
5807
6566
|
if (!authenticated) {
|
|
5808
6567
|
restoringPageRoute = false;
|
|
5809
6568
|
return;
|
|
5810
6569
|
}
|
|
5811
|
-
const
|
|
5812
|
-
|
|
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;
|
|
5813
6590
|
try {
|
|
5814
6591
|
if (route.view === "shelf") {
|
|
5815
6592
|
showShelf();
|
|
@@ -5850,7 +6627,7 @@ async function initializePage() {
|
|
|
5850
6627
|
return;
|
|
5851
6628
|
}
|
|
5852
6629
|
const options = { readOnly: route.entityMode === "read" };
|
|
5853
|
-
if (route.entity === "setting") openSettingEditor(item, options);
|
|
6630
|
+
if (route.entity === "setting") await openSettingEditor(item, options);
|
|
5854
6631
|
else if (route.entity === "character") await openCharacterEditor(item, options);
|
|
5855
6632
|
else if (route.entity === "race") await openRaceDialog(item, options);
|
|
5856
6633
|
else if (route.entity === "organization") await openOrganizationDialog(item, options);
|
|
@@ -5881,7 +6658,14 @@ async function initializePage() {
|
|
|
5881
6658
|
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
5882
6659
|
}
|
|
5883
6660
|
} finally {
|
|
6661
|
+
delete window.__scriverseReaderChapterPrefetch;
|
|
6662
|
+
delete window.__scriverseReaderWorksPrefetch;
|
|
6663
|
+
delete window.__scriverseReaderWorkPrefetch;
|
|
6664
|
+
initialWorkDetail = null;
|
|
6665
|
+
initialReaderChapterRequest = null;
|
|
5884
6666
|
document.body.classList.remove("auth-pending");
|
|
6667
|
+
document.documentElement.removeAttribute("data-pending-view");
|
|
6668
|
+
document.documentElement.classList.remove("pending-shelf-mode");
|
|
5885
6669
|
restoringPageRoute = false;
|
|
5886
6670
|
replacePageRoute(currentPageRoute());
|
|
5887
6671
|
scheduleFirstUseOnboarding();
|
|
@@ -6989,6 +7773,7 @@ async function refreshWorkAfterGlobalReplace(route, result) {
|
|
|
6989
7773
|
const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
|
|
6990
7774
|
if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
|
|
6991
7775
|
state.chapter = chapter;
|
|
7776
|
+
resetChapterDraftLineIds(state.chapter);
|
|
6992
7777
|
mergeChapterDirectoryEntry(chapter);
|
|
6993
7778
|
lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
|
|
6994
7779
|
await loadVolumeChapters(chapter.volumeId);
|
|
@@ -7410,7 +8195,9 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
7410
8195
|
volumeChapterLoadingIds.clear();
|
|
7411
8196
|
volumeChapterRequests.clear();
|
|
7412
8197
|
}
|
|
7413
|
-
const
|
|
8198
|
+
const prefetchedWork = initialWorkDetail?.id === workId ? initialWorkDetail : null;
|
|
8199
|
+
initialWorkDetail = null;
|
|
8200
|
+
const nextWork = prefetchedWork ?? await api(`/api/works/${workId}?directory=volumes`);
|
|
7414
8201
|
if (selectionGeneration !== workSelectionRequestGeneration) return false;
|
|
7415
8202
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
7416
8203
|
showSystemStatus();
|
|
@@ -7480,9 +8267,12 @@ async function loadVolumeChapters(volumeId) {
|
|
|
7480
8267
|
async function loadAllVolumeChapters(workId) {
|
|
7481
8268
|
const generation = workScopedUiGeneration;
|
|
7482
8269
|
const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
|
|
7483
|
-
for (
|
|
7484
|
-
|
|
7485
|
-
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
|
+
}));
|
|
7486
8276
|
}
|
|
7487
8277
|
}
|
|
7488
8278
|
|
|
@@ -7855,9 +8645,14 @@ function currentChapterForeshadowReminder() {
|
|
|
7855
8645
|
|
|
7856
8646
|
function syncMobileAiPanelSafeTop() {
|
|
7857
8647
|
const container = $("#chapter-foreshadow-reminder");
|
|
7858
|
-
const
|
|
8648
|
+
const toolbar = $("#editor-view .editor-toolbar");
|
|
8649
|
+
const reminderBottom = container.classList.contains("hidden")
|
|
8650
|
+
? 0
|
|
8651
|
+
: container.getBoundingClientRect().bottom;
|
|
8652
|
+
const toolbarBottom = $("#editor-view").classList.contains("hidden")
|
|
7859
8653
|
? 0
|
|
7860
|
-
:
|
|
8654
|
+
: toolbar.getBoundingClientRect().bottom;
|
|
8655
|
+
const safeTop = Math.ceil(Math.max(reminderBottom, toolbarBottom));
|
|
7861
8656
|
$("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
|
|
7862
8657
|
}
|
|
7863
8658
|
|
|
@@ -8078,9 +8873,8 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
8078
8873
|
$("#chapter-path").textContent = chapterPath;
|
|
8079
8874
|
$("#chapter-path").title = chapterPath;
|
|
8080
8875
|
$("#chapter-title").value = state.chapter.title;
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
$("#chapter-content").value = normalizedContent;
|
|
8876
|
+
$("#chapter-content").value = state.chapter.content;
|
|
8877
|
+
resetChapterDraftLineIds(state.chapter);
|
|
8084
8878
|
chapterAnnotationCounts = new Map();
|
|
8085
8879
|
clearChapterLineSelection();
|
|
8086
8880
|
scheduleChapterLineNumbers();
|
|
@@ -8091,7 +8885,6 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
8091
8885
|
updateChapterStats();
|
|
8092
8886
|
if (!canEditProse()) setSaveState("正文只读");
|
|
8093
8887
|
else if (chapterEditorReadOnly) setSaveState("阅读模式");
|
|
8094
|
-
else if (spacingChanged) scheduleChapterAutoSave(120);
|
|
8095
8888
|
else setSaveState("已保存");
|
|
8096
8889
|
renderTree();
|
|
8097
8890
|
replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
|
|
@@ -8204,6 +8997,7 @@ function renderReadingNavigation() {
|
|
|
8204
8997
|
$("#reader-next").disabled = !next || readingLoading;
|
|
8205
8998
|
$("#reader-continue").disabled = !next || readingLoading;
|
|
8206
8999
|
$("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
|
|
9000
|
+
$("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
|
|
8207
9001
|
const paged = readingPreferences.mode === "paged";
|
|
8208
9002
|
const previousPage = current && paged ? resolvePagedReadingStep({
|
|
8209
9003
|
sequence: readingSequence,
|
|
@@ -8241,7 +9035,7 @@ function applyReadingPreferences() {
|
|
|
8241
9035
|
$("#reader-font-size").value = String(readingPreferences.fontSize);
|
|
8242
9036
|
$("#reader-line-height").value = String(readingPreferences.lineHeight);
|
|
8243
9037
|
$("#reader-theme").value = readingPreferences.theme;
|
|
8244
|
-
$("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
|
|
9038
|
+
$("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
|
|
8245
9039
|
$("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
|
|
8246
9040
|
$("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
|
|
8247
9041
|
}
|
|
@@ -8376,7 +9170,13 @@ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = n
|
|
|
8376
9170
|
renderReadingStatus("正在载入章节……");
|
|
8377
9171
|
replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
|
|
8378
9172
|
try {
|
|
8379
|
-
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 });
|
|
8380
9180
|
if (!readingRequestGate.isCurrent(request)) return false;
|
|
8381
9181
|
if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
|
|
8382
9182
|
throw new Error("章节响应与当前作品不匹配");
|
|
@@ -8530,7 +9330,7 @@ function closeReadingPreview() {
|
|
|
8530
9330
|
replacePageRoute(returnRoute);
|
|
8531
9331
|
const focus = readingPreviousFocus;
|
|
8532
9332
|
readingPreviousFocus = null;
|
|
8533
|
-
const focusCandidates = [focus, $("#
|
|
9333
|
+
const focusCandidates = [focus, $("#reader-open-button"), $("#home-button")];
|
|
8534
9334
|
for (const candidate of focusCandidates) {
|
|
8535
9335
|
if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
|
|
8536
9336
|
const rect = candidate.getBoundingClientRect();
|
|
@@ -8769,6 +9569,14 @@ function pencilIconMarkup() {
|
|
|
8769
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>';
|
|
8770
9570
|
}
|
|
8771
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
|
+
|
|
8772
9580
|
function characterFavoriteIconMarkup() {
|
|
8773
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>';
|
|
8774
9582
|
}
|
|
@@ -12453,15 +13261,19 @@ async function renderBookAiSettings() {
|
|
|
12453
13261
|
clearTimeout(relationshipSearchIndexRefreshTimer);
|
|
12454
13262
|
relationshipSearchIndexRefreshTimer = null;
|
|
12455
13263
|
}
|
|
12456
|
-
const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
|
|
13264
|
+
const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions, writeTools] = await Promise.all([
|
|
12457
13265
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
12458
13266
|
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
12459
13267
|
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
12460
13268
|
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
12461
13269
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
|
|
12462
13270
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
|
|
12463
|
-
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)
|
|
12464
13274
|
]);
|
|
13275
|
+
const writeToolsState = writeTools?.tools ?? null;
|
|
13276
|
+
const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
|
|
12465
13277
|
const host = $("#module-content");
|
|
12466
13278
|
platformAiProtocolOptions = protocolOptions;
|
|
12467
13279
|
const workId = String(state.work.id);
|
|
@@ -12511,6 +13323,7 @@ async function renderBookAiSettings() {
|
|
|
12511
13323
|
host.querySelectorAll(".config-section").forEach((section) => {
|
|
12512
13324
|
if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
|
|
12513
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>`);
|
|
12514
13327
|
bindUsageCalendarInteractions(host);
|
|
12515
13328
|
scrollUsageCalendarsToLatest(host);
|
|
12516
13329
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
@@ -12769,6 +13582,23 @@ async function renderBookAiSettings() {
|
|
|
12769
13582
|
button.disabled = false;
|
|
12770
13583
|
}
|
|
12771
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
|
+
});
|
|
12772
13602
|
host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
|
|
12773
13603
|
const select = event.currentTarget;
|
|
12774
13604
|
select.disabled = true;
|
|
@@ -12903,9 +13733,11 @@ function renderAiContextDistribution(usage) {
|
|
|
12903
13733
|
label.className = "ai-context-distribution-label";
|
|
12904
13734
|
const title = document.createElement("span");
|
|
12905
13735
|
title.textContent = item.label;
|
|
12906
|
-
if (item.key === "skills" || item.key === "
|
|
13736
|
+
if (item.key === "skills" || item.key === "input" || item.key === "output") {
|
|
12907
13737
|
const description = document.createElement("small");
|
|
12908
|
-
description.textContent = item.key === "skills"
|
|
13738
|
+
description.textContent = item.key === "skills"
|
|
13739
|
+
? "待加入"
|
|
13740
|
+
: item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
|
|
12909
13741
|
title.append(" ", description);
|
|
12910
13742
|
}
|
|
12911
13743
|
const value = document.createElement("strong");
|
|
@@ -12922,6 +13754,7 @@ function renderAiContextDistribution(usage) {
|
|
|
12922
13754
|
return row;
|
|
12923
13755
|
}));
|
|
12924
13756
|
popover.dataset.hasUsage = String(Boolean(usage));
|
|
13757
|
+
return distribution;
|
|
12925
13758
|
}
|
|
12926
13759
|
|
|
12927
13760
|
function setAiContextMeter(usage, allowShrink = true) {
|
|
@@ -12931,7 +13764,7 @@ function setAiContextMeter(usage, allowShrink = true) {
|
|
|
12931
13764
|
latestAiContextUsage = displayUsage;
|
|
12932
13765
|
const meter = $("#ai-context-meter");
|
|
12933
13766
|
const value = meter.querySelector("b");
|
|
12934
|
-
renderAiContextDistribution(displayUsage);
|
|
13767
|
+
const distribution = renderAiContextDistribution(displayUsage);
|
|
12935
13768
|
if (!displayUsage) {
|
|
12936
13769
|
meter.classList.add("is-empty");
|
|
12937
13770
|
meter.classList.remove("is-warning", "is-danger");
|
|
@@ -12942,14 +13775,14 @@ function setAiContextMeter(usage, allowShrink = true) {
|
|
|
12942
13775
|
meter.setAttribute("aria-label", tooltip);
|
|
12943
13776
|
return;
|
|
12944
13777
|
}
|
|
12945
|
-
const percent =
|
|
13778
|
+
const percent = distribution.contextWindow > 0
|
|
13779
|
+
? Math.min(100, distribution.occupiedTokens / distribution.contextWindow * 100)
|
|
13780
|
+
: 0;
|
|
12946
13781
|
meter.classList.remove("is-empty");
|
|
12947
13782
|
meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
|
|
12948
13783
|
meter.classList.toggle("is-danger", percent >= 90);
|
|
12949
13784
|
meter.style.setProperty("--context-usage", String(percent));
|
|
12950
|
-
value.textContent =
|
|
12951
|
-
? formatAiContextUsagePercent(displayUsage.inputTokens, displayUsage.contextWindow)
|
|
12952
|
-
: `${percent}%`;
|
|
13785
|
+
value.textContent = formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow);
|
|
12953
13786
|
const tooltip = formatAiContextUsageTooltip(displayUsage);
|
|
12954
13787
|
meter.dataset.tooltip = tooltip;
|
|
12955
13788
|
meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
|
|
@@ -13223,7 +14056,7 @@ function commitRelationshipKeywordInputs(container) {
|
|
|
13223
14056
|
container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
|
|
13224
14057
|
}
|
|
13225
14058
|
|
|
13226
|
-
function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
14059
|
+
async function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
13227
14060
|
formDialogVditors.forEach(destroyVditorEditor);
|
|
13228
14061
|
formDialogVditors = [];
|
|
13229
14062
|
void discardPendingMarkdownAttachments();
|
|
@@ -13273,6 +14106,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
13273
14106
|
dialog.classList.toggle("editor-dialog", Boolean(options.editor));
|
|
13274
14107
|
bindDynamicListControls($("#dialog-fields"));
|
|
13275
14108
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
14109
|
+
if ($("#dialog-fields").querySelector("[data-vditor-editor]") && !(await loadVditorResources())) return;
|
|
13276
14110
|
formDialogVditors = bindVditorEditors($("#dialog-fields"));
|
|
13277
14111
|
form.onclick = null;
|
|
13278
14112
|
form.onkeydown = null;
|
|
@@ -13516,18 +14350,36 @@ function openWorkSettingsDialog(work) {
|
|
|
13516
14350
|
<div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
|
|
13517
14351
|
<button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
|
|
13518
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>`;
|
|
13519
14360
|
openDialog("作品信息",
|
|
13520
|
-
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,
|
|
13521
14362
|
async (form) => {
|
|
13522
|
-
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
|
+
} });
|
|
13523
14370
|
state.works = (await apiPage("/api/works")).items;
|
|
13524
14371
|
const updated = state.works.find((item) => item.id === work.id);
|
|
13525
14372
|
if (updated) Object.assign(work, updated);
|
|
13526
14373
|
if (state.work?.id === work.id) {
|
|
13527
|
-
|
|
13528
|
-
|
|
13529
|
-
|
|
13530
|
-
|
|
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();
|
|
13531
14383
|
updateDocumentTitle(state.work);
|
|
13532
14384
|
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
13533
14385
|
}
|
|
@@ -13691,7 +14543,8 @@ function syncSettingEditorDirty(markdown = null) {
|
|
|
13691
14543
|
entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
|
|
13692
14544
|
}
|
|
13693
14545
|
|
|
13694
|
-
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
14546
|
+
async function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
14547
|
+
if (!(await loadVditorResources())) return;
|
|
13695
14548
|
entityEditorReadOnly = readOnly;
|
|
13696
14549
|
destroyVditorEditor(settingEditorVditor);
|
|
13697
14550
|
settingEditorVditor = null;
|
|
@@ -13706,7 +14559,7 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
13706
14559
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
13707
14560
|
const editButton = $("#setting-editor-edit");
|
|
13708
14561
|
editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
|
|
13709
|
-
editButton.onclick = () => openSettingEditor(settingEditorItem);
|
|
14562
|
+
editButton.onclick = () => { void openSettingEditor(settingEditorItem); };
|
|
13710
14563
|
bindEntityDetailPinButton($("#setting-editor-pin"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
|
|
13711
14564
|
settingEditorItem = updated;
|
|
13712
14565
|
state.settings = upsertEntityCollection(state.settings, updated);
|
|
@@ -13819,9 +14672,13 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
13819
14672
|
(readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
|
|
13820
14673
|
}
|
|
13821
14674
|
|
|
13822
|
-
function characterEditorSection(key, title, description, content) {
|
|
13823
|
-
|
|
13824
|
-
|
|
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}
|
|
13825
14682
|
<div class="character-editor-section-fields">${content}</div>
|
|
13826
14683
|
</section>`;
|
|
13827
14684
|
}
|
|
@@ -13842,6 +14699,14 @@ function activateCharacterEditorTab(key) {
|
|
|
13842
14699
|
) {
|
|
13843
14700
|
void loadCharacterEditorRelationships(characterEditorItem.id);
|
|
13844
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
|
+
}
|
|
13845
14710
|
}
|
|
13846
14711
|
|
|
13847
14712
|
function setCharacterHistoryVisible(visible) {
|
|
@@ -14070,12 +14935,69 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
|
|
|
14070
14935
|
};
|
|
14071
14936
|
}
|
|
14072
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
|
+
|
|
14073
14996
|
function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
|
|
14074
14997
|
if (!window.Vditor) {
|
|
14075
14998
|
toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
|
|
14076
14999
|
return null;
|
|
14077
15000
|
}
|
|
14078
|
-
ensureVditorIconScript();
|
|
14079
15001
|
let editor = null;
|
|
14080
15002
|
editor = new window.Vditor(host, {
|
|
14081
15003
|
cdn: "/vendor/vditor",
|
|
@@ -14096,7 +15018,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
14096
15018
|
className: "vditor-line-number-button",
|
|
14097
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>',
|
|
14098
15020
|
click: () => toggleVditorLineNumbers(editor)
|
|
14099
|
-
}, "edit-mode"
|
|
15021
|
+
}, "edit-mode"],
|
|
14100
15022
|
upload: {
|
|
14101
15023
|
accept: "image/*",
|
|
14102
15024
|
max: 10 * 1024 * 1024,
|
|
@@ -14309,14 +15231,6 @@ function normalizeVditorAttachmentImages(editor) {
|
|
|
14309
15231
|
});
|
|
14310
15232
|
}
|
|
14311
15233
|
|
|
14312
|
-
function ensureVditorIconScript() {
|
|
14313
|
-
if (document.getElementById("vditorIconScript")) return;
|
|
14314
|
-
const script = document.createElement("script");
|
|
14315
|
-
script.id = "vditorIconScript";
|
|
14316
|
-
script.src = "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2";
|
|
14317
|
-
document.body.appendChild(script);
|
|
14318
|
-
}
|
|
14319
|
-
|
|
14320
15234
|
function destroyVditorEditor(editor) {
|
|
14321
15235
|
if (!editor) return;
|
|
14322
15236
|
editor.__attachmentObserver?.disconnect();
|
|
@@ -14425,6 +15339,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
|
|
|
14425
15339
|
|
|
14426
15340
|
async function openKnowledgeSectionEditor(index = null) {
|
|
14427
15341
|
if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
|
|
15342
|
+
if (!(await loadVditorResources())) return;
|
|
14428
15343
|
const label = knowledgeEditorKind === "race" ? "种族" : "组织";
|
|
14429
15344
|
destroyVditorEditor(knowledgeSectionVditor);
|
|
14430
15345
|
knowledgeSectionVditor = null;
|
|
@@ -14537,6 +15452,7 @@ function syncCharacterSectionEditorDirty(markdown = null) {
|
|
|
14537
15452
|
}
|
|
14538
15453
|
|
|
14539
15454
|
async function openCharacterSectionEditor(section = null) {
|
|
15455
|
+
if (!(await loadVditorResources())) return;
|
|
14540
15456
|
await discardPendingCharacterAttachments();
|
|
14541
15457
|
destroyVditorEditor(characterSectionVditor);
|
|
14542
15458
|
characterSectionVditor = null;
|
|
@@ -14717,6 +15633,30 @@ function renderCharacterAvatar(item) {
|
|
|
14717
15633
|
}
|
|
14718
15634
|
}
|
|
14719
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
|
+
|
|
14720
15660
|
function renderCharacterEditorFields(item) {
|
|
14721
15661
|
const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
|
|
14722
15662
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
@@ -14765,7 +15705,12 @@ function renderCharacterEditorFields(item) {
|
|
|
14765
15705
|
'<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
|
|
14766
15706
|
field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
|
|
14767
15707
|
characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
|
|
14768
|
-
'<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() : "")
|
|
14769
15714
|
].join("");
|
|
14770
15715
|
const name = $("#character-editor-fields [name='name']");
|
|
14771
15716
|
if (name) name.required = true;
|
|
@@ -14773,6 +15718,7 @@ function renderCharacterEditorFields(item) {
|
|
|
14773
15718
|
renderCharacterAvatar(item);
|
|
14774
15719
|
renderCharacterEditorRelationships();
|
|
14775
15720
|
renderCharacterMarkdownSections();
|
|
15721
|
+
bindRoleplayMemorySurface(item);
|
|
14776
15722
|
activateCharacterEditorTab("basic");
|
|
14777
15723
|
}
|
|
14778
15724
|
|
|
@@ -14941,6 +15887,10 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14941
15887
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
14942
15888
|
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
14943
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
|
+
}
|
|
14944
15894
|
}
|
|
14945
15895
|
$("#character-change-note").readOnly = viewOnly;
|
|
14946
15896
|
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
@@ -14967,6 +15917,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14967
15917
|
const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
|
|
14968
15918
|
relationshipTab.disabled = !item || !canReadModule("relationships");
|
|
14969
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 ? "查看和管理该角色的共享角色扮演记忆" : "创建人物档案后即可管理角色扮演记忆";
|
|
14970
15923
|
const form = $("#character-editor-form");
|
|
14971
15924
|
form.onsubmit = async (event) => {
|
|
14972
15925
|
event.preventDefault();
|
|
@@ -16264,6 +17217,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16264
17217
|
const tab = activeAiChatTab();
|
|
16265
17218
|
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
16266
17219
|
if (aiRequestManager.hasActive(tab.id)) return;
|
|
17220
|
+
if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
|
|
16267
17221
|
const composerSnapshot = captureAiPromptComposer();
|
|
16268
17222
|
const requestComposerSnapshot = retry
|
|
16269
17223
|
? {
|
|
@@ -16745,6 +17699,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16745
17699
|
toolCalls.push(toolCall);
|
|
16746
17700
|
processSteps.push(aiToolProcessStep(toolCall, round));
|
|
16747
17701
|
renderStreamingProcessSteps(false, elapsedProcessTime());
|
|
17702
|
+
handleInteractiveToolCallEvent(toolCall);
|
|
16748
17703
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
16749
17704
|
scrollAiFeedToBottom(feed);
|
|
16750
17705
|
} else if (eventName === "context_compacted") {
|
|
@@ -17004,6 +17959,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null, option
|
|
|
17004
17959
|
try {
|
|
17005
17960
|
const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
|
|
17006
17961
|
state.chapter = result.chapter;
|
|
17962
|
+
resetChapterDraftLineIds(state.chapter);
|
|
17007
17963
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
17008
17964
|
$("#chapter-content").value = state.chapter.content;
|
|
17009
17965
|
scheduleChapterLineNumbers();
|
|
@@ -17097,6 +18053,7 @@ async function showVersions() {
|
|
|
17097
18053
|
}
|
|
17098
18054
|
try {
|
|
17099
18055
|
state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
|
|
18056
|
+
resetChapterDraftLineIds(state.chapter);
|
|
17100
18057
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
17101
18058
|
$("#chapter-title").value = state.chapter.title;
|
|
17102
18059
|
$("#chapter-content").value = state.chapter.content;
|
|
@@ -18454,10 +19411,7 @@ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
|
|
|
18454
19411
|
renderChapterForeshadowReminder();
|
|
18455
19412
|
$("#chapter-foreshadow-reminder-details-button").focus();
|
|
18456
19413
|
});
|
|
18457
|
-
$("#chapter-
|
|
18458
|
-
if (state.chapter) void deleteChapter(state.chapter.id);
|
|
18459
|
-
});
|
|
18460
|
-
$("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
|
|
19414
|
+
$("#chapter-edit-button").addEventListener("click", toggleChapterEditPreviewMode);
|
|
18461
19415
|
$("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
|
|
18462
19416
|
$("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
18463
19417
|
$("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
|
|
@@ -18570,12 +19524,59 @@ $("#appearance-form").addEventListener("submit", (event) => {
|
|
|
18570
19524
|
toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
|
|
18571
19525
|
});
|
|
18572
19526
|
$("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
|
|
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
|
+
});
|
|
18573
19565
|
$("#chapter-content").addEventListener("input", (event) => {
|
|
18574
|
-
|
|
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;
|
|
18575
19575
|
updateChapterStats();
|
|
18576
19576
|
scheduleChapterAutoSave();
|
|
18577
19577
|
clearChapterLineSelection();
|
|
18578
19578
|
scheduleChapterLineNumbers(chapterLineInputRenderDelay);
|
|
19579
|
+
scheduleChapterCaretScroll();
|
|
18579
19580
|
setAiContextMeter(null);
|
|
18580
19581
|
});
|
|
18581
19582
|
$("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
|
|
@@ -19298,6 +20299,109 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
|
|
|
19298
20299
|
if (label) label.textContent = "导出 Markdown";
|
|
19299
20300
|
}
|
|
19300
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
|
+
});
|
|
19301
20405
|
$("#ai-prompt").addEventListener("keydown", (event) => {
|
|
19302
20406
|
const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
|
|
19303
20407
|
if (mentionMenuVisible) {
|
|
@@ -19392,9 +20496,6 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
|
|
|
19392
20496
|
$("#reader-open-button").addEventListener("click", () => {
|
|
19393
20497
|
void openReadingPreview({ restorePosition: true });
|
|
19394
20498
|
});
|
|
19395
|
-
$("#chapter-reader-button").addEventListener("click", () => {
|
|
19396
|
-
void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
|
|
19397
|
-
});
|
|
19398
20499
|
$("#reader-close").addEventListener("click", closeReadingPreview);
|
|
19399
20500
|
$("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
|
|
19400
20501
|
$("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));
|