@musnows/scriverse 0.9.4 → 0.9.6
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-protocol.js +6 -0
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-skills.js +134 -0
- package/dist/ai-skills.js.map +1 -0
- package/dist/ai-write-plans.js +2279 -0
- package/dist/ai-write-plans.js.map +1 -0
- package/dist/ai.js +2781 -154
- package/dist/ai.js.map +1 -1
- package/dist/app.js +328 -17
- 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/chapter-title-numbering.js +56 -0
- package/dist/chapter-title-numbering.js.map +1 -0
- package/dist/database.js +478 -3
- package/dist/database.js.map +1 -1
- package/dist/public/ai-context-meter.js +3 -3
- package/dist/public/ai-interactive.js +483 -0
- package/dist/public/app.js +1991 -268
- package/dist/public/chapter-editor-behavior.js +40 -0
- package/dist/public/chapter-line-id-tracker.d.ts +25 -0
- package/dist/public/chapter-line-id-tracker.js +151 -0
- package/dist/public/index.html +79 -19
- package/dist/public/model-config.d.ts +1 -0
- package/dist/public/model-config.js +9 -4
- package/dist/public/styles.css +337 -57
- package/dist/public/theme-init.js +25 -1
- package/dist/remote-mcp.js +496 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/roleplay-memory.js +53 -0
- package/dist/roleplay-memory.js.map +1 -0
- package/dist/security.js +4 -0
- package/dist/security.js.map +1 -1
- package/dist/semantic-search.js +225 -0
- package/dist/semantic-search.js.map +1 -0
- package/dist/skills/continue-writing/SKILL.md +23 -0
- package/dist/skills/polish-writing/SKILL.md +23 -0
- package/dist/store.js +695 -51
- 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 +33 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
package/dist/public/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.j
|
|
|
15
15
|
import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
|
|
16
16
|
import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
17
17
|
import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
|
|
18
|
+
import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
|
|
18
19
|
import {
|
|
19
20
|
FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
|
|
20
21
|
foreshadowReminderRequestTargetsState,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
visibleForeshadowReminders
|
|
25
26
|
} from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
|
|
26
27
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
27
|
-
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1";
|
|
28
|
+
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1&feature=semantic-search-v6";
|
|
28
29
|
import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
|
|
29
30
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
30
31
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
|
|
@@ -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, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-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";
|
|
@@ -167,12 +180,16 @@ function normalizePageSizes(value) {
|
|
|
167
180
|
]));
|
|
168
181
|
}
|
|
169
182
|
|
|
170
|
-
function
|
|
183
|
+
function isAvailableConfiguredModel(model) {
|
|
171
184
|
return Boolean(model?.enabled)
|
|
172
185
|
&& model?.providerStatus === "enabled"
|
|
173
186
|
&& model?.providerConnectionStatus === "success";
|
|
174
187
|
}
|
|
175
188
|
|
|
189
|
+
function isSelectableModel(model) {
|
|
190
|
+
return (model?.modelKind ?? "chat") === "chat" && isAvailableConfiguredModel(model);
|
|
191
|
+
}
|
|
192
|
+
|
|
176
193
|
const state = {
|
|
177
194
|
user: null,
|
|
178
195
|
csrfToken: null,
|
|
@@ -188,6 +205,7 @@ const state = {
|
|
|
188
205
|
aiCitations: [],
|
|
189
206
|
aiReferences: [],
|
|
190
207
|
aiImageAttachments: [],
|
|
208
|
+
aiSemanticSnapshot: null,
|
|
191
209
|
aiPromptSent: false,
|
|
192
210
|
aiTaskType: "chat",
|
|
193
211
|
aiContextScope: { type: "none" },
|
|
@@ -274,6 +292,7 @@ let readingReturnRoute = null;
|
|
|
274
292
|
let readingPreviousFocus = null;
|
|
275
293
|
let readingPositionSaveTimer = null;
|
|
276
294
|
let readingResizeFrame = null;
|
|
295
|
+
const readingVolumeDirectoryConcurrency = 4;
|
|
277
296
|
|
|
278
297
|
let timelineMultiSelectEnabled = false;
|
|
279
298
|
let timelineActiveTrackId = null;
|
|
@@ -282,6 +301,9 @@ let taskProgressRefreshTimer = null;
|
|
|
282
301
|
let taskAutoRunEditing = false;
|
|
283
302
|
let taskAutoRunEditingWorkId = null;
|
|
284
303
|
let relationshipSearchIndexRefreshTimer = null;
|
|
304
|
+
let semanticSearchIndexRefreshTimer = null;
|
|
305
|
+
let aiSemanticSearchResults = [];
|
|
306
|
+
let aiSemanticSearchQuery = "";
|
|
285
307
|
let backgroundTaskCenterTimer = null;
|
|
286
308
|
let backgroundTaskCenterRequest = 0;
|
|
287
309
|
let backgroundTaskCenterWorkId = null;
|
|
@@ -444,6 +466,10 @@ function canGlobalReplaceAny(work = state.work) {
|
|
|
444
466
|
return Boolean(work) && (canGlobalReplaceScope("prose", work) || canGlobalReplaceScope("settings", work));
|
|
445
467
|
}
|
|
446
468
|
|
|
469
|
+
function applyChapterEditorPreferences() {
|
|
470
|
+
$("#app").classList.toggle("editor-typewriter-mode", Boolean(state.work?.editorTypewriterModeEnabled));
|
|
471
|
+
}
|
|
472
|
+
|
|
447
473
|
function applyWorkAccessMode() {
|
|
448
474
|
const viewOnly = Boolean(state.work) && !canEditWork();
|
|
449
475
|
const proseReadOnly = Boolean(state.work) && !canEditProse();
|
|
@@ -456,6 +482,7 @@ function applyWorkAccessMode() {
|
|
|
456
482
|
$("#app").classList.toggle("prose-hidden-mode", proseHidden);
|
|
457
483
|
$("#app").classList.toggle("ai-hidden-mode", aiHidden);
|
|
458
484
|
document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
|
|
485
|
+
applyChapterEditorPreferences();
|
|
459
486
|
for (const item of WORK_PERMISSION_MODULES) {
|
|
460
487
|
if (!item.uiModule) continue;
|
|
461
488
|
const button = $(`#module-nav [data-module="${item.uiModule}"]`);
|
|
@@ -621,7 +648,10 @@ function assertAiRequestCurrent(request) {
|
|
|
621
648
|
}
|
|
622
649
|
|
|
623
650
|
function aiInteractionBusy() {
|
|
624
|
-
|
|
651
|
+
const activeTabId = activeAiChatTab()?.id;
|
|
652
|
+
return aiRequestManager.hasActive(activeTabId)
|
|
653
|
+
|| (activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false)
|
|
654
|
+
|| aiConversationNavigationPending !== null;
|
|
625
655
|
}
|
|
626
656
|
|
|
627
657
|
function aiSendButtonIconMarkup(stateName) {
|
|
@@ -631,12 +661,14 @@ function aiSendButtonIconMarkup(stateName) {
|
|
|
631
661
|
}
|
|
632
662
|
|
|
633
663
|
function syncAiRequestControls() {
|
|
634
|
-
const
|
|
664
|
+
const activeTabId = activeAiChatTab()?.id;
|
|
665
|
+
const sending = aiRequestManager.hasActive(activeTabId);
|
|
666
|
+
const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
|
|
635
667
|
const switching = aiConversationNavigationPending !== null;
|
|
636
668
|
const button = $("#ai-send");
|
|
637
|
-
const stateName = sending ? "stop" : switching ? "switching" : "send";
|
|
638
|
-
const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
|
|
639
|
-
button.disabled = switching;
|
|
669
|
+
const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
|
|
670
|
+
const label = sending ? "终止当前回复" : continuingQuestion ? "AI 正在根据回答继续处理" : switching ? "正在切换对话" : "发送消息";
|
|
671
|
+
button.disabled = switching || continuingQuestion;
|
|
640
672
|
button.dataset.state = stateName;
|
|
641
673
|
button.classList.toggle("is-stop", sending);
|
|
642
674
|
button.setAttribute("aria-label", label);
|
|
@@ -779,6 +811,8 @@ const aiConversationHistoryPageLimit = 20;
|
|
|
779
811
|
let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
780
812
|
let workScopedUiGeneration = 0;
|
|
781
813
|
let workSelectionRequestGeneration = 0;
|
|
814
|
+
let initialWorkDetail = null;
|
|
815
|
+
let initialReaderChapterRequest = null;
|
|
782
816
|
let chapterSelectionRequestGeneration = 0;
|
|
783
817
|
let aiConversationNavigationGeneration = 0;
|
|
784
818
|
let aiConversationNavigationPending = null;
|
|
@@ -815,7 +849,7 @@ const workspaceOnboardingSteps = [
|
|
|
815
849
|
{ selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲/伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
|
|
816
850
|
{ selector: "[data-module=\"tasks\"]", eyebrow: "AI 分析中心", title: "从这里理解整部小说", description: "运行人物、关系、世界观、设定、事件和一致性分析,并查看每次分析的结果与进度。", placement: "right" },
|
|
817
851
|
{ selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
|
|
818
|
-
{ selector: ".quick-actions button[data-
|
|
852
|
+
{ selector: ".quick-actions button[data-prompt^=\"续写\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
|
|
819
853
|
{ selector: "#ai-send", eyebrow: "AI 对话", title: "发送你的创作要求", description: "选择上下文范围与模型后发送任务。AI 结果默认只是建议,不会直接覆盖正文。", placement: "left" },
|
|
820
854
|
{ selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "供应商、显示偏好、作品成员和正文 ZIP 导出都集中在这里。", placement: "bottom" },
|
|
821
855
|
{ selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
|
|
@@ -1324,6 +1358,7 @@ function replaceCurrentChapterSearchMatch() {
|
|
|
1324
1358
|
const input = $("#chapter-content");
|
|
1325
1359
|
const replacement = $("#chapter-replace-query").value;
|
|
1326
1360
|
input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
|
|
1361
|
+
syncChapterDraftLineIds(input.value);
|
|
1327
1362
|
chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
|
|
1328
1363
|
updateChapterStats();
|
|
1329
1364
|
clearChapterLineSelection();
|
|
@@ -1343,6 +1378,7 @@ function replaceAllChapterSearchMatches() {
|
|
|
1343
1378
|
const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
|
|
1344
1379
|
if (!result.matches) return renderChapterSearchStatus();
|
|
1345
1380
|
input.value = result.content;
|
|
1381
|
+
syncChapterDraftLineIds(input.value);
|
|
1346
1382
|
chapterSearchMatchIndex = -1;
|
|
1347
1383
|
updateChapterStats();
|
|
1348
1384
|
clearChapterLineSelection();
|
|
@@ -1461,6 +1497,7 @@ let chapterLineNumberFrame = null;
|
|
|
1461
1497
|
let chapterLineNumberTimer = null;
|
|
1462
1498
|
let chapterLineLayout = null;
|
|
1463
1499
|
let chapterLineVirtualWindow = null;
|
|
1500
|
+
let chapterCaretScrollFrame = null;
|
|
1464
1501
|
let chapterLineSelection = null;
|
|
1465
1502
|
let chapterLineDrag = null;
|
|
1466
1503
|
let chapterWhitespaceVisible = true;
|
|
@@ -1468,6 +1505,8 @@ let chapterAutoSaveTimer = null;
|
|
|
1468
1505
|
let chapterSaveInFlight = null;
|
|
1469
1506
|
let chapterSaveGuardInFlight = null;
|
|
1470
1507
|
let lastSavedChapterSnapshot = null;
|
|
1508
|
+
let chapterDraftLineIdState = null;
|
|
1509
|
+
let chapterBeforeInputState = null;
|
|
1471
1510
|
let chapterSearchMatchIndex = -1;
|
|
1472
1511
|
let chapterSelectionRequestId = 0;
|
|
1473
1512
|
let chapterForeshadowReminderRequestId = 0;
|
|
@@ -1499,6 +1538,11 @@ let taskListPage = 1;
|
|
|
1499
1538
|
let draftTypeFilter = "all";
|
|
1500
1539
|
let draftBindingFilters = [];
|
|
1501
1540
|
let draftFiltersPanelOpen = false;
|
|
1541
|
+
const chapterCommentFilters = { chapterId: "", keyword: "" };
|
|
1542
|
+
let chapterCommentFiltersPanelOpen = false;
|
|
1543
|
+
let chapterCommentChapterOptions = [];
|
|
1544
|
+
let chapterCommentSearchTimer = null;
|
|
1545
|
+
let chapterCommentRenderRequestId = 0;
|
|
1502
1546
|
const moduleListPages = {
|
|
1503
1547
|
drafts: 1,
|
|
1504
1548
|
settings: 1,
|
|
@@ -1553,6 +1597,7 @@ let characterSectionEditorDirty = false;
|
|
|
1553
1597
|
let settingEditorVditor = null;
|
|
1554
1598
|
let knowledgeSectionVditor = null;
|
|
1555
1599
|
let characterSectionVditor = null;
|
|
1600
|
+
let vditorResourcesPromise = null;
|
|
1556
1601
|
const settingEditorDirtyTracker = createEditorDirtyTracker();
|
|
1557
1602
|
const characterSectionEditorDirtyTracker = createEditorDirtyTracker();
|
|
1558
1603
|
let formDialogVditors = [];
|
|
@@ -1562,17 +1607,22 @@ let moduleContentInteractionsBound = false;
|
|
|
1562
1607
|
function applyChapterEditorMode() {
|
|
1563
1608
|
const permissionBlocked = Boolean(state.work) && !canEditProse();
|
|
1564
1609
|
const viewOnly = permissionBlocked || chapterEditorReadOnly;
|
|
1610
|
+
const editButton = $("#chapter-edit-button");
|
|
1565
1611
|
$("#editor-view").classList.toggle("is-read-only", viewOnly);
|
|
1566
1612
|
$("#chapter-title").readOnly = viewOnly;
|
|
1567
1613
|
$("#chapter-content").readOnly = viewOnly;
|
|
1568
1614
|
$("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
|
|
1569
1615
|
$("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
|
|
1570
|
-
|
|
1571
|
-
|
|
1616
|
+
editButton.classList.toggle("hidden", permissionBlocked || !state.chapter);
|
|
1617
|
+
editButton.classList.toggle("primary-button", chapterEditorReadOnly);
|
|
1618
|
+
editButton.classList.toggle("ghost-button", !chapterEditorReadOnly);
|
|
1619
|
+
editButton.textContent = chapterEditorReadOnly ? "编辑" : "预览";
|
|
1620
|
+
editButton.setAttribute("aria-pressed", String(!chapterEditorReadOnly));
|
|
1621
|
+
editButton.setAttribute("aria-label", chapterEditorReadOnly ? "切换到编辑模式" : "切换到预览模式");
|
|
1572
1622
|
$("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
|
|
1573
|
-
$("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
|
|
1574
1623
|
syncChapterSearchControls();
|
|
1575
1624
|
if (viewOnly) cancelChapterAutoSave();
|
|
1625
|
+
syncMobileAiPanelSafeTop();
|
|
1576
1626
|
}
|
|
1577
1627
|
|
|
1578
1628
|
function enterChapterEditMode() {
|
|
@@ -1585,6 +1635,19 @@ function enterChapterEditMode() {
|
|
|
1585
1635
|
$("#chapter-content").focus();
|
|
1586
1636
|
}
|
|
1587
1637
|
|
|
1638
|
+
function toggleChapterEditPreviewMode() {
|
|
1639
|
+
if (!state.chapter || !canEditProse()) return;
|
|
1640
|
+
if (chapterEditorReadOnly) {
|
|
1641
|
+
enterChapterEditMode();
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
chapterEditorReadOnly = true;
|
|
1645
|
+
cancelChapterAutoSave();
|
|
1646
|
+
applyChapterEditorMode();
|
|
1647
|
+
setSaveState(state.dirty ? "预览中 · 有未保存修改" : "预览中", state.dirty);
|
|
1648
|
+
$("#chapter-edit-button").focus();
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1588
1651
|
function showEntityEditorPage(type, { readOnly = false } = {}) {
|
|
1589
1652
|
const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
|
|
1590
1653
|
const viewOnly = readOnly || !canEditModule(module);
|
|
@@ -1937,6 +2000,37 @@ function scheduleChapterLineNumbers(delay = 0) {
|
|
|
1937
2000
|
}, wait);
|
|
1938
2001
|
}
|
|
1939
2002
|
|
|
2003
|
+
function scheduleChapterCaretScroll() {
|
|
2004
|
+
if (!state.work?.editorTypewriterModeEnabled || chapterCaretScrollFrame !== null) return;
|
|
2005
|
+
chapterCaretScrollFrame = requestAnimationFrame(() => {
|
|
2006
|
+
chapterCaretScrollFrame = null;
|
|
2007
|
+
const input = $("#chapter-content");
|
|
2008
|
+
const measure = $("#chapter-line-measure");
|
|
2009
|
+
if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
|
|
2010
|
+
const style = getComputedStyle(input);
|
|
2011
|
+
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
2012
|
+
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
2013
|
+
const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
|
2014
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
|
|
2015
|
+
const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
|
|
2016
|
+
const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
|
|
2017
|
+
const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
|
|
2018
|
+
const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
|
|
2019
|
+
const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
|
|
2020
|
+
const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
|
|
2021
|
+
const nextScrollTop = calculateChapterCaretScroll({
|
|
2022
|
+
caretBottom,
|
|
2023
|
+
scrollTop: input.scrollTop,
|
|
2024
|
+
clientHeight: input.clientHeight,
|
|
2025
|
+
scrollHeight: input.scrollHeight
|
|
2026
|
+
});
|
|
2027
|
+
if (nextScrollTop === input.scrollTop) return;
|
|
2028
|
+
input.scrollTop = nextScrollTop;
|
|
2029
|
+
syncChapterLineNumberScroll();
|
|
2030
|
+
scheduleChapterLineNumbers();
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1940
2034
|
function lineIndexAtPointer(clientY) {
|
|
1941
2035
|
const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
|
|
1942
2036
|
if (!rows.length) return 0;
|
|
@@ -2010,6 +2104,148 @@ function renderAiCitations() {
|
|
|
2010
2104
|
setAiContextMeter(null);
|
|
2011
2105
|
}
|
|
2012
2106
|
|
|
2107
|
+
function setAiSemanticSearchVisible(visible) {
|
|
2108
|
+
const panel = $("#ai-semantic-search-panel");
|
|
2109
|
+
panel.classList.toggle("hidden", !visible);
|
|
2110
|
+
$("#ai-semantic-search-toggle").setAttribute("aria-expanded", String(visible));
|
|
2111
|
+
if (visible) $("#ai-semantic-query").focus();
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function semanticSearchScopeTypes(value) {
|
|
2115
|
+
return ({
|
|
2116
|
+
chapter: ["chapter"],
|
|
2117
|
+
setting: ["setting"],
|
|
2118
|
+
character: ["character"],
|
|
2119
|
+
race: ["race"],
|
|
2120
|
+
organization: ["organization"],
|
|
2121
|
+
timeline: ["timeline-track", "timeline-event"],
|
|
2122
|
+
relationship: ["relationship"],
|
|
2123
|
+
outlines: ["chapter-outline", "foreshadow"]
|
|
2124
|
+
})[value] ?? undefined;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function syncAiSemanticSelectionSummary() {
|
|
2128
|
+
const selected = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')];
|
|
2129
|
+
const tokenCount = selected.reduce((total, input) => total + Number(input.dataset.estimatedTokens || 0), 0);
|
|
2130
|
+
$("#ai-semantic-selection-summary").textContent = selected.length
|
|
2131
|
+
? `已选择 ${selected.length} 项,预计 ${tokenCount.toLocaleString("zh-CN")} Token`
|
|
2132
|
+
: "尚未选择结果";
|
|
2133
|
+
$("#ai-semantic-inject").disabled = selected.length === 0 || !state.work || !canWritePermissionModule(state.work, "ai-chat");
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function renderAiSemanticSearchResults(search) {
|
|
2137
|
+
aiSemanticSearchResults = Array.isArray(search?.results) ? search.results : [];
|
|
2138
|
+
const host = $("#ai-semantic-search-results");
|
|
2139
|
+
const matchLabels = { metadata: "资料", exact: "精确", phonetic: "拼音", semantic: "语义" };
|
|
2140
|
+
host.innerHTML = aiSemanticSearchResults.length
|
|
2141
|
+
? aiSemanticSearchResults.map((item, index) => {
|
|
2142
|
+
const lineRange = Number.isInteger(item.startLine)
|
|
2143
|
+
? `<span>${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
|
|
2144
|
+
: "";
|
|
2145
|
+
const matchKinds = (Array.isArray(item.matchKinds) ? item.matchKinds : [])
|
|
2146
|
+
.map((kind) => `<span class="search-result-chip search-result-chip-${esc(kind)}">${esc(matchLabels[kind] ?? kind)}</span>`).join("");
|
|
2147
|
+
const relevance = typeof item.semanticScore === "number" ? `<span>相似度 ${Math.round(item.semanticScore * 1000) / 10}%</span>` : "";
|
|
2148
|
+
const rerank = typeof item.rerankScore === "number" ? `<span>Rerank ${item.rerankScore > 0 ? "相关" : "不相关"}</span>` : "";
|
|
2149
|
+
return `<article class="ai-semantic-result" data-semantic-result-index="${index}"><label>${item.entryId ? `<input type="checkbox" data-semantic-entry-id="${esc(item.entryId)}" data-estimated-tokens="${esc(String(item.estimatedTokens ?? 0))}">` : '<span class="ai-semantic-view-only">仅查看</span>'}<span class="ai-semantic-result-copy"><strong>${esc(searchResultTypeLabel(item.type))} · ${esc(item.title)}</strong><small>${lineRange}${matchKinds}${relevance}${rerank}<span>预计 ${esc(String(item.estimatedTokens ?? 0))} Token</span></small><span>${esc(item.snippet || "无原文片段")}</span></span></label><button type="button" data-semantic-locate="${index}">定位来源</button></article>`;
|
|
2150
|
+
}).join("")
|
|
2151
|
+
: '<p class="ai-semantic-empty">没有找到可展示的结果。</p>';
|
|
2152
|
+
host.querySelectorAll('input[data-semantic-entry-id]').forEach((input) => input.addEventListener("change", syncAiSemanticSelectionSummary));
|
|
2153
|
+
host.querySelectorAll("[data-semantic-locate]").forEach((button) => button.addEventListener("click", async () => {
|
|
2154
|
+
const result = aiSemanticSearchResults[Number(button.dataset.semanticLocate)];
|
|
2155
|
+
if (!result) return;
|
|
2156
|
+
try {
|
|
2157
|
+
setAiSemanticSearchVisible(false);
|
|
2158
|
+
await openSearchResult(result);
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
toast(error.message, "error");
|
|
2161
|
+
}
|
|
2162
|
+
}));
|
|
2163
|
+
syncAiSemanticSelectionSummary();
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function renderAiSemanticInjection() {
|
|
2167
|
+
const host = $("#ai-semantic-injection");
|
|
2168
|
+
const snapshot = state.aiSemanticSnapshot;
|
|
2169
|
+
host.classList.toggle("hidden", !snapshot);
|
|
2170
|
+
if (!snapshot) {
|
|
2171
|
+
host.replaceChildren();
|
|
2172
|
+
setAiContextMeter(null);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
host.innerHTML = `<div><strong>本轮语义快照</strong><span>${esc(String(snapshot.itemCount ?? snapshot.items?.length ?? 0))} 项 · 预计 ${Number(snapshot.estimatedTokens ?? 0).toLocaleString("zh-CN")} Token</span><small>${esc(snapshot.query ?? "")}</small></div><button type="button" aria-label="移除本轮语义快照">×</button>`;
|
|
2176
|
+
host.querySelector("button")?.addEventListener("click", () => {
|
|
2177
|
+
state.aiSemanticSnapshot = null;
|
|
2178
|
+
renderAiSemanticInjection();
|
|
2179
|
+
persistActiveAiChatTab();
|
|
2180
|
+
});
|
|
2181
|
+
setAiContextMeter(null);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
async function runAiSemanticSearch() {
|
|
2185
|
+
if (!state.work) return toast("请先选择作品", "error");
|
|
2186
|
+
const query = $("#ai-semantic-query").value.trim();
|
|
2187
|
+
if (!query) return toast("请输入自然语言检索问题", "error");
|
|
2188
|
+
const button = $("#ai-semantic-search-run");
|
|
2189
|
+
button.disabled = true;
|
|
2190
|
+
$("#ai-semantic-search-status").textContent = "正在执行主动语义检索……";
|
|
2191
|
+
try {
|
|
2192
|
+
const selection = state.chapter
|
|
2193
|
+
? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd).slice(0, 4_000)
|
|
2194
|
+
: "";
|
|
2195
|
+
aiSemanticSearchQuery = query;
|
|
2196
|
+
const search = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search`, {
|
|
2197
|
+
method: "POST",
|
|
2198
|
+
body: {
|
|
2199
|
+
query,
|
|
2200
|
+
types: semanticSearchScopeTypes($("#ai-semantic-scope").value),
|
|
2201
|
+
currentChapterId: state.chapter?.id,
|
|
2202
|
+
selection: selection || undefined,
|
|
2203
|
+
includeKeyword: true
|
|
2204
|
+
}
|
|
2205
|
+
});
|
|
2206
|
+
renderAiSemanticSearchResults(search);
|
|
2207
|
+
$("#ai-semantic-search-status").textContent = search.semanticUsed
|
|
2208
|
+
? `${search.results.length} 项融合结果${search.degraded ? `;${search.reason}` : ";已标注 semantic 通道"}`
|
|
2209
|
+
: `${search.results.length} 项关键词降级结果;${search.reason}`;
|
|
2210
|
+
} catch (error) {
|
|
2211
|
+
aiSemanticSearchResults = [];
|
|
2212
|
+
renderAiSemanticSearchResults({ results: [] });
|
|
2213
|
+
$("#ai-semantic-search-status").textContent = error.message;
|
|
2214
|
+
toast(error.message, "error");
|
|
2215
|
+
} finally {
|
|
2216
|
+
button.disabled = false;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function injectAiSemanticSelection() {
|
|
2221
|
+
if (!state.work) return;
|
|
2222
|
+
const entryIds = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')]
|
|
2223
|
+
.map((input) => input.dataset.semanticEntryId)
|
|
2224
|
+
.filter(Boolean);
|
|
2225
|
+
if (!entryIds.length) return;
|
|
2226
|
+
const button = $("#ai-semantic-inject");
|
|
2227
|
+
button.disabled = true;
|
|
2228
|
+
try {
|
|
2229
|
+
const snapshot = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search/snapshots`, {
|
|
2230
|
+
method: "POST",
|
|
2231
|
+
body: {
|
|
2232
|
+
query: aiSemanticSearchQuery,
|
|
2233
|
+
entryIds,
|
|
2234
|
+
scope: { types: semanticSearchScopeTypes($("#ai-semantic-scope").value) ?? [] },
|
|
2235
|
+
conversationId: state.aiConversationId || undefined
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
state.aiSemanticSnapshot = snapshot;
|
|
2239
|
+
renderAiSemanticInjection();
|
|
2240
|
+
persistActiveAiChatTab();
|
|
2241
|
+
setAiSemanticSearchVisible(false);
|
|
2242
|
+
toast(`已将 ${snapshot.itemCount} 项语义原文加入本轮上下文`);
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
toast(error.message, "error");
|
|
2245
|
+
button.disabled = false;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2013
2249
|
function aiReferenceKey(reference) {
|
|
2014
2250
|
return `${reference.kind}:${reference.id}`;
|
|
2015
2251
|
}
|
|
@@ -2091,7 +2327,7 @@ function createAiChatTabState(input = {}) {
|
|
|
2091
2327
|
roleplayUserCharacter: input.roleplayUserCharacter ?? null,
|
|
2092
2328
|
citations: input.citations ?? [],
|
|
2093
2329
|
references: input.references ?? [],
|
|
2094
|
-
composer: input.composer ?? { text: "", citations: [], references: [], images: [], sceneDirection: "", scenePin: emptyRoleplayScenePin() },
|
|
2330
|
+
composer: input.composer ?? { text: "", citations: [], references: [], images: [], semanticSnapshot: null, sceneDirection: "", scenePin: emptyRoleplayScenePin() },
|
|
2095
2331
|
contextUsage: input.contextUsage ?? null,
|
|
2096
2332
|
contextWarning: input.contextWarning === true,
|
|
2097
2333
|
lastMessageAt: input.lastMessageAt ?? null,
|
|
@@ -2134,6 +2370,7 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
|
|
|
2134
2370
|
citations: tab.citations.map((citation) => ({ ...citation })),
|
|
2135
2371
|
references: tab.references.map((reference) => ({ ...reference })),
|
|
2136
2372
|
images: normalizeAiChatImageAttachments(snapshot.images),
|
|
2373
|
+
semanticSnapshot: snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null,
|
|
2137
2374
|
sceneDirection: String(snapshot.sceneDirection ?? ""),
|
|
2138
2375
|
scenePin: normalizeRoleplayScenePin(snapshot.scenePin)
|
|
2139
2376
|
};
|
|
@@ -2145,6 +2382,7 @@ function clearAiChatTabComposer(tab) {
|
|
|
2145
2382
|
citations: [],
|
|
2146
2383
|
references: [],
|
|
2147
2384
|
images: [],
|
|
2385
|
+
semanticSnapshot: null,
|
|
2148
2386
|
sceneDirection: "",
|
|
2149
2387
|
scenePin: normalizeRoleplayScenePin(tab.composer?.scenePin)
|
|
2150
2388
|
});
|
|
@@ -2158,6 +2396,7 @@ function applyAiChatTabState(tab) {
|
|
|
2158
2396
|
state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
|
|
2159
2397
|
state.aiReferences = tab.references.map((reference) => ({ ...reference }));
|
|
2160
2398
|
state.aiImageAttachments = normalizeAiChatImageAttachments(tab.composer?.images);
|
|
2399
|
+
state.aiSemanticSnapshot = tab.composer?.semanticSnapshot ? structuredClone(tab.composer.semanticSnapshot) : null;
|
|
2161
2400
|
state.aiLastMessageAt = tab.lastMessageAt;
|
|
2162
2401
|
$("#ai-conversation-title").textContent = tab.title || "新对话";
|
|
2163
2402
|
applyAiConversationTaskType(tab.taskType);
|
|
@@ -2169,6 +2408,7 @@ function applyAiChatTabState(tab) {
|
|
|
2169
2408
|
setAiPromptText(tab.composer.text);
|
|
2170
2409
|
restoreAiSceneComposer(tab.composer);
|
|
2171
2410
|
renderAiCitations();
|
|
2411
|
+
renderAiSemanticInjection();
|
|
2172
2412
|
renderAiReferences();
|
|
2173
2413
|
renderAiImageAttachments();
|
|
2174
2414
|
latestAiContextUsage = null;
|
|
@@ -2465,7 +2705,7 @@ function resetAiFeed(
|
|
|
2465
2705
|
const roleplayUserName = roleplayUserCharacter?.name;
|
|
2466
2706
|
feed.innerHTML = roleplayName
|
|
2467
2707
|
? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我可以通过角色卡、人物关系、知情设定和故事正文回答。"}</p></div></div>`
|
|
2468
|
-
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p
|
|
2708
|
+
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后即可开始问答;提到续写或润色时会自动加载对应 Skill,也可用 /continue-writing 或 /polish-writing 强制加载。所有引用都基于已保存正文。</p></div></div>';
|
|
2469
2709
|
}
|
|
2470
2710
|
|
|
2471
2711
|
function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
|
|
@@ -2696,6 +2936,8 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
2696
2936
|
recall_other: "回忆相识角色",
|
|
2697
2937
|
recall_known: "回忆知情设定",
|
|
2698
2938
|
recall_story: "回忆故事",
|
|
2939
|
+
recall_roleplay_memory: "回忆当前扮演线",
|
|
2940
|
+
remember_roleplay: "整理扮演记忆",
|
|
2699
2941
|
calculate_time: "计算日期"
|
|
2700
2942
|
};
|
|
2701
2943
|
|
|
@@ -2712,6 +2954,8 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
2712
2954
|
recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
|
|
2713
2955
|
recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
|
|
2714
2956
|
recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
|
|
2957
|
+
recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
|
|
2958
|
+
remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
|
|
2715
2959
|
calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
|
|
2716
2960
|
};
|
|
2717
2961
|
|
|
@@ -2875,6 +3119,10 @@ function openAiToolCallDetail(toolCall) {
|
|
|
2875
3119
|
|
|
2876
3120
|
function createAiToolCallButton(toolCall) {
|
|
2877
3121
|
const name = String(toolCall?.name ?? "unknown");
|
|
3122
|
+
if (name === "propose_write_plan" || name === "ask_user_question") {
|
|
3123
|
+
const card = createInteractiveToolCard(toolCall, AI_TOOL_CARD_ACTIONS);
|
|
3124
|
+
if (card) return card;
|
|
3125
|
+
}
|
|
2878
3126
|
const button = document.createElement("button");
|
|
2879
3127
|
button.type = "button";
|
|
2880
3128
|
button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
|
|
@@ -2885,6 +3133,392 @@ function createAiToolCallButton(toolCall) {
|
|
|
2885
3133
|
return button;
|
|
2886
3134
|
}
|
|
2887
3135
|
|
|
3136
|
+
// ---------------------------------------------------------------------------
|
|
3137
|
+
// AI 可写工具:审批卡片动作、修改计划详情、撤销与用户提问
|
|
3138
|
+
// ---------------------------------------------------------------------------
|
|
3139
|
+
|
|
3140
|
+
let aiWritePlanDialogPlanId = null;
|
|
3141
|
+
let aiWritePlanDialogBusy = false;
|
|
3142
|
+
let currentPlanDialogDetail = null;
|
|
3143
|
+
let currentPlanDialogFocusConfirm = false;
|
|
3144
|
+
let aiQuestionDialogQuestionId = null;
|
|
3145
|
+
let currentAiQuestionDialogView = null;
|
|
3146
|
+
let aiQuestionDialogBusy = false;
|
|
3147
|
+
const aiQuestionContinuationTabIds = new Set();
|
|
3148
|
+
let autoOpenedQuestionIds = new Set();
|
|
3149
|
+
const aiApprovalCenterState = { status: "" };
|
|
3150
|
+
|
|
3151
|
+
/** 消息流中交互工具卡片绑定的动作:全部先取服务端最新状态,再弹窗确认。 */
|
|
3152
|
+
const AI_TOOL_CARD_ACTIONS = {
|
|
3153
|
+
openPlanDetail(planId) {
|
|
3154
|
+
openAiWritePlanDetail(planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
|
|
3155
|
+
},
|
|
3156
|
+
confirmPlan(planId) {
|
|
3157
|
+
openAiWritePlanDetail(planId, { focusConfirm: true }).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
|
|
3158
|
+
},
|
|
3159
|
+
rejectPlan(planId) {
|
|
3160
|
+
decideAiWritePlan(planId, "reject").catch((error) => toast(`拒绝失败:${error.message}`, "error"));
|
|
3161
|
+
},
|
|
3162
|
+
openQuestionDialog(questionId) {
|
|
3163
|
+
openAiUserQuestionDialog(questionId).catch((error) => toast(`提问加载失败:${error.message}`, "error"));
|
|
3164
|
+
},
|
|
3165
|
+
rejectQuestion(questionId) {
|
|
3166
|
+
respondAiUserQuestion(questionId, { action: "reject" }).catch((error) => toast(`操作失败:${error.message}`, "error"));
|
|
3167
|
+
},
|
|
3168
|
+
openApprovalCenter() {
|
|
3169
|
+
openAiApprovalCenter();
|
|
3170
|
+
}
|
|
3171
|
+
};
|
|
3172
|
+
|
|
3173
|
+
/** 流式收到交互式可写工具调用时的提示与自动弹出提问。 */
|
|
3174
|
+
function handleInteractiveToolCallEvent(toolCall) {
|
|
3175
|
+
const name = String(toolCall?.name ?? "");
|
|
3176
|
+
if (name === "propose_write_plan") {
|
|
3177
|
+
if (toolCall.status === "failed") {
|
|
3178
|
+
const message = String(parseInteractiveToolPayload(toolCall)?.error?.message ?? "未知错误");
|
|
3179
|
+
toast(`AI 的写入审批提交失败:${message}`, "error");
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
const payload = parseInteractiveToolPayload(toolCall);
|
|
3183
|
+
const targets = Array.isArray(payload?.plan?.targets) ? payload.plan.targets.join("、") : "待确认操作";
|
|
3184
|
+
const summary = String(payload?.plan?.aiSummary ?? "");
|
|
3185
|
+
toast(`AI 提交了写入审批:${targets}${summary ? ` · ${summary}` : ""}`);
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
if (name !== "ask_user_question" || toolCall.status === "failed") return;
|
|
3189
|
+
const question = toolCall.result?.question;
|
|
3190
|
+
if (!question?.id) return;
|
|
3191
|
+
cacheAiQuestionView(question);
|
|
3192
|
+
const questionId = String(question.id);
|
|
3193
|
+
if (autoOpenedQuestionIds.has(questionId)) return;
|
|
3194
|
+
autoOpenedQuestionIds.add(questionId);
|
|
3195
|
+
// 直接弹出回答框等待作者选择;不会预填任何答案。
|
|
3196
|
+
openAiUserQuestionDialog(questionId).catch(() => undefined);
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
function questionsEndpoint(path) {
|
|
3200
|
+
return `/api/works/${state.work.id}/ai/questions${path}`;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function plansEndpoint(path) {
|
|
3204
|
+
return `/api/works/${state.work.id}/ai/write-plans${path}`;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
async function fetchAiWritePlanDetail(planId) {
|
|
3208
|
+
const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}`));
|
|
3209
|
+
cacheAiWritePlanDetail(detail);
|
|
3210
|
+
return detail;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
async function decideAiWritePlan(planId, action) {
|
|
3214
|
+
const buttonLabel = action === "confirm" ? "确认执行" : "拒绝";
|
|
3215
|
+
const targetButton = $(action === "confirm" ? "#ai-write-plan-confirm" : "#ai-write-plan-reject");
|
|
3216
|
+
if (targetButton?.disabled || aiWritePlanDialogBusy) return;
|
|
3217
|
+
if (targetButton) targetButton.disabled = true;
|
|
3218
|
+
aiWritePlanDialogBusy = true;
|
|
3219
|
+
try {
|
|
3220
|
+
const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/${action}`), { method: "POST" });
|
|
3221
|
+
cacheAiWritePlanDetail(detail);
|
|
3222
|
+
toast(action === "confirm"
|
|
3223
|
+
? `已执行 AI 写入审批:${detail.aiSummary}`
|
|
3224
|
+
: `已拒绝该写入审批,未产生任何写入`);
|
|
3225
|
+
if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(detail.id)) {
|
|
3226
|
+
applyPlanDetailToDialog(detail);
|
|
3227
|
+
}
|
|
3228
|
+
if ($("#ai-approval-center-dialog").open) loadAiApprovalCenterPlans().catch(() => undefined);
|
|
3229
|
+
return detail;
|
|
3230
|
+
} catch (error) {
|
|
3231
|
+
// 已被处理(409)等冲突也需要刷新展示的明细状态。
|
|
3232
|
+
try {
|
|
3233
|
+
const fresh = await fetchAiWritePlanDetail(planId);
|
|
3234
|
+
if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(planId)) applyPlanDetailToDialog(fresh);
|
|
3235
|
+
else if (targetButton) targetButton.disabled = false;
|
|
3236
|
+
} catch {
|
|
3237
|
+
if (targetButton) targetButton.disabled = false;
|
|
3238
|
+
}
|
|
3239
|
+
throw new Error(`${buttonLabel}失败:${error.message}`);
|
|
3240
|
+
} finally {
|
|
3241
|
+
aiWritePlanDialogBusy = false;
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
async function undoAiWritePlan(planId) {
|
|
3246
|
+
if (aiWritePlanDialogBusy) return;
|
|
3247
|
+
const button = $("#ai-write-plan-undo");
|
|
3248
|
+
button.disabled = true;
|
|
3249
|
+
try {
|
|
3250
|
+
const undoPlan = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/undo`), { method: "POST" });
|
|
3251
|
+
cacheAiWritePlanDetail(undoPlan);
|
|
3252
|
+
toast("已创建撤销审批,请在新弹窗中单独确认执行");
|
|
3253
|
+
await openAiWritePlanDetail(undoPlan.id);
|
|
3254
|
+
} catch (error) {
|
|
3255
|
+
toast(`创建撤销审批失败:${error.message}`, "error");
|
|
3256
|
+
button.disabled = false;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
function applyPlanDetailToDialog(detail) {
|
|
3261
|
+
currentPlanDialogDetail = detail;
|
|
3262
|
+
aiWritePlanDialogPlanId = String(detail.id);
|
|
3263
|
+
$("#ai-write-plan-body").innerHTML = renderWritePlanDetailMarkup(detail);
|
|
3264
|
+
const isPending = detail.status === "pending";
|
|
3265
|
+
const canUndo = Boolean(detail.undoAvailable) && detail.status === "executed";
|
|
3266
|
+
$("#ai-write-plan-confirm").classList.toggle("hidden", !isPending);
|
|
3267
|
+
$("#ai-write-plan-reject").classList.toggle("hidden", !isPending);
|
|
3268
|
+
$("#ai-write-plan-undo").classList.toggle("hidden", !canUndo);
|
|
3269
|
+
$("#ai-write-plan-confirm").disabled = false;
|
|
3270
|
+
$("#ai-write-plan-reject").disabled = false;
|
|
3271
|
+
$("#ai-write-plan-undo").disabled = false;
|
|
3272
|
+
if (isPending && currentPlanDialogFocusConfirm) {
|
|
3273
|
+
$("#ai-write-plan-dialog").querySelector(".card-actions")?.scrollIntoView({ block: "end" });
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
async function openAiWritePlanDetail(planId, options = {}) {
|
|
3278
|
+
currentPlanDialogFocusConfirm = options.focusConfirm === true;
|
|
3279
|
+
const dialog = $("#ai-write-plan-dialog");
|
|
3280
|
+
if (!dialog.open) dialog.showModal();
|
|
3281
|
+
$("#ai-write-plan-body").innerHTML = '<p class="usage-measurement-note">正在加载系统生成的完整修改明细……</p>';
|
|
3282
|
+
try {
|
|
3283
|
+
const detail = await fetchAiWritePlanDetail(planId);
|
|
3284
|
+
applyPlanDetailToDialog(detail);
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
$("#ai-write-plan-body").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
|
|
3287
|
+
throw error;
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
async function loadAiApprovalCenterPlans() {
|
|
3292
|
+
const statusQuery = aiApprovalCenterState.status ? `?status=${encodeURIComponent(aiApprovalCenterState.status)}&limit=50` : "?limit=50";
|
|
3293
|
+
const [planPayload, questionPayload] = await Promise.all([
|
|
3294
|
+
api(plansEndpoint(statusQuery)),
|
|
3295
|
+
api(questionsEndpoint(statusQuery))
|
|
3296
|
+
]);
|
|
3297
|
+
const plans = Array.isArray(planPayload) ? planPayload : (Array.isArray(planPayload?.plans) ? planPayload.plans : []);
|
|
3298
|
+
const questions = Array.isArray(questionPayload) ? questionPayload : (Array.isArray(questionPayload?.questions) ? questionPayload.questions : []);
|
|
3299
|
+
$("#ai-approval-list-host").innerHTML = renderApprovalCenterRows(plans, questions);
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
function openAiApprovalCenter() {
|
|
3303
|
+
const dialog = $("#ai-approval-center-dialog");
|
|
3304
|
+
if (!dialog.open) dialog.showModal();
|
|
3305
|
+
$("#ai-approval-center-toggle").setAttribute("aria-expanded", "true");
|
|
3306
|
+
$("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
|
|
3307
|
+
loadAiApprovalCenterPlans().catch((error) => {
|
|
3308
|
+
$("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
async function fetchAiUserQuestion(questionId) {
|
|
3313
|
+
const question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}`));
|
|
3314
|
+
cacheAiQuestionView(question);
|
|
3315
|
+
return question;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function syncAiQuestionOptionPresentation() {
|
|
3319
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
3320
|
+
for (const label of document.querySelectorAll(".ai-question-option-item")) {
|
|
3321
|
+
const input = label.querySelector('input[name="ai-question-choice"]');
|
|
3322
|
+
const isSelected = Boolean(input && checked === input);
|
|
3323
|
+
label.classList.toggle("is-selected", isSelected);
|
|
3324
|
+
label.classList.toggle("is-recommended", label.dataset.recommended === "true" && (!checked || isSelected));
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
function syncAiQuestionAnswerCount() {
|
|
3329
|
+
const input = $("#ai-question-custom-answer");
|
|
3330
|
+
const maximum = Number(input.maxLength) || 3000;
|
|
3331
|
+
$("#ai-question-answer-count").textContent = `已填写 ${input.value.length} / ${maximum}`;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
function renderAiUserQuestionOptions(question) {
|
|
3335
|
+
const host = $("#ai-question-options");
|
|
3336
|
+
const customInput = $("#ai-question-custom-answer");
|
|
3337
|
+
const isPending = question.status === "pending";
|
|
3338
|
+
host.replaceChildren();
|
|
3339
|
+
for (const option of question.options ?? []) {
|
|
3340
|
+
const label = document.createElement("label");
|
|
3341
|
+
label.className = "ai-question-option-item";
|
|
3342
|
+
label.dataset.recommended = String(option.recommended === true);
|
|
3343
|
+
const input = document.createElement("input");
|
|
3344
|
+
input.type = "radio";
|
|
3345
|
+
input.name = "ai-question-choice";
|
|
3346
|
+
input.value = String(option.index);
|
|
3347
|
+
input.checked = question.selectedOption === option.index;
|
|
3348
|
+
input.disabled = !isPending;
|
|
3349
|
+
const span = document.createElement("span");
|
|
3350
|
+
span.textContent = `${option.recommended ? "(最推荐)" : ""}${option.label}`;
|
|
3351
|
+
label.append(input, span);
|
|
3352
|
+
host.append(label);
|
|
3353
|
+
}
|
|
3354
|
+
const customLabel = document.createElement("label");
|
|
3355
|
+
customLabel.className = "ai-question-option-item ai-question-custom-choice";
|
|
3356
|
+
const customRadio = document.createElement("input");
|
|
3357
|
+
customRadio.type = "radio";
|
|
3358
|
+
customRadio.name = "ai-question-choice";
|
|
3359
|
+
customRadio.value = "custom";
|
|
3360
|
+
customRadio.checked = question.selectedOption == null && question.isCustomAnswer === true;
|
|
3361
|
+
customRadio.disabled = !isPending;
|
|
3362
|
+
const customText = document.createElement("span");
|
|
3363
|
+
customText.textContent = "自定义回答";
|
|
3364
|
+
customLabel.append(customRadio, customText);
|
|
3365
|
+
host.append(customLabel);
|
|
3366
|
+
customInput.value = question.customAnswer ?? (question.selectedOption == null && question.isCustomAnswer ? (question.answerText ?? "") : "");
|
|
3367
|
+
customInput.disabled = !isPending;
|
|
3368
|
+
syncAiQuestionAnswerCount();
|
|
3369
|
+
syncAiQuestionOptionPresentation();
|
|
3370
|
+
// 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
|
|
3371
|
+
if (isPending) syncAiQuestionSubmitState();
|
|
3372
|
+
else $("#ai-question-submit").disabled = true;
|
|
3373
|
+
$("#ai-question-skip").disabled = !isPending;
|
|
3374
|
+
$("#ai-question-expiry").textContent = isPending
|
|
3375
|
+
? `请选择一个预设选项,或填写自定义回答后提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
|
|
3376
|
+
: `该问题当前状态:${question.statusLabel}${question.answerText ? ` · 回答:${question.answerText}` : ""}`;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
async function refreshAiQuestionDialog() {
|
|
3380
|
+
const question = await fetchAiUserQuestion(aiQuestionDialogQuestionId);
|
|
3381
|
+
currentAiQuestionDialogView = question;
|
|
3382
|
+
$("#ai-question-text").textContent = question.question;
|
|
3383
|
+
renderAiUserQuestionOptions(question);
|
|
3384
|
+
return question;
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
async function openAiUserQuestionDialog(questionId) {
|
|
3388
|
+
aiQuestionDialogQuestionId = String(questionId);
|
|
3389
|
+
const dialog = $("#ai-question-dialog");
|
|
3390
|
+
if (!dialog.open) dialog.showModal();
|
|
3391
|
+
$("#ai-question-text").textContent = "";
|
|
3392
|
+
$("#ai-question-options").replaceChildren();
|
|
3393
|
+
$("#ai-question-custom-answer").value = "";
|
|
3394
|
+
syncAiQuestionAnswerCount();
|
|
3395
|
+
$("#ai-question-expiry").textContent = "正在加载问题……";
|
|
3396
|
+
try {
|
|
3397
|
+
return await refreshAiQuestionDialog();
|
|
3398
|
+
} catch (error) {
|
|
3399
|
+
$("#ai-question-expiry").textContent = `加载失败:${error.message}`;
|
|
3400
|
+
throw error;
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function beginAiQuestionContinuationUi(conversationId, questionId) {
|
|
3405
|
+
const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
|
|
3406
|
+
if (!tab) return null;
|
|
3407
|
+
const card = questionId
|
|
3408
|
+
? tab.feed.querySelector(`.ai-question-card[data-question-id="${CSS.escape(String(questionId))}"]`)
|
|
3409
|
+
: null;
|
|
3410
|
+
const note = card?.querySelector(".ai-interactive-note") ?? null;
|
|
3411
|
+
const status = card?.querySelector(".ai-status-chip") ?? null;
|
|
3412
|
+
const previousNote = note?.textContent ?? "";
|
|
3413
|
+
const previousStatus = status?.textContent ?? "";
|
|
3414
|
+
const controlStates = card
|
|
3415
|
+
? [...card.querySelectorAll("button")].map((button) => ({ button, disabled: button.disabled }))
|
|
3416
|
+
: [];
|
|
3417
|
+
if (card) {
|
|
3418
|
+
card.classList.add("is-resuming");
|
|
3419
|
+
card.setAttribute("aria-busy", "true");
|
|
3420
|
+
controlStates.forEach(({ button }) => { button.disabled = true; });
|
|
3421
|
+
if (status) status.textContent = "处理中";
|
|
3422
|
+
if (note) note.textContent = "回答已作为工具结果提交,正在根据你的回答继续处理…";
|
|
3423
|
+
}
|
|
3424
|
+
aiQuestionContinuationTabIds.add(tab.id);
|
|
3425
|
+
setAiChatTabStatus(tab, "streaming");
|
|
3426
|
+
if (isActiveAiChatTab(tab)) syncAiRequestControls();
|
|
3427
|
+
scrollAiFeedToBottom(tab.feed);
|
|
3428
|
+
return {
|
|
3429
|
+
tab,
|
|
3430
|
+
card,
|
|
3431
|
+
note,
|
|
3432
|
+
status,
|
|
3433
|
+
previousNote,
|
|
3434
|
+
previousStatus,
|
|
3435
|
+
controlStates
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
function finishAiQuestionContinuationUi(continuationUi, failed = false) {
|
|
3440
|
+
if (!continuationUi) return;
|
|
3441
|
+
aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
|
|
3442
|
+
if (continuationUi.card?.isConnected) {
|
|
3443
|
+
continuationUi.card.classList.remove("is-resuming");
|
|
3444
|
+
continuationUi.card.removeAttribute("aria-busy");
|
|
3445
|
+
continuationUi.controlStates.forEach(({ button, disabled }) => { button.disabled = disabled; });
|
|
3446
|
+
if (continuationUi.note) continuationUi.note.textContent = continuationUi.previousNote;
|
|
3447
|
+
if (continuationUi.status) continuationUi.status.textContent = continuationUi.previousStatus;
|
|
3448
|
+
}
|
|
3449
|
+
if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
|
|
3450
|
+
if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
async function reloadAiQuestionConversation(conversationId) {
|
|
3454
|
+
if (!conversationId) return null;
|
|
3455
|
+
const tab = aiChatTabManager.findByConversation(conversationId);
|
|
3456
|
+
if (!tab) return openAiConversation(conversationId);
|
|
3457
|
+
const conversation = await api(`/api/ai-conversations/${encodeURIComponent(conversationId)}?page=1&limit=100`);
|
|
3458
|
+
if (String(conversation.workId ?? "") !== String(state.work?.id ?? "")) throw new Error("AI 对话不属于当前作品");
|
|
3459
|
+
upsertAiConversationSummary(conversation);
|
|
3460
|
+
applyConversationToAiChatTab(tab, conversation);
|
|
3461
|
+
activateAiChatTab(tab.id, { persistCurrent: false, force: true });
|
|
3462
|
+
return conversation;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
async function respondAiUserQuestion(questionId, payload) {
|
|
3466
|
+
if (aiQuestionDialogBusy) return;
|
|
3467
|
+
aiQuestionDialogBusy = true;
|
|
3468
|
+
$("#ai-question-submit").disabled = true;
|
|
3469
|
+
$("#ai-question-skip").disabled = true;
|
|
3470
|
+
const questionDialog = $("#ai-question-dialog");
|
|
3471
|
+
const approvalCenterDialog = $("#ai-approval-center-dialog");
|
|
3472
|
+
let continuationUi = null;
|
|
3473
|
+
let continuationFailed = false;
|
|
3474
|
+
try {
|
|
3475
|
+
const knownQuestion = currentAiQuestionDialogView?.id === String(questionId)
|
|
3476
|
+
? currentAiQuestionDialogView
|
|
3477
|
+
: await fetchAiUserQuestion(questionId);
|
|
3478
|
+
const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
|
|
3479
|
+
if (questionDialog.open) questionDialog.close();
|
|
3480
|
+
if (approvalCenterDialog.open) approvalCenterDialog.close();
|
|
3481
|
+
continuationUi = beginAiQuestionContinuationUi(conversationId, questionId);
|
|
3482
|
+
let question;
|
|
3483
|
+
if (payload.action === "reject") {
|
|
3484
|
+
question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
|
|
3485
|
+
} else if (payload.action === "custom") {
|
|
3486
|
+
question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), { method: "POST", body: { customAnswer: payload.customAnswer } });
|
|
3487
|
+
} else {
|
|
3488
|
+
question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
|
|
3489
|
+
method: "POST",
|
|
3490
|
+
body: {
|
|
3491
|
+
selectedOption: payload.selectedOption,
|
|
3492
|
+
...(payload.customAnswer ? { customAnswer: payload.customAnswer } : {})
|
|
3493
|
+
}
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
cacheAiQuestionView(question);
|
|
3497
|
+
currentAiQuestionDialogView = question;
|
|
3498
|
+
aiQuestionDialogQuestionId = String(question.id);
|
|
3499
|
+
await reloadAiQuestionConversation(question.conversationId ?? conversationId);
|
|
3500
|
+
toast(payload.action === "reject" ? "已跳过该问题,AI 已继续处理" : "回答已提交,AI 已继续处理");
|
|
3501
|
+
return question;
|
|
3502
|
+
} catch (error) {
|
|
3503
|
+
continuationFailed = true;
|
|
3504
|
+
const latestQuestion = await fetchAiUserQuestion(questionId).catch(() => null);
|
|
3505
|
+
if (latestQuestion) {
|
|
3506
|
+
currentAiQuestionDialogView = latestQuestion;
|
|
3507
|
+
cacheAiQuestionView(latestQuestion);
|
|
3508
|
+
if (latestQuestion.status === "pending") {
|
|
3509
|
+
$("#ai-question-text").textContent = latestQuestion.question;
|
|
3510
|
+
renderAiUserQuestionOptions(latestQuestion);
|
|
3511
|
+
if (!questionDialog.open) questionDialog.showModal();
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
throw error;
|
|
3515
|
+
} finally {
|
|
3516
|
+
finishAiQuestionContinuationUi(continuationUi, continuationFailed);
|
|
3517
|
+
aiQuestionDialogBusy = false;
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
/** Convert a persisted tool call back into a process step for history rendering. */
|
|
2888
3522
|
function aiToolProcessStep(toolCall, round = 1) {
|
|
2889
3523
|
const normalizedToolCall = { ...toolCall };
|
|
2890
3524
|
delete normalizedToolCall.round;
|
|
@@ -2945,7 +3579,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
|
|
|
2945
3579
|
if (!renderableSteps.length) return;
|
|
2946
3580
|
const details = document.createElement("details");
|
|
2947
3581
|
details.className = "ai-process-details";
|
|
2948
|
-
|
|
3582
|
+
// 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
|
|
3583
|
+
details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
|
|
2949
3584
|
const summary = document.createElement("summary");
|
|
2950
3585
|
const title = document.createElement("span");
|
|
2951
3586
|
title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
|
|
@@ -3120,9 +3755,7 @@ async function deleteAiConversation(conversation) {
|
|
|
3120
3755
|
|
|
3121
3756
|
const aiConversationTaskTypeLabels = {
|
|
3122
3757
|
chat: "问答",
|
|
3123
|
-
roleplay: "角色扮演"
|
|
3124
|
-
continue: "续写",
|
|
3125
|
-
polish: "润色选中文本"
|
|
3758
|
+
roleplay: "角色扮演"
|
|
3126
3759
|
};
|
|
3127
3760
|
|
|
3128
3761
|
function aiConversationTaskTypeLabel(taskType) {
|
|
@@ -3209,6 +3842,266 @@ function renderAiConversationHistory() {
|
|
|
3209
3842
|
}
|
|
3210
3843
|
}
|
|
3211
3844
|
|
|
3845
|
+
const roleplayMemoryCategoryLabels = Object.freeze({
|
|
3846
|
+
event: "事件",
|
|
3847
|
+
state: "状态",
|
|
3848
|
+
relationship: "关系",
|
|
3849
|
+
commitment: "承诺",
|
|
3850
|
+
knowledge: "知识",
|
|
3851
|
+
scene: "场景"
|
|
3852
|
+
});
|
|
3853
|
+
const roleplayMemoryImportanceLabels = Object.freeze({ low: "低重要度", medium: "中重要度", high: "高重要度" });
|
|
3854
|
+
const roleplayMemoryCertaintyLabels = Object.freeze({ experienced: "亲历", observed: "观察", heard: "听说", believed: "相信" });
|
|
3855
|
+
const roleplayMemoryStatusLabels = Object.freeze({ active: "生效中", superseded: "已取代", archived: "已删除" });
|
|
3856
|
+
let roleplayMemoryItems = [];
|
|
3857
|
+
let roleplayMemoryCharacter = null;
|
|
3858
|
+
let roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
|
|
3859
|
+
let roleplayMemoryCursorHistory = [0];
|
|
3860
|
+
let roleplayMemorySearchTimer = null;
|
|
3861
|
+
let roleplayMemoryLoaded = false;
|
|
3862
|
+
let roleplayMemoryLoading = false;
|
|
3863
|
+
|
|
3864
|
+
function roleplayMemoryReadOnly() {
|
|
3865
|
+
return Boolean(state.work) && !canEditModule("characters");
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
function roleplayMemoryQuery() {
|
|
3869
|
+
const parameters = new URLSearchParams({
|
|
3870
|
+
cursor: String(roleplayMemoryCursorHistory.at(-1) ?? 0),
|
|
3871
|
+
limit: "20"
|
|
3872
|
+
});
|
|
3873
|
+
const query = $("#roleplay-memory-search").value.trim();
|
|
3874
|
+
const category = $("#roleplay-memory-category").value;
|
|
3875
|
+
const status = $("#roleplay-memory-status").value;
|
|
3876
|
+
if (query) parameters.set("q", query);
|
|
3877
|
+
if (category) parameters.set("categories", category);
|
|
3878
|
+
if (status === "all") parameters.set("statuses", "active,superseded,archived");
|
|
3879
|
+
else parameters.set("statuses", status || "active");
|
|
3880
|
+
return parameters;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
function roleplayMemorySourceHtml(source) {
|
|
3884
|
+
const canOpen = source.canOpen === true && source.conversationId && source.messageId;
|
|
3885
|
+
const sourceLabel = source.role === "assistant" ? "角色回复" : "用户消息";
|
|
3886
|
+
const detail = source.restricted
|
|
3887
|
+
? "来自其他用户的角色扮演对话,无权查看原文"
|
|
3888
|
+
: source.evidence || "来源消息已删除,保留来源时间";
|
|
3889
|
+
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>`;
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
function renderRoleplayMemoryList() {
|
|
3893
|
+
const host = $("#roleplay-memory-list");
|
|
3894
|
+
if (!host) return;
|
|
3895
|
+
$("#roleplay-memory-add").disabled = roleplayMemoryReadOnly();
|
|
3896
|
+
if (roleplayMemoryLoading) {
|
|
3897
|
+
host.innerHTML = '<p class="roleplay-memory-empty">正在读取角色扮演记忆……</p>';
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
if (!roleplayMemoryLoaded) {
|
|
3901
|
+
host.innerHTML = '<p class="roleplay-memory-empty">打开角色扮演记忆分区后读取该角色的共享记忆库。</p>';
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
if (!roleplayMemoryItems.length) {
|
|
3905
|
+
host.innerHTML = '<p class="roleplay-memory-empty">当前筛选下没有记忆。可以手工添加,AI 也会在成功回复后整理值得保留的扮演经历。</p>';
|
|
3906
|
+
} else {
|
|
3907
|
+
host.innerHTML = roleplayMemoryItems.map((memory) => {
|
|
3908
|
+
const sources = Array.isArray(memory.sources) ? memory.sources : [];
|
|
3909
|
+
const editable = !roleplayMemoryReadOnly();
|
|
3910
|
+
const pinAction = memory.isPinned ? "取消置顶" : "置顶";
|
|
3911
|
+
const actions = editable
|
|
3912
|
+
? memory.status === "archived"
|
|
3913
|
+
? `<button type="button" data-roleplay-memory-action="restore" data-memory-id="${esc(memory.id)}">恢复</button>`
|
|
3914
|
+
: `<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>`
|
|
3915
|
+
: "";
|
|
3916
|
+
return `<article class="roleplay-memory-card${memory.status === "archived" ? " is-archived" : ""}" data-roleplay-memory-id="${esc(memory.id)}">
|
|
3917
|
+
<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>
|
|
3918
|
+
<p class="roleplay-memory-content">${esc(memory.content)}</p>
|
|
3919
|
+
<p class="roleplay-memory-card-meta">${memory.sourceType === "ai" ? "AI 整理" : "手工添加"} · 版本 ${Number(memory.versionNo ?? 1)} · 角色共享</p>
|
|
3920
|
+
${sources.length ? `<details class="roleplay-memory-sources"><summary>来源信息 · ${sources.length} 条</summary><div class="roleplay-memory-source-list">${sources.map(roleplayMemorySourceHtml).join("")}</div></details>` : ""}
|
|
3921
|
+
${actions ? `<div class="roleplay-memory-card-actions">${actions}</div>` : ""}
|
|
3922
|
+
</article>`;
|
|
3923
|
+
}).join("");
|
|
3924
|
+
}
|
|
3925
|
+
const page = roleplayMemoryCursorHistory.length;
|
|
3926
|
+
const hasPrevious = roleplayMemoryCursorHistory.length > 1;
|
|
3927
|
+
const hasNext = roleplayMemoryPagination.nextCursor !== null;
|
|
3928
|
+
$("#roleplay-memory-pagination").classList.toggle("hidden", !hasPrevious && !hasNext);
|
|
3929
|
+
$("#roleplay-memory-previous").disabled = !hasPrevious;
|
|
3930
|
+
$("#roleplay-memory-next").disabled = !hasNext;
|
|
3931
|
+
$("#roleplay-memory-page-label").textContent = `第 ${page} 页 · 共 ${Number(roleplayMemoryPagination.total ?? 0)} 条`;
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
async function loadRoleplayMemories({ resetCursor = false } = {}) {
|
|
3935
|
+
if (!roleplayMemoryCharacter?.id || roleplayMemoryLoading) return;
|
|
3936
|
+
if (resetCursor) roleplayMemoryCursorHistory = [0];
|
|
3937
|
+
const characterId = roleplayMemoryCharacter.id;
|
|
3938
|
+
roleplayMemoryLoading = true;
|
|
3939
|
+
renderRoleplayMemoryList();
|
|
3940
|
+
try {
|
|
3941
|
+
const result = await api(`/api/characters/${encodeURIComponent(characterId)}/roleplay-memories?${roleplayMemoryQuery()}`);
|
|
3942
|
+
if (roleplayMemoryCharacter?.id !== characterId) return;
|
|
3943
|
+
roleplayMemoryItems = Array.isArray(result.items) ? result.items : [];
|
|
3944
|
+
roleplayMemoryPagination = result.pagination ?? { cursor: 0, limit: 20, total: roleplayMemoryItems.length, nextCursor: null };
|
|
3945
|
+
if (result.character) roleplayMemoryCharacter = { ...roleplayMemoryCharacter, ...result.character };
|
|
3946
|
+
roleplayMemoryLoaded = true;
|
|
3947
|
+
} catch (error) {
|
|
3948
|
+
if (roleplayMemoryCharacter?.id === characterId) {
|
|
3949
|
+
roleplayMemoryItems = [];
|
|
3950
|
+
roleplayMemoryLoaded = false;
|
|
3951
|
+
}
|
|
3952
|
+
throw error;
|
|
3953
|
+
} finally {
|
|
3954
|
+
if (roleplayMemoryCharacter?.id === characterId) {
|
|
3955
|
+
roleplayMemoryLoading = false;
|
|
3956
|
+
renderRoleplayMemoryList();
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
function bindRoleplayMemorySurface(character) {
|
|
3962
|
+
const surface = $("#character-roleplay-memory-surface");
|
|
3963
|
+
if (!surface || !character?.id) return;
|
|
3964
|
+
roleplayMemoryCharacter = { id: character.id, name: character.name, workId: character.workId ?? state.work?.id };
|
|
3965
|
+
roleplayMemoryItems = [];
|
|
3966
|
+
roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
|
|
3967
|
+
roleplayMemoryCursorHistory = [0];
|
|
3968
|
+
roleplayMemoryLoaded = false;
|
|
3969
|
+
roleplayMemoryLoading = false;
|
|
3970
|
+
if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
|
|
3971
|
+
roleplayMemorySearchTimer = null;
|
|
3972
|
+
renderRoleplayMemoryList();
|
|
3973
|
+
$("#roleplay-memory-filter-toggle").addEventListener("click", (event) => {
|
|
3974
|
+
const panel = $("#roleplay-memory-filter-panel");
|
|
3975
|
+
const expanded = panel.classList.contains("hidden");
|
|
3976
|
+
panel.classList.toggle("hidden", !expanded);
|
|
3977
|
+
event.currentTarget.setAttribute("aria-expanded", String(expanded));
|
|
3978
|
+
if (expanded) $("#roleplay-memory-search").focus();
|
|
3979
|
+
});
|
|
3980
|
+
$("#roleplay-memory-add").addEventListener("click", () => openRoleplayMemoryEditor());
|
|
3981
|
+
$("#roleplay-memory-list").addEventListener("click", async (event) => {
|
|
3982
|
+
const sourceButton = event.target.closest("[data-roleplay-memory-source-conversation]");
|
|
3983
|
+
if (sourceButton) {
|
|
3984
|
+
try {
|
|
3985
|
+
if (!$("#character-editor-form").classList.contains("hidden")) await closeEntityEditor({ force: true });
|
|
3986
|
+
await openAiConversation(
|
|
3987
|
+
sourceButton.dataset.roleplayMemorySourceConversation,
|
|
3988
|
+
true,
|
|
3989
|
+
sourceButton.dataset.roleplayMemorySourceMessage,
|
|
3990
|
+
sourceButton.dataset.roleplayMemorySourceId
|
|
3991
|
+
);
|
|
3992
|
+
} catch (error) {
|
|
3993
|
+
toast(`来源消息打开失败:${error.message}`, "error");
|
|
3994
|
+
}
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
const actionButton = event.target.closest("[data-roleplay-memory-action]");
|
|
3998
|
+
if (!actionButton) return;
|
|
3999
|
+
const memory = roleplayMemoryItems.find((item) => item.id === actionButton.dataset.memoryId);
|
|
4000
|
+
actionButton.disabled = true;
|
|
4001
|
+
try {
|
|
4002
|
+
await updateRoleplayMemoryAction(memory, actionButton.dataset.roleplayMemoryAction);
|
|
4003
|
+
} catch (error) {
|
|
4004
|
+
toast(`记忆操作失败:${error.message}`, "error");
|
|
4005
|
+
} finally {
|
|
4006
|
+
if (actionButton.isConnected) actionButton.disabled = false;
|
|
4007
|
+
}
|
|
4008
|
+
});
|
|
4009
|
+
$("#roleplay-memory-search").addEventListener("input", () => {
|
|
4010
|
+
if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
|
|
4011
|
+
roleplayMemorySearchTimer = window.setTimeout(() => {
|
|
4012
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆搜索失败:${error.message}`, "error"));
|
|
4013
|
+
}, 250);
|
|
4014
|
+
});
|
|
4015
|
+
for (const select of [$("#roleplay-memory-category"), $("#roleplay-memory-status")]) {
|
|
4016
|
+
select.addEventListener("change", () => {
|
|
4017
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选失败:${error.message}`, "error"));
|
|
4018
|
+
});
|
|
4019
|
+
}
|
|
4020
|
+
$("#roleplay-memory-filter-reset").addEventListener("click", () => {
|
|
4021
|
+
$("#roleplay-memory-search").value = "";
|
|
4022
|
+
$("#roleplay-memory-category").value = "";
|
|
4023
|
+
$("#roleplay-memory-status").value = "active";
|
|
4024
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选重置失败:${error.message}`, "error"));
|
|
4025
|
+
});
|
|
4026
|
+
$("#roleplay-memory-previous").addEventListener("click", () => {
|
|
4027
|
+
if (roleplayMemoryCursorHistory.length <= 1) return;
|
|
4028
|
+
roleplayMemoryCursorHistory.pop();
|
|
4029
|
+
void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
|
|
4030
|
+
});
|
|
4031
|
+
$("#roleplay-memory-next").addEventListener("click", () => {
|
|
4032
|
+
if (roleplayMemoryPagination.nextCursor === null) return;
|
|
4033
|
+
roleplayMemoryCursorHistory.push(roleplayMemoryPagination.nextCursor);
|
|
4034
|
+
void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
|
|
4035
|
+
});
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
function roleplayMemoryEditorFields(memory = null) {
|
|
4039
|
+
return field("category", "记忆类别", "select", memory?.category ?? "event", Object.entries(roleplayMemoryCategoryLabels))
|
|
4040
|
+
+ field("importance", "重要度", "select", memory?.importance ?? "medium", Object.entries(roleplayMemoryImportanceLabels))
|
|
4041
|
+
+ field("certainty", "可信状态", "select", memory?.certainty ?? "experienced", Object.entries(roleplayMemoryCertaintyLabels))
|
|
4042
|
+
+ field("isPinned", "置顶这条记忆", "checkbox", memory?.isPinned === true)
|
|
4043
|
+
+ field("content", "记忆内容", "textarea", memory?.content ?? "");
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
function openRoleplayMemoryEditor(memory = null) {
|
|
4047
|
+
openDialog(memory ? "编辑角色扮演记忆" : "手工添加角色扮演记忆", roleplayMemoryEditorFields(memory), async (form) => {
|
|
4048
|
+
const body = {
|
|
4049
|
+
category: String(form.get("category") ?? "event"),
|
|
4050
|
+
importance: String(form.get("importance") ?? "medium"),
|
|
4051
|
+
certainty: String(form.get("certainty") ?? "experienced"),
|
|
4052
|
+
isPinned: form.get("isPinned") === "on",
|
|
4053
|
+
content: String(form.get("content") ?? "").trim(),
|
|
4054
|
+
...(memory ? { expectedVersion: Number(memory.versionNo) } : {})
|
|
4055
|
+
};
|
|
4056
|
+
if (!body.content) throw new Error("请输入记忆内容");
|
|
4057
|
+
await api(memory
|
|
4058
|
+
? `/api/roleplay-memories/${encodeURIComponent(memory.id)}`
|
|
4059
|
+
: `/api/characters/${encodeURIComponent(roleplayMemoryCharacter.id)}/roleplay-memories`, {
|
|
4060
|
+
method: memory ? "PATCH" : "POST",
|
|
4061
|
+
body
|
|
4062
|
+
});
|
|
4063
|
+
await loadRoleplayMemories();
|
|
4064
|
+
toast(memory ? "角色扮演记忆已更新" : "角色扮演记忆已添加");
|
|
4065
|
+
}, memory ? "版本化编辑" : "非正史 · 手工添加", {
|
|
4066
|
+
submitLabel: memory ? "保存记忆" : "添加记忆",
|
|
4067
|
+
errorPrefix: "记忆保存失败:",
|
|
4068
|
+
meta: "记录该角色在作品内共享的非正史互动,不会写入正文、角色卡字段或设定库。"
|
|
4069
|
+
});
|
|
4070
|
+
const textarea = $("#dialog-fields textarea[name='content']");
|
|
4071
|
+
if (textarea) {
|
|
4072
|
+
textarea.maxLength = 2_000;
|
|
4073
|
+
textarea.rows = 7;
|
|
4074
|
+
textarea.focus();
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
async function updateRoleplayMemoryAction(memory, action) {
|
|
4079
|
+
if (!memory) return;
|
|
4080
|
+
if (action === "edit") return openRoleplayMemoryEditor(memory);
|
|
4081
|
+
if (action === "archive" && !await confirmToast("删除后 AI 不再召回这条记忆,可稍后从“已删除”筛选中恢复。", {
|
|
4082
|
+
title: "删除角色扮演记忆",
|
|
4083
|
+
confirmLabel: "确认删除"
|
|
4084
|
+
})) return;
|
|
4085
|
+
if (action === "pin") {
|
|
4086
|
+
await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
|
|
4087
|
+
method: "PATCH",
|
|
4088
|
+
body: { expectedVersion: Number(memory.versionNo), isPinned: memory.isPinned !== true }
|
|
4089
|
+
});
|
|
4090
|
+
} else if (action === "archive") {
|
|
4091
|
+
await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
|
|
4092
|
+
method: "DELETE",
|
|
4093
|
+
body: { expectedVersion: Number(memory.versionNo) }
|
|
4094
|
+
});
|
|
4095
|
+
} else if (action === "restore") {
|
|
4096
|
+
await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}/restore`, {
|
|
4097
|
+
method: "POST",
|
|
4098
|
+
body: { expectedVersion: Number(memory.versionNo) }
|
|
4099
|
+
});
|
|
4100
|
+
}
|
|
4101
|
+
await loadRoleplayMemories();
|
|
4102
|
+
toast(action === "pin" ? (memory.isPinned ? "已取消置顶" : "记忆已置顶") : action === "archive" ? "记忆已删除" : "记忆已恢复");
|
|
4103
|
+
}
|
|
4104
|
+
|
|
3212
4105
|
function defaultAiConversationTitle(prompt) {
|
|
3213
4106
|
const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
|
|
3214
4107
|
return Array.from(normalized).slice(0, 15).join("") || "新对话";
|
|
@@ -3297,7 +4190,7 @@ async function ensureAiConversationsLoaded() {
|
|
|
3297
4190
|
}
|
|
3298
4191
|
}
|
|
3299
4192
|
|
|
3300
|
-
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
|
|
4193
|
+
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null, roleplayMemorySourceId = null) {
|
|
3301
4194
|
if (!state.work) return null;
|
|
3302
4195
|
const existingTab = aiChatTabManager.findByConversation(conversationId);
|
|
3303
4196
|
const existingMessage = focusMessageId
|
|
@@ -3318,6 +4211,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
|
|
|
3318
4211
|
try {
|
|
3319
4212
|
const parameters = new URLSearchParams({ page: "1", limit: "100" });
|
|
3320
4213
|
if (focusMessageId) parameters.set("messageId", String(focusMessageId));
|
|
4214
|
+
if (roleplayMemorySourceId) parameters.set("roleplayMemorySourceId", String(roleplayMemorySourceId));
|
|
3321
4215
|
const [conversation] = await Promise.all([
|
|
3322
4216
|
api(`/api/ai-conversations/${conversationId}?${parameters}`),
|
|
3323
4217
|
ensureAiReferencesLoaded()
|
|
@@ -3657,7 +4551,7 @@ function syncAiTaskOptions() {
|
|
|
3657
4551
|
}
|
|
3658
4552
|
|
|
3659
4553
|
function applyAiConversationTaskType(taskType) {
|
|
3660
|
-
const normalizedTaskType =
|
|
4554
|
+
const normalizedTaskType = taskType === "roleplay" ? "roleplay" : "chat";
|
|
3661
4555
|
state.aiTaskType = normalizedTaskType;
|
|
3662
4556
|
const tab = activeAiChatTab();
|
|
3663
4557
|
if (tab) tab.taskType = normalizedTaskType;
|
|
@@ -3824,7 +4718,7 @@ async function persistAiRequestInterruption(request, interruption = null) {
|
|
|
3824
4718
|
const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
|
|
3825
4719
|
const metadata = cancelledByClient
|
|
3826
4720
|
? {
|
|
3827
|
-
...(
|
|
4721
|
+
...(interruption?.metadata ?? {}),
|
|
3828
4722
|
interrupted: true,
|
|
3829
4723
|
interruptionCode: "AI_REQUEST_CANCELLED",
|
|
3830
4724
|
interruptionMessage: cancellationMessage.slice(0, 500)
|
|
@@ -3997,10 +4891,12 @@ function clearAiPromptComposer() {
|
|
|
3997
4891
|
state.aiCitations = [];
|
|
3998
4892
|
state.aiReferences = [];
|
|
3999
4893
|
state.aiImageAttachments = [];
|
|
4894
|
+
state.aiSemanticSnapshot = null;
|
|
4000
4895
|
setAiPromptText("");
|
|
4001
4896
|
setAiSceneDirection("");
|
|
4002
4897
|
renderAiCitations();
|
|
4003
4898
|
renderAiImageAttachments();
|
|
4899
|
+
renderAiSemanticInjection();
|
|
4004
4900
|
hideAiMentionMenu();
|
|
4005
4901
|
syncAiSceneComposer();
|
|
4006
4902
|
}
|
|
@@ -4011,6 +4907,7 @@ function captureAiPromptComposer() {
|
|
|
4011
4907
|
citations: state.aiCitations.map((citation) => ({ ...citation })),
|
|
4012
4908
|
references: state.aiReferences.map((reference) => ({ ...reference })),
|
|
4013
4909
|
images: normalizeAiChatImageAttachments(state.aiImageAttachments),
|
|
4910
|
+
semanticSnapshot: state.aiSemanticSnapshot ? structuredClone(state.aiSemanticSnapshot) : null,
|
|
4014
4911
|
sceneDirection: aiSceneDirectionText(),
|
|
4015
4912
|
scenePin: captureAiScenePin()
|
|
4016
4913
|
};
|
|
@@ -4020,10 +4917,12 @@ function restoreAiPromptComposer(snapshot) {
|
|
|
4020
4917
|
state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
|
|
4021
4918
|
state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
|
|
4022
4919
|
state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
|
|
4920
|
+
state.aiSemanticSnapshot = snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null;
|
|
4023
4921
|
setAiPromptText(snapshot.text);
|
|
4024
4922
|
restoreAiSceneComposer(snapshot);
|
|
4025
4923
|
renderAiCitations();
|
|
4026
4924
|
renderAiImageAttachments();
|
|
4925
|
+
renderAiSemanticInjection();
|
|
4027
4926
|
hideAiMentionMenu();
|
|
4028
4927
|
}
|
|
4029
4928
|
|
|
@@ -4392,11 +5291,14 @@ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
|
|
|
4392
5291
|
}
|
|
4393
5292
|
const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
|
|
4394
5293
|
if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
|
|
4395
|
-
|
|
5294
|
+
const savedLineIds = normalizeChapterLineIdDraft(state.chapter.content, state.chapter.lineIds);
|
|
5295
|
+
const draftLineIds = syncChapterDraftLineIds($("#chapter-content").value);
|
|
5296
|
+
const savedCounts = new Map(
|
|
4396
5297
|
(Array.isArray(counts) ? counts : [])
|
|
4397
5298
|
.map((item) => [Number(item.line), Number(item.count)])
|
|
4398
5299
|
.filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
|
|
4399
5300
|
);
|
|
5301
|
+
chapterAnnotationCounts = remapChapterLineCounts(savedLineIds, draftLineIds, savedCounts);
|
|
4400
5302
|
scheduleChapterLineNumbers();
|
|
4401
5303
|
}
|
|
4402
5304
|
|
|
@@ -5617,12 +6519,47 @@ function setSaveState(text, dirty = false) {
|
|
|
5617
6519
|
});
|
|
5618
6520
|
}
|
|
5619
6521
|
|
|
6522
|
+
function resetChapterDraftLineIds(chapter = state.chapter) {
|
|
6523
|
+
chapterBeforeInputState = null;
|
|
6524
|
+
chapterDraftLineIdState = chapter ? {
|
|
6525
|
+
chapterId: chapter.id,
|
|
6526
|
+
content: String(chapter.content ?? ""),
|
|
6527
|
+
lineIds: normalizeChapterLineIdDraft(chapter.content, chapter.lineIds)
|
|
6528
|
+
} : null;
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6531
|
+
function syncChapterDraftLineIds(content, hint = null) {
|
|
6532
|
+
if (!state.chapter) return [];
|
|
6533
|
+
if (!chapterDraftLineIdState || chapterDraftLineIdState.chapterId !== state.chapter.id) {
|
|
6534
|
+
resetChapterDraftLineIds(state.chapter);
|
|
6535
|
+
}
|
|
6536
|
+
if (chapterDraftLineIdState.content !== content) {
|
|
6537
|
+
const beforeLineIds = chapterDraftLineIdState.lineIds;
|
|
6538
|
+
const lineIds = reconcileChapterLineIdDraft(
|
|
6539
|
+
chapterDraftLineIdState.content,
|
|
6540
|
+
content,
|
|
6541
|
+
beforeLineIds,
|
|
6542
|
+
hint
|
|
6543
|
+
);
|
|
6544
|
+
chapterAnnotationCounts = remapChapterLineCounts(beforeLineIds, lineIds, chapterAnnotationCounts);
|
|
6545
|
+
chapterDraftLineIdState = {
|
|
6546
|
+
chapterId: state.chapter.id,
|
|
6547
|
+
content,
|
|
6548
|
+
lineIds
|
|
6549
|
+
};
|
|
6550
|
+
}
|
|
6551
|
+
return chapterDraftLineIdState.lineIds;
|
|
6552
|
+
}
|
|
6553
|
+
|
|
5620
6554
|
function chapterDraftSnapshot() {
|
|
5621
6555
|
if (!state.chapter) return null;
|
|
6556
|
+
const content = $("#chapter-content").value;
|
|
6557
|
+
const lineIds = syncChapterDraftLineIds(content);
|
|
5622
6558
|
return {
|
|
5623
6559
|
chapterId: state.chapter.id,
|
|
5624
6560
|
title: $("#chapter-title").value.trim(),
|
|
5625
|
-
content
|
|
6561
|
+
content,
|
|
6562
|
+
...(lineIds.length <= MAX_CHAPTER_LINE_IDS ? { lineIds } : {})
|
|
5626
6563
|
};
|
|
5627
6564
|
}
|
|
5628
6565
|
|
|
@@ -5697,7 +6634,7 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5697
6634
|
const request = (async () => {
|
|
5698
6635
|
const chapter = await api(`/api/chapters/${draft.chapterId}`, {
|
|
5699
6636
|
method: "PATCH",
|
|
5700
|
-
body: { title: draft.title, content: draft.content, source: automatic ? "auto" : "manual" }
|
|
6637
|
+
body: { title: draft.title, content: draft.content, lineIds: draft.lineIds, source: automatic ? "auto" : "manual" }
|
|
5701
6638
|
});
|
|
5702
6639
|
const work = await api(`/api/works/${workId}`);
|
|
5703
6640
|
return { chapter, work };
|
|
@@ -5708,9 +6645,15 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
5708
6645
|
if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
|
|
5709
6646
|
state.chapter = saved.chapter;
|
|
5710
6647
|
state.work = saved.work;
|
|
6648
|
+
resetChapterDraftLineIds(state.chapter);
|
|
5711
6649
|
lastSavedChapterSnapshot = draft;
|
|
5712
6650
|
renderTree();
|
|
5713
6651
|
updateChapterStats();
|
|
6652
|
+
try {
|
|
6653
|
+
await loadChapterAnnotationCounts(saved.chapter.id);
|
|
6654
|
+
} catch (error) {
|
|
6655
|
+
toast("正文评论位置已更新,但评论数量刷新失败,请稍后重试", "error");
|
|
6656
|
+
}
|
|
5714
6657
|
const currentDraft = chapterDraftSnapshot();
|
|
5715
6658
|
if (sameChapterSnapshot(currentDraft, draft)) {
|
|
5716
6659
|
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
@@ -5785,13 +6728,35 @@ function restoredSettingsReturnContext(route) {
|
|
|
5785
6728
|
}
|
|
5786
6729
|
|
|
5787
6730
|
async function initializePage() {
|
|
6731
|
+
const route = parsePageRoute(window.location.hash);
|
|
6732
|
+
const earlyReaderChapterRequest = window.__scriverseReaderChapterPrefetch;
|
|
6733
|
+
const earlyReaderWorksRequest = window.__scriverseReaderWorksPrefetch;
|
|
6734
|
+
const earlyReaderWorkRequest = window.__scriverseReaderWorkPrefetch;
|
|
5788
6735
|
const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
|
|
5789
6736
|
if (!authenticated) {
|
|
5790
6737
|
restoringPageRoute = false;
|
|
5791
6738
|
return;
|
|
5792
6739
|
}
|
|
5793
|
-
const
|
|
5794
|
-
|
|
6740
|
+
const requestedWorkDetailRequest = route.workId
|
|
6741
|
+
? earlyReaderWorkRequest?.workId === route.workId
|
|
6742
|
+
? earlyReaderWorkRequest.request.then((result) => result.work ?? api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null))
|
|
6743
|
+
: api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null)
|
|
6744
|
+
: Promise.resolve(null);
|
|
6745
|
+
initialReaderChapterRequest = route.view === "reader" && route.chapterId
|
|
6746
|
+
? earlyReaderChapterRequest?.chapterId === route.chapterId
|
|
6747
|
+
? earlyReaderChapterRequest
|
|
6748
|
+
: {
|
|
6749
|
+
chapterId: route.chapterId,
|
|
6750
|
+
request: api(`/api/chapters/${encodeURIComponent(route.chapterId)}`)
|
|
6751
|
+
.then((chapter) => ({ chapter }), (error) => ({ error }))
|
|
6752
|
+
}
|
|
6753
|
+
: null;
|
|
6754
|
+
const [worksPage, requestedWorkDetail] = await Promise.all([
|
|
6755
|
+
earlyReaderWorksRequest?.request.then((result) => result.works ?? apiPage("/api/works")) ?? apiPage("/api/works"),
|
|
6756
|
+
requestedWorkDetailRequest
|
|
6757
|
+
]);
|
|
6758
|
+
state.works = worksPage.items;
|
|
6759
|
+
initialWorkDetail = requestedWorkDetail;
|
|
5795
6760
|
try {
|
|
5796
6761
|
if (route.view === "shelf") {
|
|
5797
6762
|
showShelf();
|
|
@@ -5832,7 +6797,7 @@ async function initializePage() {
|
|
|
5832
6797
|
return;
|
|
5833
6798
|
}
|
|
5834
6799
|
const options = { readOnly: route.entityMode === "read" };
|
|
5835
|
-
if (route.entity === "setting") openSettingEditor(item, options);
|
|
6800
|
+
if (route.entity === "setting") await openSettingEditor(item, options);
|
|
5836
6801
|
else if (route.entity === "character") await openCharacterEditor(item, options);
|
|
5837
6802
|
else if (route.entity === "race") await openRaceDialog(item, options);
|
|
5838
6803
|
else if (route.entity === "organization") await openOrganizationDialog(item, options);
|
|
@@ -5863,7 +6828,14 @@ async function initializePage() {
|
|
|
5863
6828
|
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
5864
6829
|
}
|
|
5865
6830
|
} finally {
|
|
6831
|
+
delete window.__scriverseReaderChapterPrefetch;
|
|
6832
|
+
delete window.__scriverseReaderWorksPrefetch;
|
|
6833
|
+
delete window.__scriverseReaderWorkPrefetch;
|
|
6834
|
+
initialWorkDetail = null;
|
|
6835
|
+
initialReaderChapterRequest = null;
|
|
5866
6836
|
document.body.classList.remove("auth-pending");
|
|
6837
|
+
document.documentElement.removeAttribute("data-pending-view");
|
|
6838
|
+
document.documentElement.classList.remove("pending-shelf-mode");
|
|
5867
6839
|
restoringPageRoute = false;
|
|
5868
6840
|
replacePageRoute(currentPageRoute());
|
|
5869
6841
|
scheduleFirstUseOnboarding();
|
|
@@ -6971,6 +7943,7 @@ async function refreshWorkAfterGlobalReplace(route, result) {
|
|
|
6971
7943
|
const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
|
|
6972
7944
|
if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
|
|
6973
7945
|
state.chapter = chapter;
|
|
7946
|
+
resetChapterDraftLineIds(state.chapter);
|
|
6974
7947
|
mergeChapterDirectoryEntry(chapter);
|
|
6975
7948
|
lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
|
|
6976
7949
|
await loadVolumeChapters(chapter.volumeId);
|
|
@@ -7349,6 +8322,13 @@ function resetWorkScopedUiCaches() {
|
|
|
7349
8322
|
draftTypeFilter = "all";
|
|
7350
8323
|
draftBindingFilters = [];
|
|
7351
8324
|
draftFiltersPanelOpen = false;
|
|
8325
|
+
chapterCommentFilters.chapterId = "";
|
|
8326
|
+
chapterCommentFilters.keyword = "";
|
|
8327
|
+
chapterCommentFiltersPanelOpen = false;
|
|
8328
|
+
chapterCommentChapterOptions = [];
|
|
8329
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
8330
|
+
chapterCommentSearchTimer = null;
|
|
8331
|
+
chapterCommentRenderRequestId += 1;
|
|
7352
8332
|
settingFilters.keyword = "";
|
|
7353
8333
|
settingFilters.category = "";
|
|
7354
8334
|
settingFilters.lockState = "all";
|
|
@@ -7392,7 +8372,9 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
7392
8372
|
volumeChapterLoadingIds.clear();
|
|
7393
8373
|
volumeChapterRequests.clear();
|
|
7394
8374
|
}
|
|
7395
|
-
const
|
|
8375
|
+
const prefetchedWork = initialWorkDetail?.id === workId ? initialWorkDetail : null;
|
|
8376
|
+
initialWorkDetail = null;
|
|
8377
|
+
const nextWork = prefetchedWork ?? await api(`/api/works/${workId}?directory=volumes`);
|
|
7396
8378
|
if (selectionGeneration !== workSelectionRequestGeneration) return false;
|
|
7397
8379
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
7398
8380
|
showSystemStatus();
|
|
@@ -7462,9 +8444,12 @@ async function loadVolumeChapters(volumeId) {
|
|
|
7462
8444
|
async function loadAllVolumeChapters(workId) {
|
|
7463
8445
|
const generation = workScopedUiGeneration;
|
|
7464
8446
|
const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
|
|
7465
|
-
for (
|
|
7466
|
-
|
|
7467
|
-
await
|
|
8447
|
+
for (let index = 0; index < volumeIds.length; index += readingVolumeDirectoryConcurrency) {
|
|
8448
|
+
const batch = volumeIds.slice(index, index + readingVolumeDirectoryConcurrency);
|
|
8449
|
+
await Promise.all(batch.map(async (volumeId) => {
|
|
8450
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
8451
|
+
await loadVolumeChapters(volumeId);
|
|
8452
|
+
}));
|
|
7468
8453
|
}
|
|
7469
8454
|
}
|
|
7470
8455
|
|
|
@@ -7651,11 +8636,21 @@ function renderChapterBatchDialog() {
|
|
|
7651
8636
|
function updateChapterBatchControls() {
|
|
7652
8637
|
const count = chapterBatchSelectedIds.size;
|
|
7653
8638
|
const action = $("#chapter-batch-action").value;
|
|
8639
|
+
const renumbering = action === "renumberTitles";
|
|
8640
|
+
const template = $("#chapter-batch-template").value.trim();
|
|
8641
|
+
const templateValid = template.split("{n}").length === 2;
|
|
8642
|
+
const startAt = Number($("#chapter-batch-start").value);
|
|
8643
|
+
const sequenceEnd = startAt + count - 1;
|
|
7654
8644
|
$("#chapter-batch-count").textContent = `已选择 ${count} 章`;
|
|
7655
|
-
$("#chapter-batch-apply").disabled = count === 0;
|
|
8645
|
+
$("#chapter-batch-apply").disabled = count === 0 || (renumbering && (!templateValid || !Number.isInteger(startAt) || startAt < 1 || sequenceEnd > 999999));
|
|
7656
8646
|
$("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
|
|
7657
8647
|
$("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
|
|
7658
|
-
|
|
8648
|
+
for (const id of ["chapter-batch-template-field", "chapter-batch-number-style-field", "chapter-batch-start-field", "chapter-batch-renumber-note"]) {
|
|
8649
|
+
$(`#${id}`).classList.toggle("hidden", !renumbering);
|
|
8650
|
+
}
|
|
8651
|
+
$("#chapter-batch-apply").textContent = action === "delete"
|
|
8652
|
+
? "软删除所选章节"
|
|
8653
|
+
: renumbering ? "重排所选章节" : "应用到所选章节";
|
|
7659
8654
|
}
|
|
7660
8655
|
|
|
7661
8656
|
function openChapterBatchDialog() {
|
|
@@ -7675,16 +8670,41 @@ async function submitChapterBatch(event) {
|
|
|
7675
8670
|
? { type: "move", volumeId: $("#chapter-batch-volume").value }
|
|
7676
8671
|
: actionValue === "setType"
|
|
7677
8672
|
? { type: "setType", chapterType: $("#chapter-batch-type").value }
|
|
7678
|
-
: actionValue === "
|
|
7679
|
-
? {
|
|
7680
|
-
|
|
8673
|
+
: actionValue === "renumberTitles"
|
|
8674
|
+
? {
|
|
8675
|
+
type: "renumberTitles",
|
|
8676
|
+
template: $("#chapter-batch-template").value.trim(),
|
|
8677
|
+
numberStyle: $("#chapter-batch-number-style").value,
|
|
8678
|
+
startAt: Number($("#chapter-batch-start").value)
|
|
8679
|
+
}
|
|
8680
|
+
: actionValue === "exclude" || actionValue === "include"
|
|
8681
|
+
? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
|
|
8682
|
+
: { type: "delete" };
|
|
7681
8683
|
const dialog = $("#chapter-batch-dialog");
|
|
7682
|
-
if (
|
|
8684
|
+
if (state.module === "editor" && state.chapter && state.dirty) {
|
|
7683
8685
|
dialog.close();
|
|
7684
|
-
const
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
8686
|
+
const confirmedDiscard = await confirmDiscardChanges("当前章节有未保存修改,批量处理将丢弃这些修改。是否继续?");
|
|
8687
|
+
if (!confirmedDiscard) {
|
|
8688
|
+
dialog.showModal();
|
|
8689
|
+
return;
|
|
8690
|
+
}
|
|
8691
|
+
}
|
|
8692
|
+
if (action.type === "renumberTitles" && (action.template.split("{n}").length !== 2 || !Number.isInteger(action.startAt) || action.startAt < 1 || action.startAt + chapters.length - 1 > 999999)) {
|
|
8693
|
+
toast("标题格式必须且只能包含一个 {n},且所选章节的序号不能超过 999999", "error");
|
|
8694
|
+
$("#chapter-batch-template").focus();
|
|
8695
|
+
return;
|
|
8696
|
+
}
|
|
8697
|
+
if (action.type === "delete" || action.type === "renumberTitles") {
|
|
8698
|
+
dialog.close();
|
|
8699
|
+
const confirmed = action.type === "delete"
|
|
8700
|
+
? await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
|
|
8701
|
+
title: "批量删除需要再次确认",
|
|
8702
|
+
confirmLabel: "确认软删除"
|
|
8703
|
+
})
|
|
8704
|
+
: await confirmToast(`将按目录顺序,把所选 ${chapters.length} 个章节从第 ${action.startAt} 个序号开始重排为“${action.template}”格式。每个改名章节都会保留版本,确认继续吗?`, {
|
|
8705
|
+
title: "重排标题需要再次确认",
|
|
8706
|
+
confirmLabel: "确认重排"
|
|
8707
|
+
});
|
|
7688
8708
|
if (!confirmed) {
|
|
7689
8709
|
dialog.showModal();
|
|
7690
8710
|
return;
|
|
@@ -7692,21 +8712,26 @@ async function submitChapterBatch(event) {
|
|
|
7692
8712
|
}
|
|
7693
8713
|
$("#chapter-batch-apply").disabled = true;
|
|
7694
8714
|
try {
|
|
7695
|
-
await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
|
|
8715
|
+
const result = await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
|
|
7696
8716
|
method: "POST",
|
|
7697
8717
|
body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
|
|
7698
8718
|
});
|
|
7699
8719
|
const workId = state.work.id;
|
|
7700
8720
|
state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
|
|
8721
|
+
const currentEditorVisible = state.module === "editor";
|
|
7701
8722
|
const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
|
|
7702
8723
|
if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
|
|
7703
8724
|
if (state.chapter && !currentStillExists) {
|
|
7704
8725
|
state.chapter = null;
|
|
7705
8726
|
showWelcome(true);
|
|
8727
|
+
} else if (state.chapter && currentEditorVisible) {
|
|
8728
|
+
await selectChapter(state.chapter.id, { editMode: !chapterEditorReadOnly });
|
|
7706
8729
|
} else renderTree();
|
|
7707
8730
|
if (dialog.open) dialog.close();
|
|
7708
8731
|
chapterBatchSelectedIds.clear();
|
|
7709
|
-
toast(
|
|
8732
|
+
toast(action.type === "renumberTitles"
|
|
8733
|
+
? `已按目录顺序重排 ${Number(result.updated ?? chapters.length)} 个章节标题`
|
|
8734
|
+
: `已批量处理 ${chapters.length} 个章节`);
|
|
7710
8735
|
} catch (error) {
|
|
7711
8736
|
if (!dialog.open) dialog.showModal();
|
|
7712
8737
|
$("#chapter-batch-apply").disabled = false;
|
|
@@ -7837,9 +8862,14 @@ function currentChapterForeshadowReminder() {
|
|
|
7837
8862
|
|
|
7838
8863
|
function syncMobileAiPanelSafeTop() {
|
|
7839
8864
|
const container = $("#chapter-foreshadow-reminder");
|
|
7840
|
-
const
|
|
8865
|
+
const toolbar = $("#editor-view .editor-toolbar");
|
|
8866
|
+
const reminderBottom = container.classList.contains("hidden")
|
|
8867
|
+
? 0
|
|
8868
|
+
: container.getBoundingClientRect().bottom;
|
|
8869
|
+
const toolbarBottom = $("#editor-view").classList.contains("hidden")
|
|
7841
8870
|
? 0
|
|
7842
|
-
:
|
|
8871
|
+
: toolbar.getBoundingClientRect().bottom;
|
|
8872
|
+
const safeTop = Math.ceil(Math.max(reminderBottom, toolbarBottom));
|
|
7843
8873
|
$("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
|
|
7844
8874
|
}
|
|
7845
8875
|
|
|
@@ -8061,6 +9091,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
8061
9091
|
$("#chapter-path").title = chapterPath;
|
|
8062
9092
|
$("#chapter-title").value = state.chapter.title;
|
|
8063
9093
|
$("#chapter-content").value = state.chapter.content;
|
|
9094
|
+
resetChapterDraftLineIds(state.chapter);
|
|
8064
9095
|
chapterAnnotationCounts = new Map();
|
|
8065
9096
|
clearChapterLineSelection();
|
|
8066
9097
|
scheduleChapterLineNumbers();
|
|
@@ -8183,6 +9214,7 @@ function renderReadingNavigation() {
|
|
|
8183
9214
|
$("#reader-next").disabled = !next || readingLoading;
|
|
8184
9215
|
$("#reader-continue").disabled = !next || readingLoading;
|
|
8185
9216
|
$("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
|
|
9217
|
+
$("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
|
|
8186
9218
|
const paged = readingPreferences.mode === "paged";
|
|
8187
9219
|
const previousPage = current && paged ? resolvePagedReadingStep({
|
|
8188
9220
|
sequence: readingSequence,
|
|
@@ -8220,7 +9252,7 @@ function applyReadingPreferences() {
|
|
|
8220
9252
|
$("#reader-font-size").value = String(readingPreferences.fontSize);
|
|
8221
9253
|
$("#reader-line-height").value = String(readingPreferences.lineHeight);
|
|
8222
9254
|
$("#reader-theme").value = readingPreferences.theme;
|
|
8223
|
-
$("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
|
|
9255
|
+
$("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
|
|
8224
9256
|
$("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
|
|
8225
9257
|
$("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
|
|
8226
9258
|
}
|
|
@@ -8355,7 +9387,13 @@ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = n
|
|
|
8355
9387
|
renderReadingStatus("正在载入章节……");
|
|
8356
9388
|
replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
|
|
8357
9389
|
try {
|
|
8358
|
-
const
|
|
9390
|
+
const initialRequest = initialReaderChapterRequest?.chapterId === target.id
|
|
9391
|
+
? initialReaderChapterRequest.request
|
|
9392
|
+
: null;
|
|
9393
|
+
if (initialRequest) initialReaderChapterRequest = null;
|
|
9394
|
+
const prefetchedResult = initialRequest ? await initialRequest : null;
|
|
9395
|
+
if (prefetchedResult?.error) throw prefetchedResult.error;
|
|
9396
|
+
const chapter = prefetchedResult?.chapter ?? await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
|
|
8359
9397
|
if (!readingRequestGate.isCurrent(request)) return false;
|
|
8360
9398
|
if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
|
|
8361
9399
|
throw new Error("章节响应与当前作品不匹配");
|
|
@@ -8509,7 +9547,7 @@ function closeReadingPreview() {
|
|
|
8509
9547
|
replacePageRoute(returnRoute);
|
|
8510
9548
|
const focus = readingPreviousFocus;
|
|
8511
9549
|
readingPreviousFocus = null;
|
|
8512
|
-
const focusCandidates = [focus, $("#
|
|
9550
|
+
const focusCandidates = [focus, $("#reader-open-button"), $("#home-button")];
|
|
8513
9551
|
for (const candidate of focusCandidates) {
|
|
8514
9552
|
if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
|
|
8515
9553
|
const rect = candidate.getBoundingClientRect();
|
|
@@ -8568,6 +9606,7 @@ function tidyChapterBlankLines() {
|
|
|
8568
9606
|
const normalized = normalizeParagraphSpacing(input.value);
|
|
8569
9607
|
if (normalized === input.value) return toast("正文空行已经符合要求");
|
|
8570
9608
|
input.value = normalized;
|
|
9609
|
+
syncChapterDraftLineIds(input.value);
|
|
8571
9610
|
scheduleChapterLineNumbers();
|
|
8572
9611
|
updateChapterStats();
|
|
8573
9612
|
scheduleChapterAutoSave(120);
|
|
@@ -8748,6 +9787,14 @@ function pencilIconMarkup() {
|
|
|
8748
9787
|
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"></path><path d="m16.5 3.5 1.4-1.4a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4L16.5 3.5Z"></path></svg>';
|
|
8749
9788
|
}
|
|
8750
9789
|
|
|
9790
|
+
function trashIconMarkup() {
|
|
9791
|
+
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>';
|
|
9792
|
+
}
|
|
9793
|
+
|
|
9794
|
+
function roleplayMemoryPinIconMarkup() {
|
|
9795
|
+
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>';
|
|
9796
|
+
}
|
|
9797
|
+
|
|
8751
9798
|
function characterFavoriteIconMarkup() {
|
|
8752
9799
|
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"></path></svg>';
|
|
8753
9800
|
}
|
|
@@ -9214,6 +10261,17 @@ function mountOutlineBoardFilterToggle() {
|
|
|
9214
10261
|
});
|
|
9215
10262
|
}
|
|
9216
10263
|
|
|
10264
|
+
function mountChapterCommentFilterToggle() {
|
|
10265
|
+
$("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]')?.remove();
|
|
10266
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="chapter-comment-filter-toggle" aria-label="筛选正文评论与待办" aria-controls="chapter-comment-filter-panel" aria-expanded="${chapterCommentFiltersPanelOpen}" 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>`);
|
|
10267
|
+
const toggle = $("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]');
|
|
10268
|
+
toggle?.addEventListener("click", () => {
|
|
10269
|
+
chapterCommentFiltersPanelOpen = !chapterCommentFiltersPanelOpen;
|
|
10270
|
+
$("#chapter-comment-filter-panel")?.classList.toggle("hidden", !chapterCommentFiltersPanelOpen);
|
|
10271
|
+
toggle.setAttribute("aria-expanded", String(chapterCommentFiltersPanelOpen));
|
|
10272
|
+
});
|
|
10273
|
+
}
|
|
10274
|
+
|
|
9217
10275
|
function bindRecordPreview(selector, open) {
|
|
9218
10276
|
$("#module-content").querySelectorAll(selector).forEach((card) => {
|
|
9219
10277
|
const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
|
|
@@ -10464,32 +11522,102 @@ async function renderRelationships(page = moduleListPages.relationships) {
|
|
|
10464
11522
|
}
|
|
10465
11523
|
|
|
10466
11524
|
async function renderWorkChapterComments(page = moduleListPages.comments) {
|
|
10467
|
-
const
|
|
10468
|
-
const
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
await
|
|
10490
|
-
|
|
11525
|
+
const workId = state.work.id;
|
|
11526
|
+
const generation = workScopedUiGeneration;
|
|
11527
|
+
const hasFilters = () => Boolean(chapterCommentFilters.chapterId || chapterCommentFilters.keyword.trim());
|
|
11528
|
+
const chapterOptionsMarkup = () => `<option value="">全部章节</option>${chapterCommentChapterOptions.map((chapter) => `<option value="${esc(chapter.id)}" ${chapterCommentFilters.chapterId === chapter.id ? "selected" : ""}>${esc(chapter.volumeTitle)} / ${esc(chapter.title)}</option>`).join("")}`;
|
|
11529
|
+
const filterToolbar = `<section id="chapter-comment-filter-panel" class="character-filter-toolbar chapter-comment-filter-toolbar${chapterCommentFiltersPanelOpen ? "" : " hidden"}" aria-label="正文评论与待办筛选">
|
|
11530
|
+
<label class="setting-filter-field" for="chapter-comment-chapter-filter"><span>按章节筛选</span><select id="chapter-comment-chapter-filter" aria-label="按章节筛选正文评论与待办">${chapterOptionsMarkup()}</select></label>
|
|
11531
|
+
<label class="setting-filter-field" for="chapter-comment-keyword-filter"><span>按关键词搜索</span><input id="chapter-comment-keyword-filter" type="search" value="${esc(chapterCommentFilters.keyword)}" placeholder="搜索评论、待办或引用正文" aria-label="按关键词搜索正文评论与待办" autocomplete="off" maxlength="100"></label>
|
|
11532
|
+
<div class="character-filter-toolbar-actions"><span id="chapter-comment-filter-result-count" class="character-filter-result-count${hasFilters() ? "" : " hidden"}" role="status" aria-live="polite"></span><button id="clear-chapter-comment-filters" class="ghost-button" type="button" ${hasFilters() ? "" : "disabled"}>重置筛选</button></div>
|
|
11533
|
+
</section><div id="chapter-comment-filter-results"></div>`;
|
|
11534
|
+
$("#module-content").innerHTML = filterToolbar;
|
|
11535
|
+
mountChapterCommentFilterToggle();
|
|
11536
|
+
|
|
11537
|
+
const refreshResults = async (requestedPage = moduleListPages.comments) => {
|
|
11538
|
+
const requestId = ++chapterCommentRenderRequestId;
|
|
11539
|
+
const pageSize = pageSizeFor("comments");
|
|
11540
|
+
const parameters = new URLSearchParams();
|
|
11541
|
+
if (chapterCommentFilters.chapterId) parameters.set("chapterId", chapterCommentFilters.chapterId);
|
|
11542
|
+
if (chapterCommentFilters.keyword.trim()) parameters.set("q", chapterCommentFilters.keyword.trim());
|
|
11543
|
+
const path = `/api/works/${encodeURIComponent(workId)}/chapter-annotations${parameters.size ? `?${parameters}` : ""}`;
|
|
11544
|
+
$("#chapter-comment-filter-results")?.setAttribute("aria-busy", "true");
|
|
11545
|
+
let result;
|
|
11546
|
+
try {
|
|
11547
|
+
result = await moduleApiPage("comments", path, requestedPage, pageSize);
|
|
11548
|
+
} finally {
|
|
11549
|
+
if (requestId === chapterCommentRenderRequestId) $("#chapter-comment-filter-results")?.removeAttribute("aria-busy");
|
|
10491
11550
|
}
|
|
11551
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== chapterCommentRenderRequestId || state.module !== "comments") return;
|
|
11552
|
+
chapterCommentChapterOptions = Array.isArray(result.chapterOptions) ? result.chapterOptions : [];
|
|
11553
|
+
if (chapterCommentFilters.chapterId && !chapterCommentChapterOptions.some((chapter) => chapter.id === chapterCommentFilters.chapterId)) {
|
|
11554
|
+
chapterCommentFilters.chapterId = "";
|
|
11555
|
+
$("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
|
|
11556
|
+
return refreshResults(1);
|
|
11557
|
+
}
|
|
11558
|
+
$("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
|
|
11559
|
+
if (!result.items.length && requestedPage > 1) return refreshResults(requestedPage - 1);
|
|
11560
|
+
const total = Number(result.total ?? result.items.length);
|
|
11561
|
+
const pageCount = Math.max(1, Math.ceil(total / result.limit));
|
|
11562
|
+
const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
|
|
11563
|
+
moduleListPages.comments = pageResult.page;
|
|
11564
|
+
mountModuleCount(total);
|
|
11565
|
+
const completedTodos = result.items.filter((annotation) => annotation.kind === "todo" && annotation.status === "resolved");
|
|
11566
|
+
const visibleAnnotations = result.items.filter((annotation) => annotation.kind !== "todo" || annotation.status !== "resolved");
|
|
11567
|
+
const visibleMarkup = visibleAnnotations.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("");
|
|
11568
|
+
const completedMarkup = completedTodos.length ? `<details class="chapter-comment-completed-group"><summary><span>已完成待办</span><strong>${completedTodos.length}</strong><small>默认折叠,点击展开</small></summary><div class="chapter-comment-completed-list">${completedTodos.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div></details>` : "";
|
|
11569
|
+
const filtersActive = hasFilters();
|
|
11570
|
+
$("#chapter-comment-filter-results").innerHTML = result.items.length
|
|
11571
|
+
? `<div class="chapter-comment-module-list">${visibleMarkup}${completedMarkup}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
|
|
11572
|
+
: chapterCommentChapterOptions.length
|
|
11573
|
+
? emptyModule("没有符合筛选条件的评论或待办", "可以切换章节、修改关键词或重置筛选。")
|
|
11574
|
+
: emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
|
|
11575
|
+
const resultCount = $("#chapter-comment-filter-result-count");
|
|
11576
|
+
resultCount.textContent = filtersActive ? `筛选后共 ${total} 条评论与待办` : "";
|
|
11577
|
+
resultCount.classList.toggle("hidden", !filtersActive);
|
|
11578
|
+
$("#clear-chapter-comment-filters").disabled = !filtersActive;
|
|
11579
|
+
bindModulePagination("comments", refreshResults);
|
|
11580
|
+
bindChapterAnnotationCards($("#chapter-comment-filter-results"), result.items, {
|
|
11581
|
+
refresh: () => refreshResults(pageResult.page),
|
|
11582
|
+
locate: async (annotation) => {
|
|
11583
|
+
const selected = await selectChapter(annotation.chapterId);
|
|
11584
|
+
if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
|
|
11585
|
+
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
|
11586
|
+
revealChapterLines(annotation.startLine, annotation.endLine);
|
|
11587
|
+
}
|
|
11588
|
+
});
|
|
11589
|
+
};
|
|
11590
|
+
|
|
11591
|
+
$("#chapter-comment-chapter-filter").addEventListener("change", (event) => {
|
|
11592
|
+
chapterCommentFilters.chapterId = event.currentTarget.value;
|
|
11593
|
+
chapterCommentFiltersPanelOpen = true;
|
|
11594
|
+
moduleListPages.comments = 1;
|
|
11595
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
11596
|
+
chapterCommentSearchTimer = null;
|
|
11597
|
+
void refreshResults(1).catch((error) => toast(error.message, "error"));
|
|
11598
|
+
});
|
|
11599
|
+
$("#chapter-comment-keyword-filter").addEventListener("input", (event) => {
|
|
11600
|
+
chapterCommentFilters.keyword = event.currentTarget.value;
|
|
11601
|
+
chapterCommentFiltersPanelOpen = true;
|
|
11602
|
+
moduleListPages.comments = 1;
|
|
11603
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
11604
|
+
chapterCommentSearchTimer = window.setTimeout(() => {
|
|
11605
|
+
chapterCommentSearchTimer = null;
|
|
11606
|
+
void refreshResults(1).catch((error) => toast(error.message, "error"));
|
|
11607
|
+
}, 250);
|
|
11608
|
+
});
|
|
11609
|
+
$("#clear-chapter-comment-filters").addEventListener("click", () => {
|
|
11610
|
+
chapterCommentFilters.chapterId = "";
|
|
11611
|
+
chapterCommentFilters.keyword = "";
|
|
11612
|
+
chapterCommentFiltersPanelOpen = true;
|
|
11613
|
+
moduleListPages.comments = 1;
|
|
11614
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
11615
|
+
chapterCommentSearchTimer = null;
|
|
11616
|
+
$("#chapter-comment-chapter-filter").value = "";
|
|
11617
|
+
$("#chapter-comment-keyword-filter").value = "";
|
|
11618
|
+
void refreshResults(1).then(() => $("#chapter-comment-keyword-filter")?.focus()).catch((error) => toast(error.message, "error"));
|
|
10492
11619
|
});
|
|
11620
|
+
await refreshResults(page);
|
|
10493
11621
|
}
|
|
10494
11622
|
|
|
10495
11623
|
async function renderReviews(page = moduleListPages.reviews) {
|
|
@@ -11636,16 +12764,17 @@ function renderProviderCards(providers, models, protocolOptions) {
|
|
|
11636
12764
|
<article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
|
|
11637
12765
|
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n分析请求超时:${Number(provider.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS).toLocaleString("zh-CN")} 秒\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
11638
12766
|
<div class="provider-models">${providerModels.map((model) => {
|
|
11639
|
-
const modelUnavailable = !
|
|
12767
|
+
const modelUnavailable = !isAvailableConfiguredModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
11640
12768
|
const modelStatus = !model.enabled
|
|
11641
12769
|
? `<span class="model-status-badge is-disabled">模型已停用</span>`
|
|
11642
12770
|
: provider.connectionStatus !== "success"
|
|
11643
12771
|
? `<span class="model-status-badge is-unavailable">连接不可用</span>`
|
|
11644
12772
|
: "";
|
|
12773
|
+
const kindLabel = model.modelKind === "embedding" ? "Embedding" : model.modelKind === "rerank" ? "Rerank" : "Chat";
|
|
11645
12774
|
const capability = model.multimodalEnabled ? " · 多模态" : "";
|
|
11646
12775
|
const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
|
|
11647
12776
|
const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
|
|
11648
|
-
return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
|
|
12777
|
+
return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${esc(kindLabel)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge}${model.modelKind === "chat" ? ` · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}` : ""}</button>${modelStatus}</div>`;
|
|
11649
12778
|
}).join("")}</div>
|
|
11650
12779
|
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-import-provider-models="${esc(provider.id)}">获取模型</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
11651
12780
|
}).join("")}</div>`
|
|
@@ -11769,7 +12898,7 @@ function renderTaskDefaults(models, providers, taskDefaults, settings, protocolO
|
|
|
11769
12898
|
<option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
|
|
11770
12899
|
${models.map((model) => {
|
|
11771
12900
|
const provider = providerById.get(model.providerId);
|
|
11772
|
-
const available = model
|
|
12901
|
+
const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
|
|
11773
12902
|
return `<option value="${esc(model.id)}" ${model.id === settings.titleGenerationModelId ? "selected" : ""} ${available || model.id === settings.titleGenerationModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
|
|
11774
12903
|
}).join("")}
|
|
11775
12904
|
</select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
|
|
@@ -11827,6 +12956,34 @@ function relationshipIndexStatusMarkup(status) {
|
|
|
11827
12956
|
</div>`;
|
|
11828
12957
|
}
|
|
11829
12958
|
|
|
12959
|
+
const semanticIndexStatusLabels = Object.freeze({
|
|
12960
|
+
disabled: "未开启",
|
|
12961
|
+
unconfigured: "配置不完整",
|
|
12962
|
+
idle: "等待重建",
|
|
12963
|
+
building: "正在构建",
|
|
12964
|
+
ready: "可以检索",
|
|
12965
|
+
failed: "部分失败",
|
|
12966
|
+
paused: "已暂停"
|
|
12967
|
+
});
|
|
12968
|
+
|
|
12969
|
+
function semanticIndexStatusMarkup(status = {}) {
|
|
12970
|
+
const progress = Math.min(100, Math.max(0, Number(status.progress) || 0));
|
|
12971
|
+
const statusName = semanticIndexStatusLabels[status.status] ?? "状态未知";
|
|
12972
|
+
const model = status.embeddingModel
|
|
12973
|
+
? `${status.embeddingModel.providerName} · ${status.embeddingModel.displayName}`
|
|
12974
|
+
: "尚未选择 embedding 模型";
|
|
12975
|
+
const rerank = status.rerankModel
|
|
12976
|
+
? `${status.rerankModel.providerName} · ${status.rerankModel.displayName}`
|
|
12977
|
+
: "未启用 rerank";
|
|
12978
|
+
return `<div class="semantic-index-summary">
|
|
12979
|
+
<div class="relationship-index-state-row"><span class="relationship-index-state is-${esc(String(status.status ?? "unknown"))}">${esc(statusName)}</span><span>Embedding <strong>${esc(model)}</strong></span><span>Rerank <strong>${esc(rerank)}</strong></span></div>
|
|
12980
|
+
<progress class="semantic-index-progress" max="100" value="${esc(String(progress))}" aria-label="RAG 构建进度 ${esc(String(progress))}%"></progress>
|
|
12981
|
+
<dl class="relationship-index-metrics"><div><dt>构建进度</dt><dd>${esc(String(progress))}%</dd></div><div><dt>已处理来源</dt><dd>${esc(String(status.processedSources ?? 0))} / ${esc(String(status.totalSources ?? 0))}</dd></div><div><dt>有效分片</dt><dd>${esc(String(status.indexedChunkCount ?? 0))}</dd></div><div><dt>失败来源</dt><dd>${esc(String(status.failedSources ?? 0))}</dd></div></dl>
|
|
12982
|
+
${status.error ? `<p class="relationship-index-error">${esc(status.error)}</p>` : ""}
|
|
12983
|
+
${status.status === "paused" ? `<p class="usage-measurement-note">已连续失败 ${esc(String(status.consecutiveFailures ?? 0))} 次。检查端点和模型后点击“完整重建 RAG”恢复。</p>` : ""}
|
|
12984
|
+
</div>`;
|
|
12985
|
+
}
|
|
12986
|
+
|
|
11830
12987
|
function updateBackgroundTaskCenterVisibility() {
|
|
11831
12988
|
const button = $("#background-task-button");
|
|
11832
12989
|
if (!button) return;
|
|
@@ -12395,6 +13552,10 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
12395
13552
|
<td>${esc(formatCacheHitRate(work.cacheHitRate))}</td>
|
|
12396
13553
|
<td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
|
|
12397
13554
|
</tr>`).join("");
|
|
13555
|
+
const callTypeLabels = { chat: "Chat / 分析", embedding: "Embedding", rerank: "Rerank" };
|
|
13556
|
+
const callTypeUsage = (Array.isArray(usage?.callTypes) ? usage.callTypes : [])
|
|
13557
|
+
.map((item) => `<span class="usage-call-type-chip"><strong>${esc(callTypeLabels[item.callType] ?? item.callType)}</strong><span>${esc(formatTokenCount(item.totalTokens))} Token · ${Number(item.requestCount || 0).toLocaleString("zh-CN")} 次</span></span>`)
|
|
13558
|
+
.join("");
|
|
12398
13559
|
return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
|
|
12399
13560
|
<div class="config-section-header usage-overview-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div><button class="ghost-button usage-details-button" type="button" data-token-usage-details aria-haspopup="dialog" aria-expanded="false" aria-controls="token-usage-details-toast">详细数据</button></div>
|
|
12400
13561
|
<div class="usage-stat-grid">
|
|
@@ -12404,6 +13565,7 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
12404
13565
|
<article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
|
|
12405
13566
|
</div>
|
|
12406
13567
|
<p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
|
|
13568
|
+
${callTypeUsage ? `<div class="usage-call-types" aria-label="按调用类型区分的 Token 用量">${callTypeUsage}</div>` : ""}
|
|
12407
13569
|
<section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
|
|
12408
13570
|
<header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
|
|
12409
13571
|
${tokenUsageCalendarMarkup(usage?.daily)}
|
|
@@ -12432,18 +13594,35 @@ async function renderBookAiSettings() {
|
|
|
12432
13594
|
clearTimeout(relationshipSearchIndexRefreshTimer);
|
|
12433
13595
|
relationshipSearchIndexRefreshTimer = null;
|
|
12434
13596
|
}
|
|
12435
|
-
|
|
13597
|
+
if (semanticSearchIndexRefreshTimer) {
|
|
13598
|
+
clearTimeout(semanticSearchIndexRefreshTimer);
|
|
13599
|
+
semanticSearchIndexRefreshTimer = null;
|
|
13600
|
+
}
|
|
13601
|
+
const [settings, providers, models, semanticModels, taskDefaults, relationshipIndex, semanticIndex, usage, protocolOptions, writeTools, remoteMcpSettings] = await Promise.all([
|
|
12436
13602
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
12437
13603
|
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
12438
13604
|
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
13605
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/semantic-models`),
|
|
12439
13606
|
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
12440
13607
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
|
|
13608
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/semantic-search-index`),
|
|
12441
13609
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
|
|
12442
|
-
moduleApi("ai-settings", "/api/platform/ai/protocols")
|
|
13610
|
+
moduleApi("ai-settings", "/api/platform/ai/protocols"),
|
|
13611
|
+
// 可写工具开关独立于 ai-settings 存储;加载失败时仍可展示其余配置。
|
|
13612
|
+
api(`/api/works/${state.work.id}/ai/tools`).catch(() => null),
|
|
13613
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/mcp-servers`)
|
|
12443
13614
|
]);
|
|
13615
|
+
const writeToolsState = writeTools?.tools ?? null;
|
|
13616
|
+
const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
|
|
12444
13617
|
const host = $("#module-content");
|
|
12445
13618
|
platformAiProtocolOptions = protocolOptions;
|
|
12446
13619
|
const workId = String(state.work.id);
|
|
13620
|
+
const remoteMcpConfigText = JSON.stringify(remoteMcpSettings?.config ?? { mcpServers: {} }, null, 2);
|
|
13621
|
+
const remoteMcpServers = Array.isArray(remoteMcpSettings?.servers) ? remoteMcpSettings.servers : [];
|
|
13622
|
+
const remoteMcpToolCount = Math.max(0, Number(remoteMcpSettings?.totalToolCount) || 0);
|
|
13623
|
+
const remoteMcpStatusText = remoteMcpServers.length > 0
|
|
13624
|
+
? `已验证 ${remoteMcpServers.length} 个远程 MCP Server,共发现 ${remoteMcpToolCount} 个工具。`
|
|
13625
|
+
: "尚未配置远程 MCP Server。";
|
|
12447
13626
|
const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
|
|
12448
13627
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
|
|
12449
13628
|
const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
|
|
@@ -12467,6 +13646,17 @@ async function renderBookAiSettings() {
|
|
|
12467
13646
|
title: "本书 Token 用量",
|
|
12468
13647
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
12469
13648
|
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
13649
|
+
const workSystemPromptSection = host.querySelector("#work-system-prompt")?.closest(".config-section");
|
|
13650
|
+
workSystemPromptSection?.insertAdjacentHTML("afterend", `<section class="config-section remote-mcp-settings"><div class="config-section-header"><div><h2>远程 MCP 工具</h2><p>填写标准的 <code>mcpServers</code> JSON 配置。保存前会逐个检查 JSON、远程传输、安全地址、MCP 握手与工具列表;任一 Server 失败时都不会覆盖当前配置。</p></div></div><label class="field-label remote-mcp-config-field"><span>mcpServers JSON</span><textarea id="remote-mcp-config" rows="12" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="remote-mcp-config-help remote-mcp-status" placeholder='{"mcpServers":{"example":{"url":"https://example.com/mcp"}}}'>${esc(remoteMcpConfigText)}</textarea></label><small id="remote-mcp-config-help" class="remote-mcp-config-help">仅支持远程 MCP 工具(SSE / Streamable HTTP),不支持会执行本地命令的 stdio 配置。敏感 Header 会加密保存,页面中的 ${esc("********")} 掩码再次保存时会保留原值。</small><p id="remote-mcp-status" class="remote-mcp-status" role="status" aria-live="polite">${esc(remoteMcpStatusText)}</p><div class="card-actions"><button id="save-remote-mcp-config" class="ghost-button config-save-button" type="button">测试并保存 MCP 配置</button></div></section>`);
|
|
13651
|
+
const semanticModelOptions = (kind, selectedId) => semanticModels
|
|
13652
|
+
.filter((model) => model.modelKind === kind)
|
|
13653
|
+
.map((model) => {
|
|
13654
|
+
const available = isAvailableConfiguredModel(model);
|
|
13655
|
+
return `<option value="${esc(model.id)}" ${model.id === selectedId ? "selected" : ""} ${available || model.id === selectedId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel(model)}`)}</option>`;
|
|
13656
|
+
}).join("");
|
|
13657
|
+
const semanticSection = `<section id="semantic-search-settings" class="config-section semantic-search-settings"><div class="config-section-header"><div><h2>主动语义检索(RAG)</h2><p>与拼音索引并列维护。Embedding 和 rerank 复用平台供应商的端点与凭证保险库;普通聊天、续写、润色和分析不会自动调用此通道。</p></div></div><div class="semantic-settings-grid"><label class="checkbox-field config-checkbox-field semantic-enabled-field"><input id="semantic-search-enabled" type="checkbox" ${settings.semanticSearchEnabled ? "checked" : ""}>启用主动语义检索</label><label>Embedding 模型<select id="semantic-embedding-model" aria-label="RAG Embedding 模型"><option value="">请选择 embedding 模型</option>${semanticModelOptions("embedding", settings.semanticEmbeddingModelId)}</select></label><label>Rerank 模型(可选)<select id="semantic-rerank-model" aria-label="RAG Rerank 模型"><option value="">不使用 rerank</option>${semanticModelOptions("rerank", settings.semanticRerankModelId)}</select></label><label>向量维度<input id="semantic-vector-dimension" type="number" min="1" max="65536" value="${esc(String(settings.semanticVectorDimension ?? 1024))}"></label><label>语义召回数量<input id="semantic-recall-limit" type="number" min="1" max="200" value="${esc(String(settings.semanticRecallLimit ?? 20))}"></label><label>展示结果数量<input id="semantic-result-limit" type="number" min="1" max="100" value="${esc(String(settings.semanticResultLimit ?? 12))}"></label><label>上下文预算(Token)<input id="semantic-budget-tokens" type="number" min="256" max="100000" value="${esc(String(settings.semanticBudgetTokens ?? 4000))}"></label><label>RRF 语义通道权重<input id="semantic-channel-weight" type="number" min="0.1" max="5" step="0.1" value="${esc(String(settings.semanticChannelWeight ?? 1))}"></label></div><p class="usage-measurement-note">API Key 由所选模型所属供应商的凭证保险库加密保存;此页面不会读取或返回明文密钥。更换端点、模型、维度或分片规则后必须完整重建,旧向量不会继续参与检索。</p><div id="semantic-search-index-status" role="status" aria-live="polite">${semanticIndexStatusMarkup(semanticIndex)}</div><div class="relationship-index-actions"><button id="save-semantic-search-settings" class="primary-button config-save-button" type="button">保存 RAG 配置</button><button id="sync-semantic-search-index" class="ghost-button config-save-button" type="button">同步增量</button><button id="refresh-semantic-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-semantic-search-index" class="ghost-button config-save-button" type="button">完整重建 RAG</button></div></section>`;
|
|
13658
|
+
const relationshipSection = [...host.querySelectorAll(".config-section")].find((section) => section.querySelector("h2")?.textContent === "人物关系拼音索引");
|
|
13659
|
+
relationshipSection?.insertAdjacentHTML("afterend", semanticSection);
|
|
12470
13660
|
bindTokenUsageDetails(host, usage, "本书 Token 用量");
|
|
12471
13661
|
const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
|
|
12472
13662
|
dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
|
|
@@ -12490,6 +13680,7 @@ async function renderBookAiSettings() {
|
|
|
12490
13680
|
host.querySelectorAll(".config-section").forEach((section) => {
|
|
12491
13681
|
if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
|
|
12492
13682
|
});
|
|
13683
|
+
host.insertAdjacentHTML("beforeend", `<section class="config-section"><div class="config-section-header"><div><h2>AI 可写工具</h2><p>默认全部关闭:逐项开启后,侧边栏 AI 才能在对应模块提交修改计划。计划只包含操作描述与 AI 简述;确认前系统会按当前数据库生成字段级明细(含修改前后值),执行时整体原子完成并再次校验权限、开关与目标版本,全程可在「AI 操作审批中心」追溯。AI 不能删除任何条目,也不能改写正文。</p></div><div class="card-actions"><button id="open-ai-approval-center-from-settings" class="ghost-button" type="button">打开 AI 操作审批中心</button></div></div><div class="ai-agent-tools ai-write-tools">${AI_WRITE_TOOLS_META.map((tool) => `<label><input name="ai-write-tool" type="checkbox" value="${esc(tool.id)}" ${writeToolsState?.[tool.id] === true ? "checked" : ""}><span><strong>${esc(tool.label)}</strong><small>${esc(tool.description)}</small></span></label>`).join("")}</div><p class="usage-measurement-note">${writeTools ? `当前单次审批最多 ${writeToolsMaxOperations} 个操作,可通过环境变量 AI_WRITE_PLAN_MAX_OPERATIONS 调整。` : "工具开关状态暂时无法加载,显示的勾选可能不是最新值。"}</p><div class="card-actions"><button id="save-ai-write-tools" class="ghost-button config-save-button" type="button">保存开关设置</button></div></section>`);
|
|
12493
13684
|
bindUsageCalendarInteractions(host);
|
|
12494
13685
|
scrollUsageCalendarsToLatest(host);
|
|
12495
13686
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
@@ -12500,6 +13691,10 @@ async function renderBookAiSettings() {
|
|
|
12500
13691
|
"afterend",
|
|
12501
13692
|
`<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
|
|
12502
13693
|
);
|
|
13694
|
+
host.querySelector('input[name="agent-tool"][value="read_character_sections"]').closest("label").insertAdjacentHTML(
|
|
13695
|
+
"afterend",
|
|
13696
|
+
`<label><input name="agent-tool" type="checkbox" value="semantic_search_story" ${agentTools.has("semantic_search_story") ? "checked" : ""} ${settings.semanticSearchEnabled ? "" : "disabled"}><span><strong>语义检索作品原文</strong><small>允许 Agent 显式调用 semantic_search_story;只影响保存后新建的对话,普通消息不会自动检索。</small></span></label>`
|
|
13697
|
+
);
|
|
12503
13698
|
host.querySelector(".ai-agent-tools").insertAdjacentHTML(
|
|
12504
13699
|
"beforeend",
|
|
12505
13700
|
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算两个 YYYY-MM-DD 日期之间的天数差,不读取作品内容。</small></span></label>`
|
|
@@ -12536,6 +13731,109 @@ async function renderBookAiSettings() {
|
|
|
12536
13731
|
return status;
|
|
12537
13732
|
};
|
|
12538
13733
|
updateRelationshipIndexStatus(relationshipIndex);
|
|
13734
|
+
const isCurrentSemanticIndexPanel = () => state.module === "ai-settings"
|
|
13735
|
+
&& String(state.work?.id ?? "") === workId
|
|
13736
|
+
&& Boolean($("#semantic-search-index-status"));
|
|
13737
|
+
const updateSemanticIndexStatus = (status) => {
|
|
13738
|
+
const statusHost = $("#semantic-search-index-status");
|
|
13739
|
+
if (!statusHost) return;
|
|
13740
|
+
statusHost.innerHTML = semanticIndexStatusMarkup(status);
|
|
13741
|
+
if (semanticSearchIndexRefreshTimer) clearTimeout(semanticSearchIndexRefreshTimer);
|
|
13742
|
+
semanticSearchIndexRefreshTimer = null;
|
|
13743
|
+
if (!["idle", "building"].includes(String(status.status))) return;
|
|
13744
|
+
semanticSearchIndexRefreshTimer = setTimeout(async () => {
|
|
13745
|
+
semanticSearchIndexRefreshTimer = null;
|
|
13746
|
+
if (!isCurrentSemanticIndexPanel()) return;
|
|
13747
|
+
try {
|
|
13748
|
+
const nextStatus = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
|
|
13749
|
+
if (isCurrentSemanticIndexPanel()) updateSemanticIndexStatus(nextStatus);
|
|
13750
|
+
} catch {
|
|
13751
|
+
// 后台轮询失败时保留当前进度,用户仍可手动刷新。
|
|
13752
|
+
}
|
|
13753
|
+
}, 1_200);
|
|
13754
|
+
};
|
|
13755
|
+
const refreshSemanticIndexStatus = async () => {
|
|
13756
|
+
const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
|
|
13757
|
+
updateSemanticIndexStatus(status);
|
|
13758
|
+
return status;
|
|
13759
|
+
};
|
|
13760
|
+
updateSemanticIndexStatus(semanticIndex);
|
|
13761
|
+
$("#save-semantic-search-settings").addEventListener("click", async () => {
|
|
13762
|
+
const button = $("#save-semantic-search-settings");
|
|
13763
|
+
const enabled = $("#semantic-search-enabled").checked;
|
|
13764
|
+
const embeddingModelId = $("#semantic-embedding-model").value || null;
|
|
13765
|
+
const vectorDimension = Number($("#semantic-vector-dimension").value);
|
|
13766
|
+
const recallLimit = Number($("#semantic-recall-limit").value);
|
|
13767
|
+
const resultLimit = Number($("#semantic-result-limit").value);
|
|
13768
|
+
const budgetTokens = Number($("#semantic-budget-tokens").value);
|
|
13769
|
+
const channelWeight = Number($("#semantic-channel-weight").value);
|
|
13770
|
+
if (enabled && !embeddingModelId) return toast("开启 RAG 前必须选择 embedding 模型", "error");
|
|
13771
|
+
if (!Number.isInteger(vectorDimension) || vectorDimension < 1 || vectorDimension > 65_536) return toast("向量维度必须是 1 到 65536 的整数", "error");
|
|
13772
|
+
if (!Number.isInteger(recallLimit) || recallLimit < 1 || recallLimit > 200) return toast("语义召回数量必须是 1 到 200 的整数", "error");
|
|
13773
|
+
if (!Number.isInteger(resultLimit) || resultLimit < 1 || resultLimit > 100) return toast("展示结果数量必须是 1 到 100 的整数", "error");
|
|
13774
|
+
if (!Number.isInteger(budgetTokens) || budgetTokens < 256 || budgetTokens > 100_000) return toast("语义上下文预算必须是 256 到 100000 Token", "error");
|
|
13775
|
+
if (!Number.isFinite(channelWeight) || channelWeight < 0.1 || channelWeight > 5) return toast("语义通道权重必须在 0.1 到 5 之间", "error");
|
|
13776
|
+
button.disabled = true;
|
|
13777
|
+
try {
|
|
13778
|
+
const updated = await api(`/api/works/${workId}/ai-settings/semantic-search`, {
|
|
13779
|
+
method: "PATCH",
|
|
13780
|
+
body: {
|
|
13781
|
+
enabled,
|
|
13782
|
+
embeddingModelId,
|
|
13783
|
+
rerankModelId: $("#semantic-rerank-model").value || null,
|
|
13784
|
+
vectorDimension,
|
|
13785
|
+
recallLimit,
|
|
13786
|
+
resultLimit,
|
|
13787
|
+
budgetTokens,
|
|
13788
|
+
channelWeight
|
|
13789
|
+
}
|
|
13790
|
+
});
|
|
13791
|
+
updateSemanticIndexStatus(updated.semanticIndex);
|
|
13792
|
+
toast(enabled ? "RAG 配置已保存;请执行完整重建" : "主动语义检索已关闭");
|
|
13793
|
+
await renderBookAiSettings();
|
|
13794
|
+
} catch (error) {
|
|
13795
|
+
toast(error.message, "error");
|
|
13796
|
+
button.disabled = false;
|
|
13797
|
+
}
|
|
13798
|
+
});
|
|
13799
|
+
$("#sync-semantic-search-index").addEventListener("click", async () => {
|
|
13800
|
+
const button = $("#sync-semantic-search-index");
|
|
13801
|
+
button.disabled = true;
|
|
13802
|
+
try {
|
|
13803
|
+
const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/sync`, { method: "POST" });
|
|
13804
|
+
updateSemanticIndexStatus({ ...status, status: "building" });
|
|
13805
|
+
toast("已开始同步 RAG 增量来源");
|
|
13806
|
+
} catch (error) {
|
|
13807
|
+
toast(error.message, "error");
|
|
13808
|
+
} finally {
|
|
13809
|
+
button.disabled = false;
|
|
13810
|
+
}
|
|
13811
|
+
});
|
|
13812
|
+
$("#refresh-semantic-search-index").addEventListener("click", async () => {
|
|
13813
|
+
const button = $("#refresh-semantic-search-index");
|
|
13814
|
+
button.disabled = true;
|
|
13815
|
+
try {
|
|
13816
|
+
await refreshSemanticIndexStatus();
|
|
13817
|
+
toast("RAG 状态已刷新", "info");
|
|
13818
|
+
} catch (error) {
|
|
13819
|
+
toast(error.message, "error");
|
|
13820
|
+
} finally {
|
|
13821
|
+
button.disabled = false;
|
|
13822
|
+
}
|
|
13823
|
+
});
|
|
13824
|
+
$("#rebuild-semantic-search-index").addEventListener("click", async () => {
|
|
13825
|
+
const button = $("#rebuild-semantic-search-index");
|
|
13826
|
+
button.disabled = true;
|
|
13827
|
+
try {
|
|
13828
|
+
const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/rebuild`, { method: "POST" });
|
|
13829
|
+
updateSemanticIndexStatus({ ...status, status: "building", progress: 0 });
|
|
13830
|
+
toast("已开始完整重建 RAG");
|
|
13831
|
+
} catch (error) {
|
|
13832
|
+
toast(error.message, "error");
|
|
13833
|
+
} finally {
|
|
13834
|
+
button.disabled = false;
|
|
13835
|
+
}
|
|
13836
|
+
});
|
|
12539
13837
|
const syncTokenQuotaWarnings = () => {
|
|
12540
13838
|
for (const period of ["daily", "monthly"]) {
|
|
12541
13839
|
const enabled = $(`#${period}-token-quota-enabled`).checked;
|
|
@@ -12628,6 +13926,39 @@ async function renderBookAiSettings() {
|
|
|
12628
13926
|
button.disabled = false;
|
|
12629
13927
|
}
|
|
12630
13928
|
});
|
|
13929
|
+
$("#save-remote-mcp-config").addEventListener("click", async () => {
|
|
13930
|
+
const button = $("#save-remote-mcp-config");
|
|
13931
|
+
const editor = $("#remote-mcp-config");
|
|
13932
|
+
const status = $("#remote-mcp-status");
|
|
13933
|
+
let configuration;
|
|
13934
|
+
try {
|
|
13935
|
+
configuration = JSON.parse(editor.value);
|
|
13936
|
+
} catch {
|
|
13937
|
+
toast("MCP 配置不是合法 JSON,请检查逗号、引号和括号", "error");
|
|
13938
|
+
editor.focus();
|
|
13939
|
+
return;
|
|
13940
|
+
}
|
|
13941
|
+
button.disabled = true;
|
|
13942
|
+
button.textContent = "正在测试连接…";
|
|
13943
|
+
status.textContent = "正在逐个验证远程 MCP Server 的地址、协议握手与工具列表,请稍候。";
|
|
13944
|
+
try {
|
|
13945
|
+
const saved = await api(`/api/works/${state.work.id}/ai-settings/mcp-servers`, {
|
|
13946
|
+
method: "PUT",
|
|
13947
|
+
body: configuration
|
|
13948
|
+
});
|
|
13949
|
+
const serverCount = Array.isArray(saved.servers) ? saved.servers.length : 0;
|
|
13950
|
+
const toolCount = Math.max(0, Number(saved.totalToolCount) || 0);
|
|
13951
|
+
toast(serverCount > 0
|
|
13952
|
+
? `已验证并保存 ${serverCount} 个 MCP Server,共 ${toolCount} 个工具`
|
|
13953
|
+
: "远程 MCP 配置已清空");
|
|
13954
|
+
await renderBookAiSettings();
|
|
13955
|
+
} catch (error) {
|
|
13956
|
+
status.textContent = "验证失败,当前已保存配置保持不变。";
|
|
13957
|
+
toast(error.message, "error");
|
|
13958
|
+
button.disabled = false;
|
|
13959
|
+
button.textContent = "测试并保存 MCP 配置";
|
|
13960
|
+
}
|
|
13961
|
+
});
|
|
12631
13962
|
$("#sync-relationship-search-index").addEventListener("click", async () => {
|
|
12632
13963
|
const button = $("#sync-relationship-search-index");
|
|
12633
13964
|
button.disabled = true;
|
|
@@ -12748,6 +14079,23 @@ async function renderBookAiSettings() {
|
|
|
12748
14079
|
button.disabled = false;
|
|
12749
14080
|
}
|
|
12750
14081
|
});
|
|
14082
|
+
$("#open-ai-approval-center-from-settings").addEventListener("click", () => openAiApprovalCenter());
|
|
14083
|
+
$("#save-ai-write-tools").addEventListener("click", async () => {
|
|
14084
|
+
const button = $("#save-ai-write-tools");
|
|
14085
|
+
button.disabled = true;
|
|
14086
|
+
try {
|
|
14087
|
+
const tools = {};
|
|
14088
|
+
host.querySelectorAll('input[name="ai-write-tool"]').forEach((input) => {
|
|
14089
|
+
tools[input.value] = input.checked;
|
|
14090
|
+
});
|
|
14091
|
+
await api(`/api/works/${state.work.id}/ai/tools`, { method: "PUT", body: { tools } });
|
|
14092
|
+
toast("AI 可写工具开关已保存");
|
|
14093
|
+
} catch (error) {
|
|
14094
|
+
toast(error.message, "error");
|
|
14095
|
+
} finally {
|
|
14096
|
+
button.disabled = false;
|
|
14097
|
+
}
|
|
14098
|
+
});
|
|
12751
14099
|
host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
|
|
12752
14100
|
const select = event.currentTarget;
|
|
12753
14101
|
select.disabled = true;
|
|
@@ -12838,28 +14186,33 @@ function currentAiRequestScope() {
|
|
|
12838
14186
|
if (!state.work) return null;
|
|
12839
14187
|
const selectedTaskType = $("#ai-task").value;
|
|
12840
14188
|
const roleplaySelected = selectedTaskType === "roleplay";
|
|
12841
|
-
const taskType =
|
|
12842
|
-
|
|
12843
|
-
|
|
12844
|
-
|
|
12845
|
-
|
|
12846
|
-
return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
|
|
12847
|
-
}
|
|
12848
|
-
const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
|
|
12849
|
-
const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
|
|
12850
|
-
if (requiresChapter && !state.chapter) return null;
|
|
12851
|
-
const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
|
|
14189
|
+
const taskType = "chat";
|
|
14190
|
+
const scopeType = state.aiPromptSent
|
|
14191
|
+
? null
|
|
14192
|
+
: roleplaySelected ? "none" : $("#ai-scope").value;
|
|
14193
|
+
if (!state.aiPromptSent && scopeType !== "none" && scopeType !== "settings-catalog" && !state.chapter) return null;
|
|
12852
14194
|
const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
|
|
12853
|
-
const
|
|
12854
|
-
|
|
12855
|
-
: scopeType === "none" ? { type: "none"
|
|
14195
|
+
const conversationScope = state.aiPromptSent
|
|
14196
|
+
? JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }))
|
|
14197
|
+
: scopeType === "none" ? { type: "none" }
|
|
12856
14198
|
: scopeType === "book" ? { type: "book" }
|
|
12857
14199
|
: scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
|
|
12858
14200
|
: scopeType === "settings-catalog" ? { type: "settings-catalog" }
|
|
12859
14201
|
: { type: "chapter", chapterId: state.chapter?.id };
|
|
12860
|
-
if (
|
|
14202
|
+
if (!state.aiPromptSent && scopeType === "chapter-summary") conversationScope.includeBookSummary = true;
|
|
12861
14203
|
conversationScope.includeSettingInfo = false;
|
|
12862
|
-
const
|
|
14204
|
+
const referencedScope = mergeAiReferenceScope(conversationScope, state.aiReferences);
|
|
14205
|
+
const chapterInput = state.chapter && !roleplaySelected ? $("#chapter-content") : null;
|
|
14206
|
+
const selectionStart = chapterInput?.selectionStart ?? 0;
|
|
14207
|
+
const selectionEnd = chapterInput?.selectionEnd ?? 0;
|
|
14208
|
+
const selection = chapterInput?.value.slice(selectionStart, selectionEnd) ?? "";
|
|
14209
|
+
const writingTarget = state.chapter && !roleplaySelected ? {
|
|
14210
|
+
chapterId: state.chapter.id,
|
|
14211
|
+
writingChapterVersion: state.chapter.versionNo,
|
|
14212
|
+
...(selection ? { selection, selectionStart, selectionEnd } : {})
|
|
14213
|
+
} : {};
|
|
14214
|
+
const scope = { ...referencedScope, ...writingTarget };
|
|
14215
|
+
if (state.aiSemanticSnapshot?.id) scope.semanticSnapshotId = state.aiSemanticSnapshot.id;
|
|
12863
14216
|
return { taskType, scope, conversationScope, selection };
|
|
12864
14217
|
}
|
|
12865
14218
|
|
|
@@ -12885,8 +14238,8 @@ function renderAiContextDistribution(usage) {
|
|
|
12885
14238
|
if (item.key === "skills" || item.key === "input" || item.key === "output") {
|
|
12886
14239
|
const description = document.createElement("small");
|
|
12887
14240
|
description.textContent = item.key === "skills"
|
|
12888
|
-
? "
|
|
12889
|
-
: item.key === "input" ? "用户和 agent 的交互" : "
|
|
14241
|
+
? item.tokens > 0 ? "按需加载" : "未加载"
|
|
14242
|
+
: item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
|
|
12890
14243
|
title.append(" ", description);
|
|
12891
14244
|
}
|
|
12892
14245
|
const value = document.createElement("strong");
|
|
@@ -13036,9 +14389,10 @@ function field(name, label, type = "text", value = "", options = []) {
|
|
|
13036
14389
|
return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
|
|
13037
14390
|
}
|
|
13038
14391
|
if (type === "keyword-chips") {
|
|
14392
|
+
const chipLabel = String(label).includes("关键词") ? "关键词" : label;
|
|
13039
14393
|
const values = uniqueRelationshipKeywords(Array.isArray(value) ? value : []);
|
|
13040
|
-
const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="
|
|
13041
|
-
return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="
|
|
14394
|
+
const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(chipLabel)}:${esc(keyword)}">×</button></span>`).join("");
|
|
14395
|
+
return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}" data-remove-label="${esc(chipLabel)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入${esc(chipLabel)}后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入${esc(chipLabel)}后按回车添加;也可用逗号一次添加多个。</small></div>`;
|
|
13042
14396
|
}
|
|
13043
14397
|
if (type === "key-value-list") {
|
|
13044
14398
|
const config = Array.isArray(options) ? {} : options;
|
|
@@ -13050,9 +14404,10 @@ function field(name, label, type = "text", value = "", options = []) {
|
|
|
13050
14404
|
const valueAriaLabel = config.valueAriaLabel ?? "扩展属性内容";
|
|
13051
14405
|
const removeLabel = config.removeLabel ?? "删除此扩展属性";
|
|
13052
14406
|
const addLabel = config.addLabel ?? "添加属性";
|
|
14407
|
+
const multilineValue = config.multilineValue ?? false;
|
|
13053
14408
|
const values = normalizeCharacterDetails(value);
|
|
13054
14409
|
const rows = values.length ? values : [{ label: "", value: "" }];
|
|
13055
|
-
return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}"
|
|
14410
|
+
return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}">${multilineValue ? `<textarea class="key-value-list-value" name="${esc(valueName)}" rows="1" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}" data-auto-grow>${esc(item.value)}</textarea>` : `<input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}">`}<button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
|
|
13056
14411
|
}
|
|
13057
14412
|
if (type === "section-list") {
|
|
13058
14413
|
const values = normalizeCharacterSections(value);
|
|
@@ -13125,6 +14480,10 @@ function renderKnowledgeMarkdownSections() {
|
|
|
13125
14480
|
}
|
|
13126
14481
|
|
|
13127
14482
|
function bindDynamicListControls(container) {
|
|
14483
|
+
resizeAutoGrowingTextareas(container);
|
|
14484
|
+
container.addEventListener("input", (event) => {
|
|
14485
|
+
if (event.target.matches?.("textarea[data-auto-grow]")) resizeAutoGrowingTextarea(event.target);
|
|
14486
|
+
});
|
|
13128
14487
|
container.querySelectorAll("[data-item-list-add]").forEach((button) => button.addEventListener("click", () => {
|
|
13129
14488
|
const rows = button.previousElementSibling;
|
|
13130
14489
|
const row = document.createElement("div");
|
|
@@ -13146,6 +14505,7 @@ function bindDynamicListControls(container) {
|
|
|
13146
14505
|
const row = rows.lastElementChild.cloneNode(true);
|
|
13147
14506
|
row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
|
|
13148
14507
|
rows.append(row);
|
|
14508
|
+
resizeAutoGrowingTextareas(row);
|
|
13149
14509
|
row.querySelector("input").focus();
|
|
13150
14510
|
}));
|
|
13151
14511
|
container.onclick = (event) => {
|
|
@@ -13153,21 +14513,65 @@ function bindDynamicListControls(container) {
|
|
|
13153
14513
|
if (!remove) return;
|
|
13154
14514
|
const row = remove.closest(".item-list-row, .structured-list-row");
|
|
13155
14515
|
const rows = row.parentElement;
|
|
13156
|
-
if (rows.children.length === 1)
|
|
14516
|
+
if (rows.children.length === 1) {
|
|
14517
|
+
row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
|
|
14518
|
+
resizeAutoGrowingTextareas(row);
|
|
14519
|
+
}
|
|
13157
14520
|
else row.remove();
|
|
13158
14521
|
};
|
|
13159
14522
|
}
|
|
13160
14523
|
|
|
14524
|
+
const supportsNativeTextareaContentSizing = typeof CSS !== "undefined" && CSS.supports("field-sizing", "content");
|
|
14525
|
+
|
|
14526
|
+
function resizeAutoGrowingTextarea(textarea) {
|
|
14527
|
+
if (supportsNativeTextareaContentSizing) {
|
|
14528
|
+
textarea.style.removeProperty("height");
|
|
14529
|
+
return;
|
|
14530
|
+
}
|
|
14531
|
+
textarea.style.height = "0px";
|
|
14532
|
+
const minimumHeight = Number.parseFloat(getComputedStyle(textarea).minHeight) || 0;
|
|
14533
|
+
textarea.style.height = `${Math.max(minimumHeight, textarea.scrollHeight)}px`;
|
|
14534
|
+
}
|
|
14535
|
+
|
|
14536
|
+
const autoGrowingTextareaWidths = new WeakMap();
|
|
14537
|
+
const autoGrowingTextareaObserver = typeof ResizeObserver === "function"
|
|
14538
|
+
? new ResizeObserver((entries) => {
|
|
14539
|
+
entries.forEach(({ target }) => {
|
|
14540
|
+
const width = target.getBoundingClientRect().width;
|
|
14541
|
+
if (autoGrowingTextareaWidths.get(target) === width) return;
|
|
14542
|
+
autoGrowingTextareaWidths.set(target, width);
|
|
14543
|
+
resizeAutoGrowingTextarea(target);
|
|
14544
|
+
});
|
|
14545
|
+
})
|
|
14546
|
+
: null;
|
|
14547
|
+
|
|
14548
|
+
function resizeAutoGrowingTextareas(container) {
|
|
14549
|
+
container?.querySelectorAll("textarea[data-auto-grow]").forEach((textarea) => {
|
|
14550
|
+
resizeAutoGrowingTextarea(textarea);
|
|
14551
|
+
autoGrowingTextareaObserver?.observe(textarea);
|
|
14552
|
+
});
|
|
14553
|
+
}
|
|
14554
|
+
|
|
14555
|
+
let autoGrowingTextareaResizeFrame = null;
|
|
14556
|
+
window.addEventListener("resize", () => {
|
|
14557
|
+
if (autoGrowingTextareaResizeFrame !== null) cancelAnimationFrame(autoGrowingTextareaResizeFrame);
|
|
14558
|
+
autoGrowingTextareaResizeFrame = requestAnimationFrame(() => {
|
|
14559
|
+
autoGrowingTextareaResizeFrame = null;
|
|
14560
|
+
resizeAutoGrowingTextareas(document);
|
|
14561
|
+
});
|
|
14562
|
+
});
|
|
14563
|
+
|
|
13161
14564
|
function appendRelationshipKeywordChips(editor, values) {
|
|
13162
14565
|
const input = editor.querySelector("[data-keyword-input]");
|
|
13163
14566
|
if (!input) return;
|
|
13164
14567
|
const existing = new Set([...editor.querySelectorAll("[data-keyword-value]")].map((control) => String(control.value).toLocaleLowerCase("zh-CN")));
|
|
13165
14568
|
const name = editor.dataset.name || "keywords";
|
|
14569
|
+
const removeLabel = editor.dataset.removeLabel || "关键词";
|
|
13166
14570
|
for (const keyword of uniqueRelationshipKeywords(values)) {
|
|
13167
14571
|
const key = keyword.toLocaleLowerCase("zh-CN");
|
|
13168
14572
|
if (existing.has(key)) continue;
|
|
13169
14573
|
existing.add(key);
|
|
13170
|
-
input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="
|
|
14574
|
+
input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(removeLabel)}:${esc(keyword)}">×</button></span>`);
|
|
13171
14575
|
}
|
|
13172
14576
|
}
|
|
13173
14577
|
|
|
@@ -13205,7 +14609,7 @@ function commitRelationshipKeywordInputs(container) {
|
|
|
13205
14609
|
container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
|
|
13206
14610
|
}
|
|
13207
14611
|
|
|
13208
|
-
function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
14612
|
+
async function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
13209
14613
|
formDialogVditors.forEach(destroyVditorEditor);
|
|
13210
14614
|
formDialogVditors = [];
|
|
13211
14615
|
void discardPendingMarkdownAttachments();
|
|
@@ -13255,6 +14659,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
13255
14659
|
dialog.classList.toggle("editor-dialog", Boolean(options.editor));
|
|
13256
14660
|
bindDynamicListControls($("#dialog-fields"));
|
|
13257
14661
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
14662
|
+
if ($("#dialog-fields").querySelector("[data-vditor-editor]") && !(await loadVditorResources())) return;
|
|
13258
14663
|
formDialogVditors = bindVditorEditors($("#dialog-fields"));
|
|
13259
14664
|
form.onclick = null;
|
|
13260
14665
|
form.onkeydown = null;
|
|
@@ -13498,18 +14903,36 @@ function openWorkSettingsDialog(work) {
|
|
|
13498
14903
|
<div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
|
|
13499
14904
|
<button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
|
|
13500
14905
|
</section>` : "";
|
|
14906
|
+
const editorPreferencesField = `<section class="work-access-field work-editor-preferences-field" aria-labelledby="work-editor-preferences-title">
|
|
14907
|
+
<div><strong id="work-editor-preferences-title">正文编辑辅助</strong><small>仅对当前作品生效。新建作品默认关闭,不影响其他作品或系统设置。</small></div>
|
|
14908
|
+
<div class="work-editor-preference-options" role="group" aria-labelledby="work-editor-preferences-title">
|
|
14909
|
+
<label class="work-editor-preference-option"><input name="editorAutoIndentEnabled" type="checkbox" ${work.editorAutoIndentEnabled ? "checked" : ""}><span><b>自动空两字</b><small>按 Enter 新建段落时自动插入两个全角空格。</small></span></label>
|
|
14910
|
+
<label class="work-editor-preference-option"><input name="editorTypewriterModeEnabled" type="checkbox" ${work.editorTypewriterModeEnabled ? "checked" : ""}><span><b>打字机模式</b><small>输入位置超过页面六成后,将当前行保持在页面中部。</small></span></label>
|
|
14911
|
+
</div>
|
|
14912
|
+
</section>`;
|
|
13501
14913
|
openDialog("作品信息",
|
|
13502
|
-
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
|
|
14914
|
+
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + editorPreferencesField + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
|
|
13503
14915
|
async (form) => {
|
|
13504
|
-
await api(`/api/works/${work.id}`, { method: "PATCH", body: {
|
|
14916
|
+
await api(`/api/works/${work.id}`, { method: "PATCH", body: {
|
|
14917
|
+
title: form.get("title"),
|
|
14918
|
+
author: form.get("author"),
|
|
14919
|
+
description: form.get("description"),
|
|
14920
|
+
editorAutoIndentEnabled: form.has("editorAutoIndentEnabled"),
|
|
14921
|
+
editorTypewriterModeEnabled: form.has("editorTypewriterModeEnabled")
|
|
14922
|
+
} });
|
|
13505
14923
|
state.works = (await apiPage("/api/works")).items;
|
|
13506
14924
|
const updated = state.works.find((item) => item.id === work.id);
|
|
13507
14925
|
if (updated) Object.assign(work, updated);
|
|
13508
14926
|
if (state.work?.id === work.id) {
|
|
13509
|
-
|
|
13510
|
-
|
|
13511
|
-
|
|
13512
|
-
|
|
14927
|
+
if (updated) Object.assign(state.work, updated);
|
|
14928
|
+
else {
|
|
14929
|
+
state.work.title = String(form.get("title") ?? state.work.title);
|
|
14930
|
+
state.work.author = String(form.get("author") ?? state.work.author);
|
|
14931
|
+
state.work.description = String(form.get("description") ?? state.work.description);
|
|
14932
|
+
state.work.editorAutoIndentEnabled = form.has("editorAutoIndentEnabled");
|
|
14933
|
+
state.work.editorTypewriterModeEnabled = form.has("editorTypewriterModeEnabled");
|
|
14934
|
+
}
|
|
14935
|
+
applyChapterEditorPreferences();
|
|
13513
14936
|
updateDocumentTitle(state.work);
|
|
13514
14937
|
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
13515
14938
|
}
|
|
@@ -13673,7 +15096,8 @@ function syncSettingEditorDirty(markdown = null) {
|
|
|
13673
15096
|
entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
|
|
13674
15097
|
}
|
|
13675
15098
|
|
|
13676
|
-
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
15099
|
+
async function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
15100
|
+
if (!(await loadVditorResources())) return;
|
|
13677
15101
|
entityEditorReadOnly = readOnly;
|
|
13678
15102
|
destroyVditorEditor(settingEditorVditor);
|
|
13679
15103
|
settingEditorVditor = null;
|
|
@@ -13688,7 +15112,7 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
13688
15112
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
13689
15113
|
const editButton = $("#setting-editor-edit");
|
|
13690
15114
|
editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
|
|
13691
|
-
editButton.onclick = () => openSettingEditor(settingEditorItem);
|
|
15115
|
+
editButton.onclick = () => { void openSettingEditor(settingEditorItem); };
|
|
13692
15116
|
bindEntityDetailPinButton($("#setting-editor-pin"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
|
|
13693
15117
|
settingEditorItem = updated;
|
|
13694
15118
|
state.settings = upsertEntityCollection(state.settings, updated);
|
|
@@ -13801,9 +15225,13 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
13801
15225
|
(readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
|
|
13802
15226
|
}
|
|
13803
15227
|
|
|
13804
|
-
function characterEditorSection(key, title, description, content) {
|
|
13805
|
-
|
|
13806
|
-
|
|
15228
|
+
function characterEditorSection(key, title, description, content, headerActions = "") {
|
|
15229
|
+
const hasHeaderActions = Boolean(headerActions);
|
|
15230
|
+
const header = hasHeaderActions
|
|
15231
|
+
? `<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>`
|
|
15232
|
+
: `<header><div><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3></div><p>${esc(description)}</p></header>`;
|
|
15233
|
+
return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}${hasHeaderActions ? " has-header-actions" : ""}" data-character-editor-panel="${esc(key)}" role="tabpanel">
|
|
15234
|
+
${header}
|
|
13807
15235
|
<div class="character-editor-section-fields">${content}</div>
|
|
13808
15236
|
</section>`;
|
|
13809
15237
|
}
|
|
@@ -13815,6 +15243,7 @@ function activateCharacterEditorTab(key) {
|
|
|
13815
15243
|
button.tabIndex = active ? 0 : -1;
|
|
13816
15244
|
});
|
|
13817
15245
|
document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
|
|
15246
|
+
resizeAutoGrowingTextareas(document.querySelector(`[data-character-editor-panel="${key}"]`));
|
|
13818
15247
|
if (
|
|
13819
15248
|
key === "relationships"
|
|
13820
15249
|
&& characterEditorItem?.id
|
|
@@ -13824,6 +15253,14 @@ function activateCharacterEditorTab(key) {
|
|
|
13824
15253
|
) {
|
|
13825
15254
|
void loadCharacterEditorRelationships(characterEditorItem.id);
|
|
13826
15255
|
}
|
|
15256
|
+
if (
|
|
15257
|
+
key === "roleplay-memory"
|
|
15258
|
+
&& characterEditorItem?.id
|
|
15259
|
+
&& !roleplayMemoryLoaded
|
|
15260
|
+
&& !roleplayMemoryLoading
|
|
15261
|
+
) {
|
|
15262
|
+
void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`角色扮演记忆加载失败:${error.message}`, "error"));
|
|
15263
|
+
}
|
|
13827
15264
|
}
|
|
13828
15265
|
|
|
13829
15266
|
function setCharacterHistoryVisible(visible) {
|
|
@@ -14052,12 +15489,69 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
|
|
|
14052
15489
|
};
|
|
14053
15490
|
}
|
|
14054
15491
|
|
|
15492
|
+
function loadVditorStylesheet() {
|
|
15493
|
+
const existing = document.getElementById("vditorStylesheet");
|
|
15494
|
+
if (existing?.dataset.loaded === "true") return Promise.resolve();
|
|
15495
|
+
return new Promise((resolve, reject) => {
|
|
15496
|
+
const link = existing ?? document.createElement("link");
|
|
15497
|
+
link.id = "vditorStylesheet";
|
|
15498
|
+
link.rel = "stylesheet";
|
|
15499
|
+
link.href = "/vendor/vditor/dist/index.css?v=3.11.2";
|
|
15500
|
+
link.addEventListener("load", () => {
|
|
15501
|
+
link.dataset.loaded = "true";
|
|
15502
|
+
resolve();
|
|
15503
|
+
}, { once: true });
|
|
15504
|
+
link.addEventListener("error", () => {
|
|
15505
|
+
link.remove();
|
|
15506
|
+
reject(new Error("Vditor stylesheet failed to load"));
|
|
15507
|
+
}, { once: true });
|
|
15508
|
+
if (!existing) document.head.append(link);
|
|
15509
|
+
});
|
|
15510
|
+
}
|
|
15511
|
+
|
|
15512
|
+
function loadVditorScript(id, src) {
|
|
15513
|
+
const existing = document.getElementById(id);
|
|
15514
|
+
if (existing?.dataset.loaded === "true") return Promise.resolve();
|
|
15515
|
+
return new Promise((resolve, reject) => {
|
|
15516
|
+
const script = existing ?? document.createElement("script");
|
|
15517
|
+
script.id = id;
|
|
15518
|
+
script.src = src;
|
|
15519
|
+
script.addEventListener("load", () => {
|
|
15520
|
+
script.dataset.loaded = "true";
|
|
15521
|
+
resolve();
|
|
15522
|
+
}, { once: true });
|
|
15523
|
+
script.addEventListener("error", () => {
|
|
15524
|
+
script.remove();
|
|
15525
|
+
reject(new Error(`Vditor script failed to load: ${id}`));
|
|
15526
|
+
}, { once: true });
|
|
15527
|
+
if (!existing) document.body.append(script);
|
|
15528
|
+
});
|
|
15529
|
+
}
|
|
15530
|
+
|
|
15531
|
+
async function loadVditorResources() {
|
|
15532
|
+
if (window.Vditor && document.getElementById("vditorStylesheet")) return true;
|
|
15533
|
+
if (!vditorResourcesPromise) {
|
|
15534
|
+
vditorResourcesPromise = Promise.all([
|
|
15535
|
+
loadVditorStylesheet(),
|
|
15536
|
+
loadVditorScript("vditorIconScript", "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"),
|
|
15537
|
+
loadVditorScript("vditorMainScript", "/vendor/vditor/dist/index.min.js?v=3.11.2")
|
|
15538
|
+
]).then(() => {
|
|
15539
|
+
if (!window.Vditor) throw new Error("Vditor constructor is unavailable");
|
|
15540
|
+
return true;
|
|
15541
|
+
}).catch(() => {
|
|
15542
|
+
vditorResourcesPromise = null;
|
|
15543
|
+
toast("Markdown 编辑器资源加载失败,请检查网络后重试", "error");
|
|
15544
|
+
return false;
|
|
15545
|
+
});
|
|
15546
|
+
}
|
|
15547
|
+
return vditorResourcesPromise;
|
|
15548
|
+
}
|
|
15549
|
+
|
|
14055
15550
|
function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
|
|
14056
15551
|
if (!window.Vditor) {
|
|
14057
15552
|
toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
|
|
14058
15553
|
return null;
|
|
14059
15554
|
}
|
|
14060
|
-
ensureVditorIconScript();
|
|
14061
15555
|
let editor = null;
|
|
14062
15556
|
editor = new window.Vditor(host, {
|
|
14063
15557
|
cdn: "/vendor/vditor",
|
|
@@ -14078,7 +15572,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
14078
15572
|
className: "vditor-line-number-button",
|
|
14079
15573
|
icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h3M4 9h3M4 13h3M4 17h3M10 5h10M10 9h10M10 13h10M10 17h10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/></svg>',
|
|
14080
15574
|
click: () => toggleVditorLineNumbers(editor)
|
|
14081
|
-
}, "edit-mode"
|
|
15575
|
+
}, "edit-mode"],
|
|
14082
15576
|
upload: {
|
|
14083
15577
|
accept: "image/*",
|
|
14084
15578
|
max: 10 * 1024 * 1024,
|
|
@@ -14291,14 +15785,6 @@ function normalizeVditorAttachmentImages(editor) {
|
|
|
14291
15785
|
});
|
|
14292
15786
|
}
|
|
14293
15787
|
|
|
14294
|
-
function ensureVditorIconScript() {
|
|
14295
|
-
if (document.getElementById("vditorIconScript")) return;
|
|
14296
|
-
const script = document.createElement("script");
|
|
14297
|
-
script.id = "vditorIconScript";
|
|
14298
|
-
script.src = "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2";
|
|
14299
|
-
document.body.appendChild(script);
|
|
14300
|
-
}
|
|
14301
|
-
|
|
14302
15788
|
function destroyVditorEditor(editor) {
|
|
14303
15789
|
if (!editor) return;
|
|
14304
15790
|
editor.__attachmentObserver?.disconnect();
|
|
@@ -14407,6 +15893,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
|
|
|
14407
15893
|
|
|
14408
15894
|
async function openKnowledgeSectionEditor(index = null) {
|
|
14409
15895
|
if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
|
|
15896
|
+
if (!(await loadVditorResources())) return;
|
|
14410
15897
|
const label = knowledgeEditorKind === "race" ? "种族" : "组织";
|
|
14411
15898
|
destroyVditorEditor(knowledgeSectionVditor);
|
|
14412
15899
|
knowledgeSectionVditor = null;
|
|
@@ -14519,6 +16006,7 @@ function syncCharacterSectionEditorDirty(markdown = null) {
|
|
|
14519
16006
|
}
|
|
14520
16007
|
|
|
14521
16008
|
async function openCharacterSectionEditor(section = null) {
|
|
16009
|
+
if (!(await loadVditorResources())) return;
|
|
14522
16010
|
await discardPendingCharacterAttachments();
|
|
14523
16011
|
destroyVditorEditor(characterSectionVditor);
|
|
14524
16012
|
characterSectionVditor = null;
|
|
@@ -14699,22 +16187,46 @@ function renderCharacterAvatar(item) {
|
|
|
14699
16187
|
}
|
|
14700
16188
|
}
|
|
14701
16189
|
|
|
16190
|
+
function roleplayMemoryToolbarMarkup() {
|
|
16191
|
+
return `<div class="roleplay-memory-toolbar">
|
|
16192
|
+
<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>
|
|
16193
|
+
<button id="roleplay-memory-add" class="primary-button" type="button">手工添加</button>
|
|
16194
|
+
</div>`;
|
|
16195
|
+
}
|
|
16196
|
+
|
|
16197
|
+
function roleplayMemorySurfaceMarkup() {
|
|
16198
|
+
return `<div id="character-roleplay-memory-surface" class="character-roleplay-memory-surface">
|
|
16199
|
+
<section id="roleplay-memory-filter-panel" class="roleplay-memory-filter-panel hidden" aria-label="角色扮演记忆筛选">
|
|
16200
|
+
<label for="roleplay-memory-search">搜索<input id="roleplay-memory-search" type="search" maxlength="200" placeholder="搜索事件、承诺、场景或角色状态"></label>
|
|
16201
|
+
<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>
|
|
16202
|
+
<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>
|
|
16203
|
+
<button id="roleplay-memory-filter-reset" class="ghost-button" type="button">重置筛选</button>
|
|
16204
|
+
</section>
|
|
16205
|
+
<div id="roleplay-memory-list" class="roleplay-memory-list" aria-live="polite"></div>
|
|
16206
|
+
<nav id="roleplay-memory-pagination" class="module-pagination roleplay-memory-pagination hidden" aria-label="角色扮演记忆分页">
|
|
16207
|
+
<button id="roleplay-memory-previous" type="button" disabled>上一页</button>
|
|
16208
|
+
<span id="roleplay-memory-page-label">第 1 页</span>
|
|
16209
|
+
<button id="roleplay-memory-next" type="button" disabled>下一页</button>
|
|
16210
|
+
</nav>
|
|
16211
|
+
</div>`;
|
|
16212
|
+
}
|
|
16213
|
+
|
|
14702
16214
|
function renderCharacterEditorFields(item) {
|
|
14703
16215
|
const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
|
|
14704
16216
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
14705
16217
|
const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
|
|
14706
16218
|
const stateEntries = characterStateEntries(item?.currentState ?? {});
|
|
16219
|
+
const raceField = !canReadModule("races")
|
|
16220
|
+
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
16221
|
+
: state.races.length
|
|
16222
|
+
? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
|
|
16223
|
+
: '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>';
|
|
14707
16224
|
$("#character-editor-fields").innerHTML = [
|
|
14708
16225
|
characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
|
|
14709
16226
|
`<div class="avatar-settings character-avatar-settings"><div id="character-avatar-preview" class="character-avatar character-avatar-editor-preview" role="img" aria-label="角色头像"></div><div class="avatar-settings-copy"><strong>角色头像</strong><small>支持 PNG、JPEG、WebP,文件不超过 2 MB。选择后可框选正方形选区再裁剪上传。</small></div><div class="avatar-settings-actions"><button id="character-avatar-upload-button" class="ghost-button" type="button">${item?.avatarUrl ? "更换头像" : "上传头像"}</button><button id="character-avatar-remove-button" class="ghost-button${item?.avatarUrl ? "" : " hidden"}" type="button">移除头像</button></div></div>` +
|
|
14710
|
-
|
|
16227
|
+
raceField +
|
|
14711
16228
|
field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
|
|
14712
|
-
field("aliases", "别名", "
|
|
14713
|
-
(!canReadModule("races")
|
|
14714
|
-
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
14715
|
-
: state.races.length
|
|
14716
|
-
? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
|
|
14717
|
-
: '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
|
|
16229
|
+
field("aliases", "别名", "keyword-chips", item?.aliases ?? []) +
|
|
14718
16230
|
(!canReadModule("organizations")
|
|
14719
16231
|
? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
|
|
14720
16232
|
: organizationOptions.length
|
|
@@ -14730,7 +16242,7 @@ function renderCharacterEditorFields(item) {
|
|
|
14730
16242
|
field("summary", "人物简介", "textarea", item?.profile?.summary) +
|
|
14731
16243
|
'<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
|
|
14732
16244
|
characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
|
|
14733
|
-
field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
|
|
16245
|
+
field("details", "扩展属性", "key-value-list", item?.attributes?.details, { multilineValue: true }) +
|
|
14734
16246
|
'<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
|
|
14735
16247
|
characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
|
|
14736
16248
|
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
@@ -14747,14 +16259,19 @@ function renderCharacterEditorFields(item) {
|
|
|
14747
16259
|
'<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
|
|
14748
16260
|
field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
|
|
14749
16261
|
characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
|
|
14750
|
-
'<div id="character-editor-relationships" class="character-editor-relationships-field"></div>')
|
|
16262
|
+
'<div id="character-editor-relationships" class="character-editor-relationships-field"></div>'),
|
|
16263
|
+
characterEditorSection("roleplay-memory", "角色扮演记忆", "该角色在作品内唯一、所有有权用户共享的非正史角色扮演记忆库。",
|
|
16264
|
+
item?.id
|
|
16265
|
+
? roleplayMemorySurfaceMarkup()
|
|
16266
|
+
: '<div class="character-editor-empty-field"><b>角色扮演记忆</b><span>保存角色卡后即可管理该角色的共享记忆库。</span></div>',
|
|
16267
|
+
item?.id ? roleplayMemoryToolbarMarkup() : "")
|
|
14751
16268
|
].join("");
|
|
14752
|
-
const name = $("#character-editor-fields [name='name']");
|
|
14753
|
-
if (name) name.required = true;
|
|
14754
16269
|
bindDynamicListControls($("#character-editor-fields"));
|
|
16270
|
+
bindRelationshipKeywordControls($("#character-editor-fields"));
|
|
14755
16271
|
renderCharacterAvatar(item);
|
|
14756
16272
|
renderCharacterEditorRelationships();
|
|
14757
16273
|
renderCharacterMarkdownSections();
|
|
16274
|
+
bindRoleplayMemorySurface(item);
|
|
14758
16275
|
activateCharacterEditorTab("basic");
|
|
14759
16276
|
}
|
|
14760
16277
|
|
|
@@ -14830,7 +16347,7 @@ function renderCharacterHistory() {
|
|
|
14830
16347
|
const restored = await api(`/api/characters/${characterEditorItem.id}/restore`, { method: "POST", body: { versionNo } });
|
|
14831
16348
|
characterEditorItem = restored;
|
|
14832
16349
|
renderCharacterEditorFields(restored);
|
|
14833
|
-
$("#character-editor-
|
|
16350
|
+
$("#character-editor-name").value = restored.name;
|
|
14834
16351
|
$("#character-editor-version").textContent = `v${restored.versionNo}`;
|
|
14835
16352
|
$("#character-change-note").value = "";
|
|
14836
16353
|
await Promise.all([renderCharacters(), loadAiReferences()]);
|
|
@@ -14869,7 +16386,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14869
16386
|
characterEditorRelationshipsLoaded = false;
|
|
14870
16387
|
characterEditorSections = [];
|
|
14871
16388
|
$("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
|
|
14872
|
-
$("#character-editor-
|
|
16389
|
+
$("#character-editor-name").value = item?.name ?? "";
|
|
14873
16390
|
$("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
|
|
14874
16391
|
$("#character-change-note").value = "";
|
|
14875
16392
|
$("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
|
|
@@ -14918,11 +16435,18 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14918
16435
|
setCharacterHistoryVisible(false);
|
|
14919
16436
|
renderCharacterEditorFields(item);
|
|
14920
16437
|
const viewOnly = readOnly || !canEditModule("characters");
|
|
16438
|
+
$("#character-editor-form").classList.toggle("is-read-only", viewOnly);
|
|
16439
|
+
$("#character-editor-name").readOnly = viewOnly;
|
|
16440
|
+
$("#character-editor-name").setAttribute("aria-readonly", String(viewOnly));
|
|
14921
16441
|
if (viewOnly) {
|
|
14922
16442
|
$("#character-editor-eyebrow").textContent = readOnly ? "阅读人物档案" : "人物档案";
|
|
14923
16443
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
14924
16444
|
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
14925
16445
|
$("#character-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
16446
|
+
if (item) {
|
|
16447
|
+
$("#character-roleplay-memory-surface")?.querySelectorAll("button, input, select").forEach((control) => { control.disabled = false; });
|
|
16448
|
+
renderRoleplayMemoryList();
|
|
16449
|
+
}
|
|
14926
16450
|
}
|
|
14927
16451
|
$("#character-change-note").readOnly = viewOnly;
|
|
14928
16452
|
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
@@ -14949,6 +16473,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14949
16473
|
const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
|
|
14950
16474
|
relationshipTab.disabled = !item || !canReadModule("relationships");
|
|
14951
16475
|
relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
|
|
16476
|
+
const roleplayMemoryTab = document.querySelector("[data-character-editor-tab='roleplay-memory']");
|
|
16477
|
+
roleplayMemoryTab.disabled = !item;
|
|
16478
|
+
roleplayMemoryTab.title = item ? "查看和管理该角色的共享角色扮演记忆" : "创建人物档案后即可管理角色扮演记忆";
|
|
14952
16479
|
const form = $("#character-editor-form");
|
|
14953
16480
|
form.onsubmit = async (event) => {
|
|
14954
16481
|
event.preventDefault();
|
|
@@ -14958,10 +16485,11 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14958
16485
|
busyTarget: form,
|
|
14959
16486
|
button: submit,
|
|
14960
16487
|
prepare: async () => {
|
|
16488
|
+
commitRelationshipKeywordInputs(form);
|
|
14961
16489
|
const body = collectCharacterBody(new FormData(form));
|
|
14962
16490
|
if (!body.name) {
|
|
14963
16491
|
toast("请填写角色标准名", "error");
|
|
14964
|
-
|
|
16492
|
+
$("#character-editor-name").focus();
|
|
14965
16493
|
return null;
|
|
14966
16494
|
}
|
|
14967
16495
|
const currentItem = characterEditorItem;
|
|
@@ -14975,7 +16503,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
14975
16503
|
state.characters = upsertEntityCollection(state.characters, saved);
|
|
14976
16504
|
entityEditorDirty = false;
|
|
14977
16505
|
renderCharacterAvatar(saved);
|
|
14978
|
-
$("#character-editor-
|
|
16506
|
+
$("#character-editor-name").value = saved.name;
|
|
14979
16507
|
$("#character-editor-version").textContent = `v${saved.versionNo}`;
|
|
14980
16508
|
$("#character-change-note").value = "";
|
|
14981
16509
|
$("#character-history-button").disabled = false;
|
|
@@ -15001,6 +16529,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
15001
16529
|
if (item) {
|
|
15002
16530
|
void loadCharacterMarkdownSections(item.id);
|
|
15003
16531
|
}
|
|
16532
|
+
(viewOnly ? $("#character-editor-close") : $("#character-editor-name")).focus();
|
|
15004
16533
|
}
|
|
15005
16534
|
|
|
15006
16535
|
function knowledgeEditorSection(key, title, description, content) {
|
|
@@ -16154,11 +17683,16 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
|
|
|
16154
17683
|
|
|
16155
17684
|
function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
|
|
16156
17685
|
const values = modelFormValues(item);
|
|
17686
|
+
const modelKindFields = `<div class="form-field model-kind-fields" role="group" aria-labelledby="model-kind-heading"><span id="model-kind-heading">专用模型类型</span><label class="checkbox-field model-capability-option"><input id="model-kind-embedding" name="embeddingModel" type="checkbox" ${values.modelKind === "embedding" ? "checked" : ""}><span><strong>这是一个 embedding 模型</strong><small>只用于语义向量,不会出现在 chat 框或 AI 分析任务中。</small></span></label><label class="checkbox-field model-capability-option"><input id="model-kind-rerank" name="rerankModel" type="checkbox" ${values.modelKind === "rerank" ? "checked" : ""}><span><strong>这是一个 rerank 模型</strong><small>只用于语义候选重排,不会出现在 chat 框或 AI 分析任务中。</small></span></label><small>两项都不勾选时,该模型按普通 chat 模型使用。</small></div>`;
|
|
16157
17687
|
const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
|
|
16158
17688
|
const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>支持多模态的接口协议都可以作为默认读图模型。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
|
|
16159
17689
|
const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
|
|
16160
17690
|
const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
|
|
16161
|
-
const connectionTestDescription = values.
|
|
17691
|
+
const connectionTestDescription = values.modelKind === "embedding"
|
|
17692
|
+
? "使用当前供应商凭据调用 OpenAI-compatible embeddings 接口并校验向量。"
|
|
17693
|
+
: values.modelKind === "rerank"
|
|
17694
|
+
? "使用 Qwen reranker 的 yes/no 模板发起最小相关性判定。"
|
|
17695
|
+
: values.multimodalEnabled && imageDefaultSupported
|
|
16162
17696
|
? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
|
|
16163
17697
|
: "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
|
|
16164
17698
|
const connectionTest = item && item.providerStatus === "enabled"
|
|
@@ -16167,8 +17701,9 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
16167
17701
|
<button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
|
|
16168
17702
|
</section>`
|
|
16169
17703
|
: "";
|
|
16170
|
-
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
16171
|
-
const
|
|
17704
|
+
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + modelKindFields + `<div data-chat-model-fields>` + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + `</div>` + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
17705
|
+
const modelKind = form.get("embeddingModel") === "on" ? "embedding" : form.get("rerankModel") === "on" ? "rerank" : "chat";
|
|
17706
|
+
const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), modelKind, purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
|
|
16172
17707
|
await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
|
|
16173
17708
|
await renderPlatformAiConfig();
|
|
16174
17709
|
await loadModels();
|
|
@@ -16186,6 +17721,17 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
16186
17721
|
const multimodalInput = $("#model-multimodal-enabled");
|
|
16187
17722
|
const imageDefaultField = $("#model-image-tool-default-field");
|
|
16188
17723
|
const imageDefaultInput = $("#model-image-tool-default");
|
|
17724
|
+
const embeddingModelInput = $("#model-kind-embedding");
|
|
17725
|
+
const rerankModelInput = $("#model-kind-rerank");
|
|
17726
|
+
const chatModelFields = $("#dialog-fields [data-chat-model-fields]");
|
|
17727
|
+
const syncModelKindFields = (changedInput = null) => {
|
|
17728
|
+
if (changedInput?.checked) {
|
|
17729
|
+
const other = changedInput === embeddingModelInput ? rerankModelInput : embeddingModelInput;
|
|
17730
|
+
if (other) other.checked = false;
|
|
17731
|
+
}
|
|
17732
|
+
const specialized = Boolean(embeddingModelInput?.checked || rerankModelInput?.checked);
|
|
17733
|
+
chatModelFields?.classList.toggle("hidden", specialized);
|
|
17734
|
+
};
|
|
16189
17735
|
const syncMultimodalFields = () => {
|
|
16190
17736
|
if (!multimodalInput || !imageDefaultField || !imageDefaultInput) return;
|
|
16191
17737
|
const hideImageDefault = !multimodalInput.checked || !imageDefaultSupported;
|
|
@@ -16212,6 +17758,8 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
16212
17758
|
contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
|
|
16213
17759
|
thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
|
|
16214
17760
|
multimodalInput?.addEventListener("change", syncMultimodalFields);
|
|
17761
|
+
embeddingModelInput?.addEventListener("change", () => syncModelKindFields(embeddingModelInput));
|
|
17762
|
+
rerankModelInput?.addEventListener("change", () => syncModelKindFields(rerankModelInput));
|
|
16215
17763
|
$("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
|
|
16216
17764
|
const button = event.currentTarget;
|
|
16217
17765
|
button.disabled = true;
|
|
@@ -16235,6 +17783,7 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
16235
17783
|
syncKimiTemperature();
|
|
16236
17784
|
syncThinkingEffort();
|
|
16237
17785
|
syncMultimodalFields();
|
|
17786
|
+
syncModelKindFields();
|
|
16238
17787
|
}
|
|
16239
17788
|
|
|
16240
17789
|
async function sendAi() {
|
|
@@ -16246,6 +17795,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16246
17795
|
const tab = activeAiChatTab();
|
|
16247
17796
|
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
16248
17797
|
if (aiRequestManager.hasActive(tab.id)) return;
|
|
17798
|
+
if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
|
|
16249
17799
|
const composerSnapshot = captureAiPromptComposer();
|
|
16250
17800
|
const requestComposerSnapshot = retry
|
|
16251
17801
|
? {
|
|
@@ -16270,8 +17820,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16270
17820
|
if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
|
|
16271
17821
|
const requestScope = currentAiRequestScope();
|
|
16272
17822
|
if (!requestScope) return toast("请先选择章节", "error");
|
|
16273
|
-
const {
|
|
16274
|
-
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
17823
|
+
const { scope } = requestScope;
|
|
16275
17824
|
const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
16276
17825
|
const selectedTaskType = $("#ai-task").value;
|
|
16277
17826
|
persistActiveAiChatTab();
|
|
@@ -16305,9 +17854,6 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16305
17854
|
if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
|
|
16306
17855
|
return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
|
|
16307
17856
|
}
|
|
16308
|
-
if (imageAttachmentIds.length > 0 && taskType !== "chat") {
|
|
16309
|
-
return toast("图片附件目前仅支持问答对话", "error");
|
|
16310
|
-
}
|
|
16311
17857
|
try {
|
|
16312
17858
|
await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
|
|
16313
17859
|
} catch (error) {
|
|
@@ -16316,86 +17862,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16316
17862
|
}
|
|
16317
17863
|
setAiChatTabStatus(tab, "streaming");
|
|
16318
17864
|
if (retry?.message?.isConnected) retry.message.remove();
|
|
16319
|
-
if (
|
|
16320
|
-
if (!retry) {
|
|
16321
|
-
try {
|
|
16322
|
-
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
16323
|
-
const persistedUserMessage = await persistAiConversationMessage(
|
|
16324
|
-
request.conversationId,
|
|
16325
|
-
"user",
|
|
16326
|
-
instruction,
|
|
16327
|
-
citations,
|
|
16328
|
-
{ modelId },
|
|
16329
|
-
{ signal: request.signal }
|
|
16330
|
-
);
|
|
16331
|
-
assertAiRequestCurrent(request);
|
|
16332
|
-
updateAiConversationSummaryFromMessage(persistedUserMessage);
|
|
16333
|
-
requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
|
|
16334
|
-
tab.modelId = modelId;
|
|
16335
|
-
tab.selectedModelId = modelId;
|
|
16336
|
-
tab.promptSent = true;
|
|
16337
|
-
clearAiChatTabComposer(tab);
|
|
16338
|
-
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
|
|
16339
|
-
if (isActiveAiChatTab(tab)) {
|
|
16340
|
-
state.aiConversationModelId = modelId;
|
|
16341
|
-
state.aiPromptSent = true;
|
|
16342
|
-
syncAiTaskOptions();
|
|
16343
|
-
renderAiRoleplayCharacterSelect();
|
|
16344
|
-
renderAiQuickActions();
|
|
16345
|
-
clearAiPromptComposer();
|
|
16346
|
-
}
|
|
16347
|
-
} catch (error) {
|
|
16348
|
-
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
16349
|
-
setAiChatTabStatus(tab, "error");
|
|
16350
|
-
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
16351
|
-
}
|
|
16352
|
-
} else {
|
|
16353
|
-
prepareAiRetryState(tab, modelId);
|
|
16354
|
-
}
|
|
16355
|
-
} else if (retry) {
|
|
16356
|
-
prepareAiRetryState(tab, modelId);
|
|
16357
|
-
}
|
|
17865
|
+
if (retry) prepareAiRetryState(tab, modelId);
|
|
16358
17866
|
let assistantContent = "";
|
|
16359
17867
|
let assistantMessage;
|
|
16360
17868
|
let assistantMetadata = {};
|
|
16361
17869
|
let persistedStreamMessage = null;
|
|
16362
|
-
let
|
|
16363
|
-
|
|
16364
|
-
|
|
16365
|
-
|
|
16366
|
-
|
|
16367
|
-
|
|
16368
|
-
|
|
16369
|
-
|
|
16370
|
-
|
|
16371
|
-
|
|
16372
|
-
|
|
16373
|
-
|
|
16374
|
-
|
|
16375
|
-
|
|
16376
|
-
|
|
16377
|
-
|
|
16378
|
-
|
|
16379
|
-
|
|
16380
|
-
|
|
16381
|
-
|
|
16382
|
-
} else {
|
|
16383
|
-
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
16384
|
-
suggestion = await api(`/api/works/${encodeURIComponent(request.workId)}/suggestions`, {
|
|
16385
|
-
method: "POST",
|
|
16386
|
-
body: { taskType, instruction, scope, modelId, citations, conversationId: requestHolder.snapshot.conversationId },
|
|
16387
|
-
signal: request.signal
|
|
16388
|
-
});
|
|
16389
|
-
assertAiRequestCurrent(request);
|
|
16390
|
-
const suggestionFailed = suggestion.guard?.status === "failed"
|
|
16391
|
-
|| suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
16392
|
-
|| suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
16393
|
-
if (suggestionFailed) setAiChatTabStatus(tab, "error");
|
|
16394
|
-
tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
|
|
16395
|
-
if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
|
|
16396
|
-
assistantContent = suggestion.content;
|
|
16397
|
-
assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
|
|
16398
|
-
}
|
|
17870
|
+
let writingSuggestion = null;
|
|
17871
|
+
const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
|
|
17872
|
+
instruction,
|
|
17873
|
+
...(sceneDirection ? { sceneDirection } : {}),
|
|
17874
|
+
...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
|
|
17875
|
+
scope,
|
|
17876
|
+
modelId,
|
|
17877
|
+
citations,
|
|
17878
|
+
...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
|
|
17879
|
+
conversationId: requestHolder.snapshot.conversationId,
|
|
17880
|
+
...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
|
|
17881
|
+
}, retry), createAiIdempotencyKey());
|
|
17882
|
+
const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
|
|
17883
|
+
if (streamed.action === "warn") return;
|
|
17884
|
+
assistantContent = streamed.content;
|
|
17885
|
+
assistantMessage = streamed.message;
|
|
17886
|
+
assistantMetadata = streamed.metadata;
|
|
17887
|
+
writingSuggestion = streamed.writingSuggestion;
|
|
17888
|
+
persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
|
|
17889
|
+
applyAiConversationTitle(streamed.conversationTitle, streamedRequest.conversationId);
|
|
16399
17890
|
try {
|
|
16400
17891
|
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
16401
17892
|
if (persistedStreamMessage) {
|
|
@@ -16414,19 +17905,19 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16414
17905
|
assistantContent,
|
|
16415
17906
|
[],
|
|
16416
17907
|
assistantMetadata,
|
|
16417
|
-
{ signal: request.signal, requestId:
|
|
17908
|
+
{ signal: request.signal, requestId: aiAssistantRequestId(request) }
|
|
16418
17909
|
);
|
|
16419
17910
|
assertAiRequestCurrent(request);
|
|
16420
17911
|
updateAiConversationSummaryFromMessage(persistedAssistantMessage);
|
|
16421
17912
|
if (assistantMessage) {
|
|
16422
17913
|
updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
|
|
16423
17914
|
attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
|
|
16424
|
-
}
|
|
17915
|
+
}
|
|
16425
17916
|
}
|
|
17917
|
+
if (writingSuggestion && assistantMessage) attachWritingSuggestion(assistantMessage, writingSuggestion, { tab });
|
|
16426
17918
|
} catch (error) {
|
|
16427
17919
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
16428
17920
|
setAiChatTabStatus(tab, "error");
|
|
16429
|
-
if (suggestion) appendSuggestion(suggestion, null, null, { tab });
|
|
16430
17921
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
16431
17922
|
}
|
|
16432
17923
|
} catch (error) {
|
|
@@ -16509,7 +18000,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
16509
18000
|
failureMessage,
|
|
16510
18001
|
[],
|
|
16511
18002
|
{},
|
|
16512
|
-
{ requestId:
|
|
18003
|
+
{ requestId: aiAssistantRequestId(request) }
|
|
16513
18004
|
);
|
|
16514
18005
|
updateAiConversationSummaryFromMessage(persistedFailureMessage);
|
|
16515
18006
|
} catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
@@ -16589,6 +18080,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16589
18080
|
let persistedMessageId = null;
|
|
16590
18081
|
let persistedMessageCreatedAt = null;
|
|
16591
18082
|
let conversationTitle = null;
|
|
18083
|
+
let writingSuggestion = null;
|
|
16592
18084
|
let persistedUserMessage = null;
|
|
16593
18085
|
let contextAction = "ready";
|
|
16594
18086
|
let warningOnly = false;
|
|
@@ -16727,6 +18219,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16727
18219
|
toolCalls.push(toolCall);
|
|
16728
18220
|
processSteps.push(aiToolProcessStep(toolCall, round));
|
|
16729
18221
|
renderStreamingProcessSteps(false, elapsedProcessTime());
|
|
18222
|
+
handleInteractiveToolCallEvent(toolCall);
|
|
16730
18223
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
16731
18224
|
scrollAiFeedToBottom(feed);
|
|
16732
18225
|
} else if (eventName === "context_compacted") {
|
|
@@ -16743,6 +18236,13 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16743
18236
|
persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
16744
18237
|
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
16745
18238
|
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
18239
|
+
writingSuggestion = payload.writingSuggestion && typeof payload.writingSuggestion === "object"
|
|
18240
|
+
? payload.writingSuggestion
|
|
18241
|
+
: null;
|
|
18242
|
+
const writingSuggestionFailed = writingSuggestion?.guard?.status === "failed"
|
|
18243
|
+
|| writingSuggestion?.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
18244
|
+
|| writingSuggestion?.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
18245
|
+
if (writingSuggestionFailed) setAiChatTabStatus(tab, "error");
|
|
16746
18246
|
const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
|
|
16747
18247
|
setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
|
|
16748
18248
|
await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
|
|
@@ -16755,7 +18255,18 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16755
18255
|
const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
|
|
16756
18256
|
? payload.processDurationMs
|
|
16757
18257
|
: elapsedProcessTime();
|
|
16758
|
-
generatedMetadata = {
|
|
18258
|
+
generatedMetadata = {
|
|
18259
|
+
modelDisplayName: payload.model?.displayName,
|
|
18260
|
+
outputTokens: payload.outputTokens,
|
|
18261
|
+
cacheHitPercent: payload.cacheHitPercent,
|
|
18262
|
+
toolCalls,
|
|
18263
|
+
processSteps,
|
|
18264
|
+
processDurationMs,
|
|
18265
|
+
...(writingSuggestion ? {
|
|
18266
|
+
activeSkills: [writingSuggestion.taskType === "continue" ? "continue-writing" : "polish-writing"],
|
|
18267
|
+
writingSuggestionId: writingSuggestion.id
|
|
18268
|
+
} : {})
|
|
18269
|
+
};
|
|
16759
18270
|
renderAiProcessSteps(message, processSteps, true, processDurationMs);
|
|
16760
18271
|
meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
|
|
16761
18272
|
attachAssistantCopyAction(message, streamedText);
|
|
@@ -16772,18 +18283,21 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
16772
18283
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
16773
18284
|
if (streamError) throw streamError;
|
|
16774
18285
|
assertAiStreamCompleted(streamCompleted);
|
|
16775
|
-
return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
|
|
18286
|
+
return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, writingSuggestion, userMessage: persistedUserMessage };
|
|
16776
18287
|
} catch (error) {
|
|
16777
18288
|
const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
|
|
16778
18289
|
const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
|
|
16779
|
-
const
|
|
18290
|
+
const hasRenderableProcessSteps = processSteps.some(shouldRenderAiProcessStep);
|
|
18291
|
+
const interruption = streamedText || hasRenderableProcessSteps ? {
|
|
16780
18292
|
content: streamedText,
|
|
16781
18293
|
message,
|
|
16782
18294
|
metadata: {
|
|
16783
18295
|
interrupted: true,
|
|
16784
18296
|
interruptionCode,
|
|
16785
18297
|
interruptionMessage: streamFailure.message.slice(0, 500),
|
|
16786
|
-
processDurationMs: Math.min(86_400_000, elapsedProcessTime())
|
|
18298
|
+
processDurationMs: Math.min(86_400_000, elapsedProcessTime()),
|
|
18299
|
+
...(toolCalls.length ? { toolCalls } : {}),
|
|
18300
|
+
...(processSteps.length ? { processSteps } : {})
|
|
16787
18301
|
}
|
|
16788
18302
|
} : null;
|
|
16789
18303
|
if (interruption) streamFailure.streamInterruption = interruption;
|
|
@@ -16966,46 +18480,91 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
16966
18480
|
if (isFailure) renderMessageCardActions(message);
|
|
16967
18481
|
attachMessageIdentity(message, messageId);
|
|
16968
18482
|
feed.append(message);
|
|
18483
|
+
const writingSuggestionId = role === "assistant" && typeof metadata?.writingSuggestionId === "string"
|
|
18484
|
+
? metadata.writingSuggestionId
|
|
18485
|
+
: "";
|
|
18486
|
+
if (writingSuggestionId && !isFailure && !isInterrupted) {
|
|
18487
|
+
api(`/api/suggestions/${encodeURIComponent(writingSuggestionId)}`)
|
|
18488
|
+
.then((suggestion) => attachWritingSuggestion(message, suggestion, { tab }))
|
|
18489
|
+
.catch(() => undefined);
|
|
18490
|
+
}
|
|
16969
18491
|
scrollAiFeedToBottom(feed);
|
|
18492
|
+
return message;
|
|
16970
18493
|
}
|
|
16971
18494
|
|
|
16972
|
-
function
|
|
16973
|
-
|
|
16974
|
-
const
|
|
16975
|
-
|
|
16976
|
-
|
|
16977
|
-
|
|
16978
|
-
|
|
16979
|
-
const
|
|
16980
|
-
|
|
16981
|
-
|
|
16982
|
-
|
|
16983
|
-
|
|
16984
|
-
|
|
16985
|
-
|
|
18495
|
+
function continuationGuardMarkup(guard) {
|
|
18496
|
+
if (!guard) return "";
|
|
18497
|
+
const issues = Array.isArray(guard.issues) ? guard.issues : [];
|
|
18498
|
+
return `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>`;
|
|
18499
|
+
}
|
|
18500
|
+
|
|
18501
|
+
async function applyAcceptedWritingSuggestion(message, suggestion) {
|
|
18502
|
+
const result = await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/accept`, { method: "POST", body: {} });
|
|
18503
|
+
state.chapter = result.chapter;
|
|
18504
|
+
resetChapterDraftLineIds(state.chapter);
|
|
18505
|
+
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
18506
|
+
$("#chapter-content").value = state.chapter.content;
|
|
18507
|
+
scheduleChapterLineNumbers();
|
|
18508
|
+
updateChapterStats();
|
|
18509
|
+
state.work = await api(`/api/works/${state.work.id}`);
|
|
18510
|
+
renderTree();
|
|
18511
|
+
message.querySelector("[data-writing-suggestion-actions]").innerHTML = "<span>已采纳并生成新版本</span>";
|
|
18512
|
+
toast("AI 建议已采纳,正文已生成新版本");
|
|
18513
|
+
}
|
|
18514
|
+
|
|
18515
|
+
function attachWritingSuggestion(message, suggestion, options = {}) {
|
|
18516
|
+
if (!suggestion || suggestion.action === "note" || !suggestion.id) return message;
|
|
18517
|
+
const suggestionId = String(suggestion.id);
|
|
18518
|
+
if (message.dataset.writingSuggestionId === suggestionId) return message;
|
|
18519
|
+
message.dataset.writingSuggestionId = suggestionId;
|
|
18520
|
+
message.querySelector("[data-writing-suggestion-ui]")?.remove();
|
|
18521
|
+
const heading = message.querySelector(".message-heading > span");
|
|
18522
|
+
if (heading) heading.textContent = "助手建议";
|
|
18523
|
+
const host = document.createElement("div");
|
|
18524
|
+
host.dataset.writingSuggestionUi = "";
|
|
18525
|
+
host.className = "writing-suggestion-ui";
|
|
18526
|
+
host.innerHTML = `${continuationGuardMarkup(suggestion.guard)}<div class="message-actions" data-writing-suggestion-actions></div>`;
|
|
18527
|
+
const actions = host.querySelector("[data-writing-suggestion-actions]");
|
|
18528
|
+
if (suggestion.status === "accepted") {
|
|
18529
|
+
actions.innerHTML = "<span>已采纳并生成新版本</span>";
|
|
18530
|
+
} else if (suggestion.status === "rejected") {
|
|
18531
|
+
actions.innerHTML = "<span>已拒绝</span>";
|
|
18532
|
+
} else {
|
|
18533
|
+
actions.innerHTML = '<button type="button" data-action="accept">采纳到正文</button><button type="button" data-action="reject">拒绝</button>';
|
|
18534
|
+
actions.querySelector('[data-action="accept"]').addEventListener("click", async () => {
|
|
16986
18535
|
try {
|
|
16987
|
-
|
|
16988
|
-
|
|
16989
|
-
|
|
16990
|
-
|
|
16991
|
-
scheduleChapterLineNumbers();
|
|
16992
|
-
updateChapterStats();
|
|
16993
|
-
state.work = await api(`/api/works/${state.work.id}`);
|
|
16994
|
-
renderTree();
|
|
16995
|
-
message.querySelector(".message-actions").innerHTML = "<span>已采纳并生成新版本</span>";
|
|
16996
|
-
toast("AI 建议已采纳,正文已生成新版本");
|
|
16997
|
-
} catch (error) { toast(error.message, "error"); }
|
|
18536
|
+
await applyAcceptedWritingSuggestion(message, suggestion);
|
|
18537
|
+
} catch (error) {
|
|
18538
|
+
toast(error.message, "error");
|
|
18539
|
+
}
|
|
16998
18540
|
});
|
|
16999
|
-
|
|
17000
|
-
|
|
17001
|
-
|
|
18541
|
+
actions.querySelector('[data-action="reject"]').addEventListener("click", async () => {
|
|
18542
|
+
try {
|
|
18543
|
+
await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/reject`, { method: "POST", body: {} });
|
|
18544
|
+
actions.innerHTML = "<span>已拒绝</span>";
|
|
18545
|
+
} catch (error) {
|
|
18546
|
+
toast(error.message, "error");
|
|
18547
|
+
}
|
|
17002
18548
|
});
|
|
17003
18549
|
}
|
|
17004
|
-
|
|
17005
|
-
|
|
18550
|
+
message.append(host);
|
|
18551
|
+
const tab = options.tab ?? activeAiChatTab();
|
|
18552
|
+
scrollAiFeedToBottom(options.feed ?? tab?.feed ?? $("#ai-feed"));
|
|
17006
18553
|
return message;
|
|
17007
18554
|
}
|
|
17008
18555
|
|
|
18556
|
+
function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
|
|
18557
|
+
const tab = options.tab ?? activeAiChatTab();
|
|
18558
|
+
const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
|
|
18559
|
+
const message = appendMessage("assistant", suggestion.content, [], createdAt, {
|
|
18560
|
+
modelDisplayName: suggestion.model?.displayName,
|
|
18561
|
+
outputTokens: suggestion.outputTokens,
|
|
18562
|
+
cacheHitPercent: suggestion.cacheHitPercent,
|
|
18563
|
+
processDurationMs: suggestion.processDurationMs
|
|
18564
|
+
}, messageId, { tab, feed });
|
|
18565
|
+
return attachWritingSuggestion(message, suggestion, { tab, feed });
|
|
18566
|
+
}
|
|
18567
|
+
|
|
17009
18568
|
function chapterVersionCompareOption(version) {
|
|
17010
18569
|
return `<option value="version:${Number(version.versionNo)}">v${Number(version.versionNo)} · ${esc(chapterVersionSourceLabel(version.source))}</option>`;
|
|
17011
18570
|
}
|
|
@@ -17079,6 +18638,7 @@ async function showVersions() {
|
|
|
17079
18638
|
}
|
|
17080
18639
|
try {
|
|
17081
18640
|
state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
|
|
18641
|
+
resetChapterDraftLineIds(state.chapter);
|
|
17082
18642
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
17083
18643
|
$("#chapter-title").value = state.chapter.title;
|
|
17084
18644
|
$("#chapter-content").value = state.chapter.content;
|
|
@@ -18436,16 +19996,15 @@ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
|
|
|
18436
19996
|
renderChapterForeshadowReminder();
|
|
18437
19997
|
$("#chapter-foreshadow-reminder-details-button").focus();
|
|
18438
19998
|
});
|
|
18439
|
-
$("#chapter-
|
|
18440
|
-
if (state.chapter) void deleteChapter(state.chapter.id);
|
|
18441
|
-
});
|
|
18442
|
-
$("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
|
|
19999
|
+
$("#chapter-edit-button").addEventListener("click", toggleChapterEditPreviewMode);
|
|
18443
20000
|
$("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
|
|
18444
20001
|
$("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
18445
20002
|
$("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
|
|
18446
20003
|
$("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
|
|
18447
20004
|
$("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
|
|
18448
20005
|
$("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
|
|
20006
|
+
$("#chapter-batch-template").addEventListener("input", updateChapterBatchControls);
|
|
20007
|
+
$("#chapter-batch-start").addEventListener("input", updateChapterBatchControls);
|
|
18449
20008
|
$("#chapter-batch-search").addEventListener("input", renderChapterBatchDialog);
|
|
18450
20009
|
$("#chapter-batch-select-all").addEventListener("click", () => {
|
|
18451
20010
|
const searchQuery = $("#chapter-batch-search").value.trim().toLocaleLowerCase("zh-CN");
|
|
@@ -18484,7 +20043,7 @@ $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
|
18484
20043
|
$("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
18485
20044
|
$("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
18486
20045
|
$("#character-editor-fields").addEventListener("click", (event) => {
|
|
18487
|
-
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
|
|
20046
|
+
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove], [data-keyword-chip-remove]")) markEntityEditorDirty();
|
|
18488
20047
|
const uploadButton = event.target.closest("#character-avatar-upload-button");
|
|
18489
20048
|
if (uploadButton) {
|
|
18490
20049
|
if (!characterEditorItem?.id) {
|
|
@@ -18552,11 +20111,59 @@ $("#appearance-form").addEventListener("submit", (event) => {
|
|
|
18552
20111
|
toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
|
|
18553
20112
|
});
|
|
18554
20113
|
$("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
|
|
18555
|
-
$("#chapter-content").addEventListener("
|
|
20114
|
+
$("#chapter-content").addEventListener("beforeinput", (event) => {
|
|
20115
|
+
if (!state.chapter) return;
|
|
20116
|
+
const input = event.currentTarget;
|
|
20117
|
+
syncChapterDraftLineIds(input.value);
|
|
20118
|
+
chapterBeforeInputState = {
|
|
20119
|
+
chapterId: state.chapter.id,
|
|
20120
|
+
content: input.value,
|
|
20121
|
+
selectionStart: input.selectionStart,
|
|
20122
|
+
selectionEnd: input.selectionEnd,
|
|
20123
|
+
inputType: event.inputType
|
|
20124
|
+
};
|
|
20125
|
+
});
|
|
20126
|
+
$("#chapter-content").addEventListener("keydown", (event) => {
|
|
20127
|
+
const input = event.currentTarget;
|
|
20128
|
+
if (
|
|
20129
|
+
event.key !== "Enter"
|
|
20130
|
+
|| !state.work?.editorAutoIndentEnabled
|
|
20131
|
+
|| event.isComposing
|
|
20132
|
+
|| event.altKey
|
|
20133
|
+
|| event.ctrlKey
|
|
20134
|
+
|| event.metaKey
|
|
20135
|
+
|| !state.chapter
|
|
20136
|
+
|| input.readOnly
|
|
20137
|
+
) return;
|
|
20138
|
+
event.preventDefault();
|
|
20139
|
+
syncChapterDraftLineIds(input.value);
|
|
20140
|
+
chapterBeforeInputState = {
|
|
20141
|
+
chapterId: state.chapter.id,
|
|
20142
|
+
content: input.value,
|
|
20143
|
+
selectionStart: input.selectionStart,
|
|
20144
|
+
selectionEnd: input.selectionEnd,
|
|
20145
|
+
inputType: "insertLineBreak"
|
|
20146
|
+
};
|
|
20147
|
+
const next = insertIndentedParagraph(input.value, input.selectionStart, input.selectionEnd);
|
|
20148
|
+
input.setRangeText(`\n${CHAPTER_PARAGRAPH_INDENT}`, input.selectionStart, input.selectionEnd, "end");
|
|
20149
|
+
input.setSelectionRange(next.selectionStart, next.selectionEnd);
|
|
20150
|
+
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertLineBreak" }));
|
|
20151
|
+
});
|
|
20152
|
+
$("#chapter-content").addEventListener("input", (event) => {
|
|
20153
|
+
const input = event.currentTarget;
|
|
20154
|
+
const beforeInput = chapterBeforeInputState;
|
|
20155
|
+
const hint = beforeInput
|
|
20156
|
+
&& beforeInput.chapterId === state.chapter?.id
|
|
20157
|
+
&& chapterDraftLineIdState?.content === beforeInput.content
|
|
20158
|
+
? beforeInput
|
|
20159
|
+
: null;
|
|
20160
|
+
syncChapterDraftLineIds(input.value, hint);
|
|
20161
|
+
chapterBeforeInputState = null;
|
|
18556
20162
|
updateChapterStats();
|
|
18557
20163
|
scheduleChapterAutoSave();
|
|
18558
20164
|
clearChapterLineSelection();
|
|
18559
20165
|
scheduleChapterLineNumbers(chapterLineInputRenderDelay);
|
|
20166
|
+
scheduleChapterCaretScroll();
|
|
18560
20167
|
setAiContextMeter(null);
|
|
18561
20168
|
});
|
|
18562
20169
|
$("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
|
|
@@ -18639,6 +20246,17 @@ $("#ai-panel-toggle").addEventListener("click", () => {
|
|
|
18639
20246
|
panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
|
|
18640
20247
|
applyPanelLayout(true);
|
|
18641
20248
|
});
|
|
20249
|
+
$("#ai-semantic-search-toggle").addEventListener("click", () => {
|
|
20250
|
+
setAiSemanticSearchVisible($("#ai-semantic-search-panel").classList.contains("hidden"));
|
|
20251
|
+
});
|
|
20252
|
+
$("#ai-semantic-search-close").addEventListener("click", () => setAiSemanticSearchVisible(false));
|
|
20253
|
+
$("#ai-semantic-search-run").addEventListener("click", () => { void runAiSemanticSearch(); });
|
|
20254
|
+
$("#ai-semantic-query").addEventListener("keydown", (event) => {
|
|
20255
|
+
if (event.key !== "Enter" || event.shiftKey) return;
|
|
20256
|
+
event.preventDefault();
|
|
20257
|
+
void runAiSemanticSearch();
|
|
20258
|
+
});
|
|
20259
|
+
$("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
|
|
18642
20260
|
setupPanelResize($("#left-panel-resize"), "left");
|
|
18643
20261
|
setupPanelResize($("#ai-panel-resize"), "ai");
|
|
18644
20262
|
if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
|
|
@@ -19049,6 +20667,11 @@ document.addEventListener("keydown", (event) => {
|
|
|
19049
20667
|
return;
|
|
19050
20668
|
}
|
|
19051
20669
|
if (event.key === "Escape") {
|
|
20670
|
+
if (!$("#ai-semantic-search-panel").classList.contains("hidden")) {
|
|
20671
|
+
setAiSemanticSearchVisible(false);
|
|
20672
|
+
$("#ai-semantic-search-toggle").focus();
|
|
20673
|
+
return;
|
|
20674
|
+
}
|
|
19052
20675
|
if (!$("#ai-model-popover").classList.contains("hidden")) {
|
|
19053
20676
|
setAiModelPickerVisible(false);
|
|
19054
20677
|
return;
|
|
@@ -19279,6 +20902,109 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
|
|
|
19279
20902
|
if (label) label.textContent = "导出 Markdown";
|
|
19280
20903
|
}
|
|
19281
20904
|
});
|
|
20905
|
+
// --- AI 操作审批中心与写入审批 / 提问弹窗 ---
|
|
20906
|
+
$("#ai-approval-center-toggle").addEventListener("click", () => {
|
|
20907
|
+
const dialog = $("#ai-approval-center-dialog");
|
|
20908
|
+
if (dialog.open) {
|
|
20909
|
+
dialog.close();
|
|
20910
|
+
return;
|
|
20911
|
+
}
|
|
20912
|
+
openAiApprovalCenter();
|
|
20913
|
+
});
|
|
20914
|
+
$("#ai-approval-center-close").addEventListener("click", () => $("#ai-approval-center-dialog").close());
|
|
20915
|
+
$("#ai-approval-center-dialog").addEventListener("close", () => {
|
|
20916
|
+
$("#ai-approval-center-toggle").setAttribute("aria-expanded", "false");
|
|
20917
|
+
});
|
|
20918
|
+
$("#ai-approval-center-dialog .ai-approval-filters").addEventListener("click", (event) => {
|
|
20919
|
+
const chip = event.target.closest("[data-status-filter]");
|
|
20920
|
+
if (!chip) return;
|
|
20921
|
+
aiApprovalCenterState.status = String(chip.dataset.statusFilter ?? "");
|
|
20922
|
+
document.querySelectorAll("#ai-approval-center-dialog [data-status-filter]").forEach((item) => {
|
|
20923
|
+
item.setAttribute("aria-pressed", String(item === chip));
|
|
20924
|
+
});
|
|
20925
|
+
$("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
|
|
20926
|
+
loadAiApprovalCenterPlans().catch((error) => {
|
|
20927
|
+
$("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
|
|
20928
|
+
});
|
|
20929
|
+
});
|
|
20930
|
+
$("#ai-approval-list-host").addEventListener("click", (event) => {
|
|
20931
|
+
const row = event.target.closest("[data-plan-id]");
|
|
20932
|
+
if (row) {
|
|
20933
|
+
openAiWritePlanDetail(row.dataset.planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
|
|
20934
|
+
return;
|
|
20935
|
+
}
|
|
20936
|
+
const questionRow = event.target.closest("[data-question-id]");
|
|
20937
|
+
if (questionRow) openAiUserQuestionDialog(questionRow.dataset.questionId).catch((error) => toast(`提问详情加载失败:${error.message}`, "error"));
|
|
20938
|
+
});
|
|
20939
|
+
$("#ai-write-plan-close").addEventListener("click", () => $("#ai-write-plan-dialog").close());
|
|
20940
|
+
$("#ai-write-plan-refresh").addEventListener("click", () => {
|
|
20941
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20942
|
+
openAiWritePlanDetail(aiWritePlanDialogPlanId).catch((error) => toast(`状态刷新失败:${error.message}`, "error"));
|
|
20943
|
+
});
|
|
20944
|
+
$("#ai-write-plan-confirm").addEventListener("click", () => {
|
|
20945
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20946
|
+
decideAiWritePlan(aiWritePlanDialogPlanId, "confirm").catch((error) => toast(error.message, "error"));
|
|
20947
|
+
});
|
|
20948
|
+
$("#ai-write-plan-reject").addEventListener("click", () => {
|
|
20949
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20950
|
+
decideAiWritePlan(aiWritePlanDialogPlanId, "reject").catch((error) => toast(error.message, "error"));
|
|
20951
|
+
});
|
|
20952
|
+
$("#ai-write-plan-undo").addEventListener("click", () => {
|
|
20953
|
+
if (!aiWritePlanDialogPlanId) return;
|
|
20954
|
+
undoAiWritePlan(aiWritePlanDialogPlanId);
|
|
20955
|
+
});
|
|
20956
|
+
$("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
|
|
20957
|
+
|
|
20958
|
+
function syncAiQuestionSubmitState() {
|
|
20959
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
20960
|
+
const customInput = $("#ai-question-custom-answer");
|
|
20961
|
+
const submit = $("#ai-question-submit");
|
|
20962
|
+
const disabled = !checked
|
|
20963
|
+
|| checked.disabled
|
|
20964
|
+
|| (checked.value === "custom" && !String(customInput.value).trim());
|
|
20965
|
+
submit.disabled = disabled;
|
|
20966
|
+
submit.title = disabled ? "请选择一个预设选项,或填写自定义回答后提交" : "";
|
|
20967
|
+
}
|
|
20968
|
+
$("#ai-question-form").addEventListener("change", (event) => {
|
|
20969
|
+
const control = event.target;
|
|
20970
|
+
if (control?.name !== "ai-question-choice") return;
|
|
20971
|
+
const isCustom = control.value === "custom";
|
|
20972
|
+
const customInput = $("#ai-question-custom-answer");
|
|
20973
|
+
if (isCustom && !control.disabled) customInput.focus();
|
|
20974
|
+
syncAiQuestionOptionPresentation();
|
|
20975
|
+
syncAiQuestionSubmitState();
|
|
20976
|
+
});
|
|
20977
|
+
$("#ai-question-custom-answer").addEventListener("input", () => {
|
|
20978
|
+
const customInput = $("#ai-question-custom-answer");
|
|
20979
|
+
syncAiQuestionAnswerCount();
|
|
20980
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
20981
|
+
if (!checked && String(customInput.value).trim()) {
|
|
20982
|
+
const customRadio = document.querySelector('input[name="ai-question-choice"][value="custom"]');
|
|
20983
|
+
if (customRadio && !customRadio.disabled) customRadio.checked = true;
|
|
20984
|
+
}
|
|
20985
|
+
syncAiQuestionOptionPresentation();
|
|
20986
|
+
syncAiQuestionSubmitState();
|
|
20987
|
+
});
|
|
20988
|
+
$("#ai-question-submit").addEventListener("click", () => {
|
|
20989
|
+
const checked = document.querySelector('input[name="ai-question-choice"]:checked');
|
|
20990
|
+
if (!checked || aiQuestionDialogQuestionId == null) return;
|
|
20991
|
+
if (checked.value === "custom") {
|
|
20992
|
+
const text = String($("#ai-question-custom-answer").value).trim();
|
|
20993
|
+
if (!text) return;
|
|
20994
|
+
respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "custom", customAnswer: text }).catch((error) => toast(error.message, "error"));
|
|
20995
|
+
return;
|
|
20996
|
+
}
|
|
20997
|
+
const supplementalAnswer = String($("#ai-question-custom-answer").value).trim();
|
|
20998
|
+
respondAiUserQuestion(aiQuestionDialogQuestionId, {
|
|
20999
|
+
action: "option",
|
|
21000
|
+
selectedOption: Number(checked.value),
|
|
21001
|
+
...(supplementalAnswer ? { customAnswer: supplementalAnswer } : {})
|
|
21002
|
+
}).catch((error) => toast(error.message, "error"));
|
|
21003
|
+
});
|
|
21004
|
+
$("#ai-question-skip").addEventListener("click", () => {
|
|
21005
|
+
if (aiQuestionDialogQuestionId == null) return;
|
|
21006
|
+
respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "reject" }).catch((error) => toast(error.message, "error"));
|
|
21007
|
+
});
|
|
19282
21008
|
$("#ai-prompt").addEventListener("keydown", (event) => {
|
|
19283
21009
|
const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
|
|
19284
21010
|
if (mentionMenuVisible) {
|
|
@@ -19373,9 +21099,6 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
|
|
|
19373
21099
|
$("#reader-open-button").addEventListener("click", () => {
|
|
19374
21100
|
void openReadingPreview({ restorePosition: true });
|
|
19375
21101
|
});
|
|
19376
|
-
$("#chapter-reader-button").addEventListener("click", () => {
|
|
19377
|
-
void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
|
|
19378
|
-
});
|
|
19379
21102
|
$("#reader-close").addEventListener("click", closeReadingPreview);
|
|
19380
21103
|
$("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
|
|
19381
21104
|
$("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));
|