@musnows/scriverse 0.5.12 → 0.6.1
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 -1
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-tool-results.js +177 -0
- package/dist/ai-tool-results.js.map +1 -0
- package/dist/ai.js +743 -138
- package/dist/ai.js.map +1 -1
- package/dist/app.js +94 -33
- package/dist/app.js.map +1 -1
- package/dist/cli-contract.js +5 -5
- package/dist/cli-core.js +8 -2
- package/dist/cli-core.js.map +1 -1
- package/dist/collaboration-presence.js +1 -0
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +17 -0
- package/dist/database.js.map +1 -1
- package/dist/public/ai-context-meter.js +39 -0
- package/dist/public/app.js +619 -228
- package/dist/public/entity-version.js +2 -2
- package/dist/public/index.html +22 -11
- package/dist/public/model-config.d.ts +3 -0
- package/dist/public/model-config.js +14 -0
- package/dist/public/page-route.js +1 -0
- package/dist/public/styles.css +73 -14
- package/dist/public/work-permissions.d.ts +1 -1
- package/dist/public/work-permissions.js +1 -1
- package/dist/store.js +98 -13
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +4 -5
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/work-permissions.js +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -5,18 +5,18 @@ import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-
|
|
|
5
5
|
import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
|
|
6
6
|
import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
7
7
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
8
|
-
import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=
|
|
8
|
+
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260731-model-context-guidance-v1";
|
|
9
9
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
10
10
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
|
|
11
11
|
import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
|
|
12
12
|
import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
|
|
13
13
|
import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
|
|
14
|
-
import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=
|
|
14
|
+
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260731-skills-description-v1";
|
|
15
15
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
16
16
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
17
17
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
18
18
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
|
|
19
|
-
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=
|
|
19
|
+
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260731-drafts-to-ideas-v1";
|
|
20
20
|
import {
|
|
21
21
|
chapterVersionSourceLabel,
|
|
22
22
|
foreshadowStatusLabel,
|
|
@@ -36,12 +36,12 @@ import {
|
|
|
36
36
|
timelineStatusLabel,
|
|
37
37
|
characterStateFieldLabel
|
|
38
38
|
} from "/display-labels.js?v=20260728-hybrid-search-v1";
|
|
39
|
-
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=
|
|
39
|
+
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
|
|
40
40
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
41
41
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
42
42
|
import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
|
|
43
43
|
import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
|
|
44
|
-
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=
|
|
44
|
+
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
|
|
45
45
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
46
46
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
47
47
|
import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
|
|
@@ -69,6 +69,7 @@ const defaultPageSizes = Object.freeze({
|
|
|
69
69
|
timeline: 30,
|
|
70
70
|
outlines: 30,
|
|
71
71
|
relationships: 30,
|
|
72
|
+
comments: 30,
|
|
72
73
|
reviews: 30,
|
|
73
74
|
analysisTasks: 30,
|
|
74
75
|
fileVersions: 30
|
|
@@ -86,6 +87,12 @@ function normalizePageSizes(value) {
|
|
|
86
87
|
]));
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
function isSelectableModel(model) {
|
|
91
|
+
return Boolean(model?.enabled)
|
|
92
|
+
&& model?.providerStatus === "enabled"
|
|
93
|
+
&& model?.providerConnectionStatus === "success";
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
const state = {
|
|
90
97
|
user: null,
|
|
91
98
|
csrfToken: null,
|
|
@@ -128,6 +135,7 @@ const cachedWorkModules = new Set([
|
|
|
128
135
|
"timeline",
|
|
129
136
|
"outlines",
|
|
130
137
|
"relationships",
|
|
138
|
+
"comments",
|
|
131
139
|
"reviews",
|
|
132
140
|
"tasks",
|
|
133
141
|
"ai-settings"
|
|
@@ -305,6 +313,7 @@ function applyWorkAccessMode() {
|
|
|
305
313
|
const button = $(`#module-nav [data-module="${item.uiModule}"]`);
|
|
306
314
|
if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
|
|
307
315
|
}
|
|
316
|
+
$("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
|
|
308
317
|
$("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
|
|
309
318
|
$("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
310
319
|
$("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
@@ -386,6 +395,8 @@ let aiReferencesLoadPromise = null;
|
|
|
386
395
|
let aiReferencesLoadWorkId = null;
|
|
387
396
|
let aiConversationsLoadPromise = null;
|
|
388
397
|
let aiConversationsLoadWorkId = null;
|
|
398
|
+
const aiConversationHistoryPageLimit = 20;
|
|
399
|
+
let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
389
400
|
let workScopedUiGeneration = 0;
|
|
390
401
|
const loadedVolumeChapterIds = new Set();
|
|
391
402
|
const volumeChapterLoadingIds = new Set();
|
|
@@ -656,7 +667,7 @@ function renderPresence() {
|
|
|
656
667
|
const localKey = presencePageKey(presencePageForRoute());
|
|
657
668
|
syncChapterAutoSaveWithPresence();
|
|
658
669
|
control.classList.remove("hidden");
|
|
659
|
-
$("#presence-count").textContent = `${groups.length}
|
|
670
|
+
$("#presence-count").textContent = `${groups.length} 人`;
|
|
660
671
|
$("#presence-list").innerHTML = groups.map((participant) => {
|
|
661
672
|
const isCurrent = participant.userId === state.user?.userId;
|
|
662
673
|
const samePage = !isCurrent && participant.pages.some((page) => page.key === localKey);
|
|
@@ -902,6 +913,7 @@ const moduleListPages = {
|
|
|
902
913
|
outlinePlans: 1,
|
|
903
914
|
foreshadows: 1,
|
|
904
915
|
relationships: 1,
|
|
916
|
+
comments: 1,
|
|
905
917
|
reviews: 1
|
|
906
918
|
};
|
|
907
919
|
const characterFilters = { raceIds: [], organizationIds: [] };
|
|
@@ -1238,7 +1250,7 @@ function renderAiCitations() {
|
|
|
1238
1250
|
card.append(main, remove, fullText);
|
|
1239
1251
|
host.append(card);
|
|
1240
1252
|
}
|
|
1241
|
-
|
|
1253
|
+
setAiContextMeter(null);
|
|
1242
1254
|
}
|
|
1243
1255
|
|
|
1244
1256
|
function aiReferenceKey(reference) {
|
|
@@ -1282,7 +1294,7 @@ function renderAiReferences() {
|
|
|
1282
1294
|
if (rendered.has(aiReferenceKey(reference))) continue;
|
|
1283
1295
|
prompt.append(createAiReferenceChip(reference), document.createTextNode(" "));
|
|
1284
1296
|
}
|
|
1285
|
-
|
|
1297
|
+
setAiContextMeter(null);
|
|
1286
1298
|
}
|
|
1287
1299
|
|
|
1288
1300
|
function renderAiQuickActions() {
|
|
@@ -1325,6 +1337,34 @@ function resetAiFeed() {
|
|
|
1325
1337
|
$("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
|
|
1326
1338
|
}
|
|
1327
1339
|
|
|
1340
|
+
function appendAiContextCompactionDivider(kind, before = null) {
|
|
1341
|
+
const divider = document.createElement("div");
|
|
1342
|
+
divider.className = "ai-context-compaction-divider";
|
|
1343
|
+
divider.dataset.contextCompaction = kind;
|
|
1344
|
+
divider.dataset.testid = "ai-context-compaction-divider";
|
|
1345
|
+
divider.setAttribute("role", "separator");
|
|
1346
|
+
divider.setAttribute("aria-label", "已压缩上下文");
|
|
1347
|
+
divider.innerHTML = "<span>已压缩上下文</span>";
|
|
1348
|
+
const feed = $("#ai-feed");
|
|
1349
|
+
if (before?.parentElement === feed) feed.insertBefore(divider, before);
|
|
1350
|
+
else feed.append(divider);
|
|
1351
|
+
scrollAiFeedToBottom();
|
|
1352
|
+
return divider;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
|
|
1356
|
+
const feed = $("#ai-feed");
|
|
1357
|
+
feed.querySelector('[data-context-compaction="conversation"]')?.remove();
|
|
1358
|
+
const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
|
|
1359
|
+
if (compactedCount === 0) return null;
|
|
1360
|
+
const messages = [...feed.querySelectorAll("[data-message-id]")];
|
|
1361
|
+
const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
|
|
1362
|
+
const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
|
|
1363
|
+
const loadedOffset = Math.max(0, totalCount - messages.length);
|
|
1364
|
+
const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
|
|
1365
|
+
return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1328
1368
|
function renderMessageCardActions(message) {
|
|
1329
1369
|
let actions = message.querySelector(".message-card-actions");
|
|
1330
1370
|
if (!actions) {
|
|
@@ -1361,7 +1401,7 @@ function renderMessageCardActions(message) {
|
|
|
1361
1401
|
fork.disabled = true;
|
|
1362
1402
|
try {
|
|
1363
1403
|
const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, { method: "POST", body: { messageId: message.dataset.messageId } });
|
|
1364
|
-
|
|
1404
|
+
upsertAiConversationSummary(conversation);
|
|
1365
1405
|
await openAiConversation(conversation.id);
|
|
1366
1406
|
toast("已从所选消息创建分支对话");
|
|
1367
1407
|
} catch (error) {
|
|
@@ -1393,7 +1433,7 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
1393
1433
|
grep: "查询正文关键字",
|
|
1394
1434
|
search_story_entities: "搜索作品实体",
|
|
1395
1435
|
read_character_sections: "读取人物 Markdown 章节",
|
|
1396
|
-
search_drafts: "
|
|
1436
|
+
search_drafts: "搜索想法"
|
|
1397
1437
|
};
|
|
1398
1438
|
|
|
1399
1439
|
const AI_TOOL_DESCRIPTIONS = {
|
|
@@ -1553,6 +1593,17 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
|
|
|
1553
1593
|
const list = document.createElement("div");
|
|
1554
1594
|
list.className = "ai-process-list";
|
|
1555
1595
|
for (const step of steps) {
|
|
1596
|
+
if (step?.type === "context_compaction") {
|
|
1597
|
+
const compaction = document.createElement("div");
|
|
1598
|
+
compaction.className = "ai-process-context-compaction";
|
|
1599
|
+
compaction.dataset.testid = "ai-process-context-compaction";
|
|
1600
|
+
compaction.setAttribute("role", "separator");
|
|
1601
|
+
compaction.setAttribute("aria-label", `第 ${Number(step.round) || 1} 轮已压缩上下文`);
|
|
1602
|
+
compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
|
|
1603
|
+
compaction.innerHTML = "<span>已压缩上下文</span>";
|
|
1604
|
+
list.append(compaction);
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1556
1607
|
if (step?.type === "tool" && step.toolCall) {
|
|
1557
1608
|
const tool = document.createElement("section");
|
|
1558
1609
|
tool.className = "ai-process-step ai-process-tool-step";
|
|
@@ -1593,6 +1644,12 @@ function setAiHistoryVisible(visible) {
|
|
|
1593
1644
|
function renderAiConversationHistory() {
|
|
1594
1645
|
const host = $("#ai-history-list");
|
|
1595
1646
|
host.replaceChildren();
|
|
1647
|
+
const pagination = $("#ai-history-pagination");
|
|
1648
|
+
const showPagination = aiConversationHistoryPage.page > 1 || aiConversationHistoryPage.hasMore;
|
|
1649
|
+
pagination.classList.toggle("hidden", !showPagination);
|
|
1650
|
+
$("#ai-history-previous").disabled = aiConversationHistoryPage.page <= 1;
|
|
1651
|
+
$("#ai-history-next").disabled = !aiConversationHistoryPage.hasMore;
|
|
1652
|
+
$("#ai-history-page-label").textContent = `第 ${aiConversationHistoryPage.page} 页 · 本页 ${state.aiConversations.length} 条`;
|
|
1596
1653
|
if (!state.aiConversations.length) {
|
|
1597
1654
|
host.innerHTML = '<p class="ai-history-empty">还没有历史对话</p>';
|
|
1598
1655
|
return;
|
|
@@ -1612,20 +1669,72 @@ function renderAiConversationHistory() {
|
|
|
1612
1669
|
}
|
|
1613
1670
|
}
|
|
1614
1671
|
|
|
1615
|
-
|
|
1672
|
+
function defaultAiConversationTitle(prompt) {
|
|
1673
|
+
const normalized = String(prompt ?? "").replace(/\s+/gu, " ").trim();
|
|
1674
|
+
return Array.from(normalized).slice(0, 15).join("") || "新对话";
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function upsertAiConversationSummary(conversation) {
|
|
1678
|
+
if (!conversation?.id) return;
|
|
1679
|
+
const current = state.aiConversations.find((item) => item.id === conversation.id);
|
|
1680
|
+
const lastMessage = Array.isArray(conversation.messages) ? conversation.messages.at(-1) : null;
|
|
1681
|
+
const summary = { ...current, ...conversation, ...(lastMessage ? { preview: lastMessage.content } : {}) };
|
|
1682
|
+
delete summary.messages;
|
|
1683
|
+
delete summary.messagesPage;
|
|
1684
|
+
if (!current && loadedAiConversationsWorkId === state.work?.id) {
|
|
1685
|
+
loadedAiConversationsWorkId = null;
|
|
1686
|
+
aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
1687
|
+
state.aiConversations = [summary];
|
|
1688
|
+
renderAiConversationHistory();
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
state.aiConversations = [summary, ...state.aiConversations.filter((item) => item.id !== summary.id)]
|
|
1692
|
+
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
|
|
1693
|
+
renderAiConversationHistory();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function updateAiConversationSummaryFromMessage(message) {
|
|
1697
|
+
if (!message?.conversationId) return;
|
|
1698
|
+
const current = state.aiConversations.find((conversation) => conversation.id === message.conversationId);
|
|
1699
|
+
if (!current) return;
|
|
1700
|
+
upsertAiConversationSummary({
|
|
1701
|
+
...current,
|
|
1702
|
+
title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
|
|
1703
|
+
messageCount: Number(current.messageCount ?? 0) + 1,
|
|
1704
|
+
preview: message.content,
|
|
1705
|
+
updatedAt: message.createdAt ?? current.updatedAt
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function applyAiConversationTitle(title) {
|
|
1710
|
+
if (!title || !state.aiConversationId) return;
|
|
1711
|
+
const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
|
|
1712
|
+
if (current) current.title = title;
|
|
1713
|
+
$("#ai-conversation-title").textContent = title;
|
|
1714
|
+
renderAiConversationHistory();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function applyAiConversations(pageResult) {
|
|
1718
|
+
state.aiConversations = pageResult.items;
|
|
1719
|
+
aiConversationHistoryPage = {
|
|
1720
|
+
page: pageResult.page,
|
|
1721
|
+
limit: pageResult.limit,
|
|
1722
|
+
hasMore: pageResult.hasMore,
|
|
1723
|
+
nextPage: pageResult.nextPage
|
|
1724
|
+
};
|
|
1725
|
+
loadedAiConversationsWorkId = state.work?.id ?? null;
|
|
1726
|
+
renderAiConversationHistory();
|
|
1727
|
+
const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
|
|
1728
|
+
if (current) $("#ai-conversation-title").textContent = current.title;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function loadAiConversations(page = 1) {
|
|
1616
1732
|
const workId = state.work?.id;
|
|
1617
1733
|
if (!workId) return;
|
|
1618
1734
|
const generation = workScopedUiGeneration;
|
|
1619
|
-
const conversations =
|
|
1735
|
+
const conversations = await apiPage(`/api/works/${workId}/ai-conversations`, page, aiConversationHistoryPageLimit);
|
|
1620
1736
|
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
1621
|
-
|
|
1622
|
-
loadedAiConversationsWorkId = workId;
|
|
1623
|
-
renderAiConversationHistory();
|
|
1624
|
-
if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
|
|
1625
|
-
else {
|
|
1626
|
-
const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
|
|
1627
|
-
if (current) $("#ai-conversation-title").textContent = current.title;
|
|
1628
|
-
}
|
|
1737
|
+
applyAiConversations(conversations);
|
|
1629
1738
|
}
|
|
1630
1739
|
|
|
1631
1740
|
async function ensureAiConversationsLoaded() {
|
|
@@ -1633,7 +1742,7 @@ async function ensureAiConversationsLoaded() {
|
|
|
1633
1742
|
if (!workId || loadedAiConversationsWorkId === workId) return;
|
|
1634
1743
|
if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
|
|
1635
1744
|
aiConversationsLoadWorkId = workId;
|
|
1636
|
-
aiConversationsLoadPromise = loadAiConversations(
|
|
1745
|
+
aiConversationsLoadPromise = loadAiConversations();
|
|
1637
1746
|
try {
|
|
1638
1747
|
await aiConversationsLoadPromise;
|
|
1639
1748
|
} finally {
|
|
@@ -1646,11 +1755,15 @@ async function ensureAiConversationsLoaded() {
|
|
|
1646
1755
|
|
|
1647
1756
|
async function openAiConversation(conversationId, hideHistory = true) {
|
|
1648
1757
|
const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
|
|
1758
|
+
upsertAiConversationSummary(conversation);
|
|
1649
1759
|
state.aiConversationId = conversation.id;
|
|
1650
1760
|
state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
|
|
1651
1761
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
1652
1762
|
resetAiFeed();
|
|
1653
1763
|
for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
|
|
1764
|
+
if (conversation.hasCompactedSummary) {
|
|
1765
|
+
renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
|
|
1766
|
+
}
|
|
1654
1767
|
state.aiCitations = [];
|
|
1655
1768
|
state.aiReferences = [];
|
|
1656
1769
|
setAiPromptText("");
|
|
@@ -1666,14 +1779,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
|
|
|
1666
1779
|
async function createNewAiConversation() {
|
|
1667
1780
|
if (!state.work) return;
|
|
1668
1781
|
const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
|
|
1782
|
+
upsertAiConversationSummary(conversation);
|
|
1669
1783
|
state.aiConversationId = conversation.id;
|
|
1670
1784
|
state.aiPromptSent = false;
|
|
1671
1785
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
1672
1786
|
resetAiFeed();
|
|
1673
1787
|
hideAiContextWarning();
|
|
1788
|
+
setAiContextMeter(null);
|
|
1674
1789
|
renderAiQuickActions();
|
|
1675
1790
|
setAiHistoryVisible(false);
|
|
1676
|
-
await loadAiConversations(false);
|
|
1677
1791
|
}
|
|
1678
1792
|
|
|
1679
1793
|
async function ensureAiConversation() {
|
|
@@ -1686,7 +1800,7 @@ async function persistAiConversationMessage(role, content, citations = [], metad
|
|
|
1686
1800
|
const conversationId = await ensureAiConversation();
|
|
1687
1801
|
if (!conversationId) throw new Error("无法创建 AI 对话");
|
|
1688
1802
|
const message = await api(`/api/ai-conversations/${conversationId}/messages`, { method: "POST", body: { role, content, citations, metadata } });
|
|
1689
|
-
|
|
1803
|
+
updateAiConversationSummaryFromMessage(message);
|
|
1690
1804
|
return message;
|
|
1691
1805
|
}
|
|
1692
1806
|
|
|
@@ -1823,9 +1937,9 @@ async function createSelectedLineAnnotation(kind) {
|
|
|
1823
1937
|
if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
|
|
1824
1938
|
const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
|
|
1825
1939
|
closeLineCitationMenu();
|
|
1826
|
-
const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" :
|
|
1827
|
-
title: kind === "todo" ? "添加正文待办" : "
|
|
1828
|
-
inputLabel: kind === "todo" ? "待办内容" : "
|
|
1940
|
+
const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
|
|
1941
|
+
title: kind === "todo" ? "添加正文待办" : "添加正文评论",
|
|
1942
|
+
inputLabel: kind === "todo" ? "待办内容" : "评论内容",
|
|
1829
1943
|
confirmLabel: "添加",
|
|
1830
1944
|
maxLength: 2000
|
|
1831
1945
|
});
|
|
@@ -1835,61 +1949,107 @@ async function createSelectedLineAnnotation(kind) {
|
|
|
1835
1949
|
method: "POST",
|
|
1836
1950
|
body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
|
|
1837
1951
|
});
|
|
1838
|
-
toast(kind === "todo" ? "正文待办已添加" : "
|
|
1952
|
+
toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
|
|
1839
1953
|
await openChapterAnnotationsDialog();
|
|
1840
1954
|
} catch (error) {
|
|
1841
1955
|
toast(error.message, "error");
|
|
1842
1956
|
}
|
|
1843
1957
|
}
|
|
1844
1958
|
|
|
1845
|
-
function
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1959
|
+
function chapterAnnotationLineLabel(annotation) {
|
|
1960
|
+
return annotation.startLine === annotation.endLine
|
|
1961
|
+
? `第 ${annotation.startLine} 行`
|
|
1962
|
+
: `第 ${annotation.startLine}-${annotation.endLine} 行`;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function chapterAnnotationStatusLabel(annotation) {
|
|
1966
|
+
if (annotation.status === "resolved") return annotation.kind === "todo" ? "已完成" : "已解决";
|
|
1967
|
+
return annotation.kind === "todo" ? "待处理" : "未解决";
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function chapterAnnotationCard(annotation, { showSource = false } = {}) {
|
|
1971
|
+
const lineLabel = chapterAnnotationLineLabel(annotation);
|
|
1972
|
+
const sourceLabel = `${annotation.volumeTitle ?? "正文"} / ${annotation.chapterTitle ?? "未命名章节"}`;
|
|
1973
|
+
const heading = showSource
|
|
1974
|
+
? `<strong class="chapter-annotation-source" title="${esc(sourceLabel)}">${esc(sourceLabel)}</strong><span class="chapter-annotation-line">${esc(lineLabel)}</span>`
|
|
1975
|
+
: `<strong>${esc(lineLabel)}</strong>`;
|
|
1976
|
+
return `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
|
|
1977
|
+
<header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "评论"}</span>${heading}<em>${esc(chapterAnnotationStatusLabel(annotation))}</em></header>
|
|
1849
1978
|
<blockquote>${esc(annotation.quote || "空白行")}</blockquote>
|
|
1850
|
-
<p>${esc(annotation.note)}</p>
|
|
1979
|
+
<p data-annotation-content>${esc(annotation.note)}</p>
|
|
1851
1980
|
<small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
|
|
1852
|
-
<footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit
|
|
1853
|
-
</article
|
|
1981
|
+
<footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
|
|
1982
|
+
</article>`;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function bindChapterAnnotationCards(host, annotations, { refresh, locate, overlayDialog = null }) {
|
|
1986
|
+
const suspendOverlay = () => {
|
|
1987
|
+
if (overlayDialog?.open) overlayDialog.close();
|
|
1988
|
+
};
|
|
1989
|
+
const resumeOverlay = () => {
|
|
1990
|
+
if (overlayDialog && !overlayDialog.open) overlayDialog.showModal();
|
|
1991
|
+
};
|
|
1854
1992
|
host.querySelectorAll("[data-annotation-id]").forEach((card) => {
|
|
1855
|
-
const annotation =
|
|
1993
|
+
const annotation = annotations.find((item) => item.id === card.dataset.annotationId);
|
|
1856
1994
|
if (!annotation) return;
|
|
1857
|
-
card.querySelector("[data-annotation-locate]")?.addEventListener("click", () => {
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
|
|
1995
|
+
card.querySelector("[data-annotation-locate]")?.addEventListener("click", async () => {
|
|
1996
|
+
try { await locate(annotation); }
|
|
1997
|
+
catch (error) { toast(error.message, "error"); }
|
|
1861
1998
|
});
|
|
1862
1999
|
card.querySelector("[data-annotation-status]")?.addEventListener("click", async () => {
|
|
1863
2000
|
try {
|
|
1864
2001
|
await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { status: annotation.status === "resolved" ? "open" : "resolved", expectedVersionNo: annotation.versionNo } });
|
|
1865
|
-
await
|
|
2002
|
+
await refresh();
|
|
1866
2003
|
} catch (error) { toast(error.message, "error"); }
|
|
1867
2004
|
});
|
|
1868
2005
|
card.querySelector("[data-annotation-edit]")?.addEventListener("click", async () => {
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2006
|
+
suspendOverlay();
|
|
2007
|
+
const note = await inputToast(annotation.kind === "todo" ? "修改待办内容" : "修改评论内容", {
|
|
2008
|
+
title: annotation.kind === "todo" ? "编辑待办" : "编辑评论",
|
|
2009
|
+
inputLabel: annotation.kind === "todo" ? "待办内容" : "评论内容",
|
|
2010
|
+
value: annotation.note,
|
|
2011
|
+
confirmLabel: "保存",
|
|
2012
|
+
maxLength: 2000
|
|
2013
|
+
});
|
|
2014
|
+
if (!note) return resumeOverlay();
|
|
1873
2015
|
try {
|
|
1874
2016
|
await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { note, expectedVersionNo: annotation.versionNo } });
|
|
1875
|
-
await
|
|
2017
|
+
await refresh();
|
|
1876
2018
|
} catch (error) { toast(error.message, "error"); }
|
|
1877
|
-
|
|
2019
|
+
resumeOverlay();
|
|
1878
2020
|
});
|
|
1879
2021
|
card.querySelector("[data-annotation-delete]")?.addEventListener("click", async () => {
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2022
|
+
suspendOverlay();
|
|
2023
|
+
const confirmed = await confirmToast("删除后不会在正文评论中显示,但历史版本仍会保留。", {
|
|
2024
|
+
title: annotation.kind === "todo" ? "删除待办" : "删除评论",
|
|
2025
|
+
confirmLabel: "确认删除"
|
|
2026
|
+
});
|
|
2027
|
+
if (!confirmed) return resumeOverlay();
|
|
1884
2028
|
try {
|
|
1885
2029
|
await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "DELETE", body: { expectedVersionNo: annotation.versionNo } });
|
|
1886
|
-
await
|
|
1887
|
-
|
|
1888
|
-
|
|
2030
|
+
await refresh();
|
|
2031
|
+
} catch (error) { toast(error.message, "error"); }
|
|
2032
|
+
resumeOverlay();
|
|
1889
2033
|
});
|
|
1890
2034
|
});
|
|
1891
2035
|
}
|
|
1892
2036
|
|
|
2037
|
+
function renderChapterAnnotations() {
|
|
2038
|
+
const host = $("#chapter-annotations-list");
|
|
2039
|
+
host.innerHTML = chapterAnnotations.length
|
|
2040
|
+
? chapterAnnotations.map((annotation) => chapterAnnotationCard(annotation)).join("")
|
|
2041
|
+
: '<p class="entity-history-empty">本章还没有评论。在正文行上点击右键即可添加。</p>';
|
|
2042
|
+
bindChapterAnnotationCards(host, chapterAnnotations, {
|
|
2043
|
+
refresh: loadChapterAnnotations,
|
|
2044
|
+
locate: (annotation) => {
|
|
2045
|
+
$("#chapter-annotations-dialog").close();
|
|
2046
|
+
paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
|
|
2047
|
+
selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
|
|
2048
|
+
},
|
|
2049
|
+
overlayDialog: $("#chapter-annotations-dialog")
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
|
|
1893
2053
|
async function loadChapterAnnotations() {
|
|
1894
2054
|
if (!state.chapter) return;
|
|
1895
2055
|
chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
|
|
@@ -1898,8 +2058,8 @@ async function loadChapterAnnotations() {
|
|
|
1898
2058
|
|
|
1899
2059
|
async function openChapterAnnotationsDialog() {
|
|
1900
2060
|
if (!state.chapter) return;
|
|
1901
|
-
$("#chapter-annotations-meta").textContent = `《${state.chapter.title}
|
|
1902
|
-
$("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty"
|
|
2061
|
+
$("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文评论`;
|
|
2062
|
+
$("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载评论…</p>';
|
|
1903
2063
|
if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
|
|
1904
2064
|
try {
|
|
1905
2065
|
await loadChapterAnnotations();
|
|
@@ -2149,9 +2309,7 @@ async function api(path, options = {}) {
|
|
|
2149
2309
|
moduleRequestCache.clear();
|
|
2150
2310
|
showAuth(false);
|
|
2151
2311
|
}
|
|
2152
|
-
|
|
2153
|
-
error.code = payload.error?.code;
|
|
2154
|
-
throw error;
|
|
2312
|
+
throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
|
|
2155
2313
|
}
|
|
2156
2314
|
if (response.status === 204) {
|
|
2157
2315
|
invalidateModuleRequestsAfterMutation(path, method);
|
|
@@ -2162,6 +2320,41 @@ async function api(path, options = {}) {
|
|
|
2162
2320
|
return payload.data;
|
|
2163
2321
|
}
|
|
2164
2322
|
|
|
2323
|
+
function createClientError(payload, fallbackMessage, fallbackStatus = null) {
|
|
2324
|
+
const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
2325
|
+
const error = new Error(typeof source.message === "string" ? source.message : fallbackMessage);
|
|
2326
|
+
error.code = typeof source.code === "string" ? source.code : undefined;
|
|
2327
|
+
error.status = Number.isInteger(source.status) ? source.status : fallbackStatus;
|
|
2328
|
+
error.details = source.details;
|
|
2329
|
+
error.failure = typeof source.failure === "string" ? source.failure : undefined;
|
|
2330
|
+
error.callId = typeof source.callId === "string" ? source.callId : undefined;
|
|
2331
|
+
error.providerName = typeof source.providerName === "string" ? source.providerName : undefined;
|
|
2332
|
+
error.providerId = typeof source.providerId === "string" ? source.providerId : undefined;
|
|
2333
|
+
error.modelId = typeof source.modelId === "string" ? source.modelId : undefined;
|
|
2334
|
+
error.modelRecordId = typeof source.modelRecordId === "string" ? source.modelRecordId : undefined;
|
|
2335
|
+
return error;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function formatAiFailureMessage(error) {
|
|
2339
|
+
const message = error instanceof Error ? error.message : String(error ?? "未知错误");
|
|
2340
|
+
const lines = [`调用失败:${message}`];
|
|
2341
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
2342
|
+
const status = Number.isInteger(error?.status) ? error.status : null;
|
|
2343
|
+
const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
|
|
2344
|
+
const failure = typeof error?.failure === "string" ? error.failure : typeof details.failure === "string" ? details.failure : "";
|
|
2345
|
+
const callId = typeof error?.callId === "string" ? error.callId : typeof details.callId === "string" ? details.callId : "";
|
|
2346
|
+
const providerName = typeof error?.providerName === "string" ? error.providerName : typeof details.providerName === "string" ? details.providerName : "";
|
|
2347
|
+
const providerId = typeof error?.providerId === "string" ? error.providerId : typeof details.providerId === "string" ? details.providerId : "";
|
|
2348
|
+
const modelId = typeof error?.modelId === "string" ? error.modelId : typeof details.modelId === "string" ? details.modelId : "";
|
|
2349
|
+
if (code) lines.push(`错误码:${code}`);
|
|
2350
|
+
if (status) lines.push(`服务端状态:HTTP ${status}`);
|
|
2351
|
+
if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
|
|
2352
|
+
if (modelId) lines.push(`模型 ID:${modelId}`);
|
|
2353
|
+
if (failure && failure !== message) lines.push(`详细原因:${failure}`);
|
|
2354
|
+
if (callId) lines.push(`调用 ID:${callId}`);
|
|
2355
|
+
return lines.join("\n\n");
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2165
2358
|
async function apiPage(path, page = 1, limit = 30) {
|
|
2166
2359
|
const separator = path.includes("?") ? "&" : "?";
|
|
2167
2360
|
const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
|
|
@@ -2204,7 +2397,6 @@ function invalidateModuleRequestsAfterMutation(path, method) {
|
|
|
2204
2397
|
path.startsWith("/api/auth/")
|
|
2205
2398
|
|| path.includes("/presence")
|
|
2206
2399
|
|| path.includes("/context/prepare")
|
|
2207
|
-
|| path.includes("/ai-context-usage")
|
|
2208
2400
|
|| path.includes("/chat/stream")
|
|
2209
2401
|
) return;
|
|
2210
2402
|
|
|
@@ -2221,6 +2413,7 @@ function invalidateModuleRequestsAfterMutation(path, method) {
|
|
|
2221
2413
|
if (path.includes("/timeline")) affected.add("timeline");
|
|
2222
2414
|
if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
|
|
2223
2415
|
if (path.includes("/relationships")) affected.add("relationships");
|
|
2416
|
+
if (path.includes("/chapter-annotations/") || /\/chapters\/[^/]+\/annotations(?:$|\?)/u.test(path)) affected.add("comments");
|
|
2224
2417
|
if (path.includes("/reviews")) affected.add("reviews");
|
|
2225
2418
|
if (path.includes("/entity-versions/")) {
|
|
2226
2419
|
if (path.includes("/draft/")) affected.add("drafts");
|
|
@@ -2485,7 +2678,7 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
|
|
|
2485
2678
|
});
|
|
2486
2679
|
}
|
|
2487
2680
|
|
|
2488
|
-
function inputToast(message, { title = "请输入", inputLabel = title, placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
|
|
2681
|
+
function inputToast(message, { title = "请输入", inputLabel = title, value = "", placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
|
|
2489
2682
|
const region = $("#toast-region");
|
|
2490
2683
|
const element = document.createElement("section");
|
|
2491
2684
|
element.className = "toast toast-confirmation toast-input-dialog";
|
|
@@ -2499,6 +2692,7 @@ function inputToast(message, { title = "请输入", inputLabel = title, placehol
|
|
|
2499
2692
|
input.className = "toast-input";
|
|
2500
2693
|
input.type = "text";
|
|
2501
2694
|
input.maxLength = maxLength;
|
|
2695
|
+
input.value = value;
|
|
2502
2696
|
input.placeholder = placeholder;
|
|
2503
2697
|
input.setAttribute("aria-label", inputLabel);
|
|
2504
2698
|
const actions = document.createElement("div");
|
|
@@ -2925,15 +3119,15 @@ const workAuditActionLabels = {
|
|
|
2925
3119
|
"chapter.deleted": "删除章节",
|
|
2926
3120
|
"chapter.purged": "彻底删除章节",
|
|
2927
3121
|
"chapter.restored": "恢复章节",
|
|
2928
|
-
"draft.created": "
|
|
2929
|
-
"draft.updated": "
|
|
2930
|
-
"draft.deleted": "
|
|
2931
|
-
"draft.restored": "
|
|
3122
|
+
"draft.created": "创建想法",
|
|
3123
|
+
"draft.updated": "更新想法",
|
|
3124
|
+
"draft.deleted": "删除想法",
|
|
3125
|
+
"draft.restored": "恢复想法",
|
|
2932
3126
|
"work.imported": "导入正文"
|
|
2933
3127
|
};
|
|
2934
3128
|
|
|
2935
3129
|
function workAuditEntityLabel(type) {
|
|
2936
|
-
return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "
|
|
3130
|
+
return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "想法", user: "用户" })[type] ?? type;
|
|
2937
3131
|
}
|
|
2938
3132
|
|
|
2939
3133
|
function workAuditDetailText(detail) {
|
|
@@ -3051,6 +3245,7 @@ async function openPlatformUiSettingsDialog() {
|
|
|
3051
3245
|
$("#page-size-timeline").value = String(pageSizes.timeline);
|
|
3052
3246
|
$("#page-size-outlines").value = String(pageSizes.outlines);
|
|
3053
3247
|
$("#page-size-relationships").value = String(pageSizes.relationships);
|
|
3248
|
+
$("#page-size-comments").value = String(pageSizes.comments);
|
|
3054
3249
|
$("#page-size-reviews").value = String(pageSizes.reviews);
|
|
3055
3250
|
$("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
|
|
3056
3251
|
$("#page-size-file-versions").value = String(pageSizes.fileVersions);
|
|
@@ -3417,6 +3612,7 @@ function resetWorkScopedUiCaches() {
|
|
|
3417
3612
|
aiReferencesLoadWorkId = null;
|
|
3418
3613
|
aiConversationsLoadPromise = null;
|
|
3419
3614
|
aiConversationsLoadWorkId = null;
|
|
3615
|
+
aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
3420
3616
|
raceHierarchyLoadPromise = null;
|
|
3421
3617
|
raceHierarchyLoadWorkId = null;
|
|
3422
3618
|
loadedRaceHierarchyWorkId = null;
|
|
@@ -3438,9 +3634,6 @@ function resetWorkScopedUiCaches() {
|
|
|
3438
3634
|
volumeChapterRequests.clear();
|
|
3439
3635
|
state.collapsedRaceIds.clear();
|
|
3440
3636
|
lastSavedChapterSnapshot = null;
|
|
3441
|
-
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
3442
|
-
aiContextUsageTimer = null;
|
|
3443
|
-
aiContextUsageRequest += 1;
|
|
3444
3637
|
state.aiCitations = [];
|
|
3445
3638
|
state.aiReferences = [];
|
|
3446
3639
|
state.aiPromptSent = false;
|
|
@@ -3962,7 +4155,7 @@ function markActiveModule(module) {
|
|
|
3962
4155
|
}
|
|
3963
4156
|
|
|
3964
4157
|
const moduleMeta = {
|
|
3965
|
-
drafts: ["临时想法", "
|
|
4158
|
+
drafts: ["临时想法", "创作想法", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建想法"],
|
|
3966
4159
|
settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
|
|
3967
4160
|
characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
|
|
3968
4161
|
races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
|
|
@@ -3970,6 +4163,7 @@ const moduleMeta = {
|
|
|
3970
4163
|
timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
|
|
3971
4164
|
outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
|
|
3972
4165
|
relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
|
|
4166
|
+
comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
|
|
3973
4167
|
reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
|
|
3974
4168
|
tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
|
|
3975
4169
|
"ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
|
|
@@ -4014,7 +4208,7 @@ async function showModule(module) {
|
|
|
4014
4208
|
$("#module-description").textContent = meta[2];
|
|
4015
4209
|
$("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
|
|
4016
4210
|
$("#module-create-button").textContent = meta[3];
|
|
4017
|
-
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
|
|
4211
|
+
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || module === "comments" || !canEditModule(module));
|
|
4018
4212
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
4019
4213
|
bindModuleContentInteractions();
|
|
4020
4214
|
try {
|
|
@@ -4026,6 +4220,7 @@ async function showModule(module) {
|
|
|
4026
4220
|
if (module === "timeline") await renderTimeline();
|
|
4027
4221
|
if (module === "outlines") await renderOutlines();
|
|
4028
4222
|
if (module === "relationships") await renderRelationships();
|
|
4223
|
+
if (module === "comments") await renderWorkChapterComments();
|
|
4029
4224
|
if (module === "reviews") await renderReviews();
|
|
4030
4225
|
if (module === "tasks") await renderTasks(taskListPage);
|
|
4031
4226
|
if (module === "ai-settings") await renderBookAiSettings();
|
|
@@ -4333,7 +4528,7 @@ function mountRelationshipFilterToggle() {
|
|
|
4333
4528
|
|
|
4334
4529
|
function mountDraftFilterToggle() {
|
|
4335
4530
|
$("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
|
|
4336
|
-
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="
|
|
4531
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选想法" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" 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>`);
|
|
4337
4532
|
$("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
|
|
4338
4533
|
draftFiltersPanelOpen = !draftFiltersPanelOpen;
|
|
4339
4534
|
await renderDrafts(moduleListPages.drafts);
|
|
@@ -4429,15 +4624,15 @@ function settingRecordActions(item) {
|
|
|
4429
4624
|
}
|
|
4430
4625
|
|
|
4431
4626
|
function draftTypeLabel(draftType) {
|
|
4432
|
-
return draftType === "setting" ? "
|
|
4627
|
+
return draftType === "setting" ? "设定想法" : "正文想法";
|
|
4433
4628
|
}
|
|
4434
4629
|
|
|
4435
4630
|
async function deleteDraft(item) {
|
|
4436
4631
|
if (!item || !canEditModule("drafts")) return;
|
|
4437
4632
|
const dialog = $("#form-dialog");
|
|
4438
4633
|
dialog.close();
|
|
4439
|
-
if (!await confirmToast(
|
|
4440
|
-
title: "
|
|
4634
|
+
if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
|
|
4635
|
+
title: "删除想法",
|
|
4441
4636
|
confirmLabel: "确认删除"
|
|
4442
4637
|
})) {
|
|
4443
4638
|
openDraftDialog(item);
|
|
@@ -4446,7 +4641,7 @@ async function deleteDraft(item) {
|
|
|
4446
4641
|
try {
|
|
4447
4642
|
await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
4448
4643
|
await renderDrafts(moduleListPages.drafts);
|
|
4449
|
-
toast("
|
|
4644
|
+
toast("想法已删除");
|
|
4450
4645
|
} catch (error) {
|
|
4451
4646
|
toast(error.message, "error");
|
|
4452
4647
|
try {
|
|
@@ -4459,21 +4654,21 @@ async function deleteDraft(item) {
|
|
|
4459
4654
|
|
|
4460
4655
|
function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
4461
4656
|
const viewOnly = readOnly || !canEditModule("drafts");
|
|
4462
|
-
const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="
|
|
4463
|
-
<div><strong
|
|
4464
|
-
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete
|
|
4657
|
+
const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="想法操作">
|
|
4658
|
+
<div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
|
|
4659
|
+
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
|
|
4465
4660
|
</section>` : "";
|
|
4466
|
-
const fields = `<p class="form-field-note"
|
|
4467
|
-
+ field("draftType", "
|
|
4661
|
+
const fields = `<p class="form-field-note">这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
|
|
4662
|
+
+ field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
|
|
4468
4663
|
+ field("title", "标题", "text", item?.title ?? "")
|
|
4469
4664
|
+ field("content", "内容", "markdown", item?.content ?? "", {
|
|
4470
4665
|
placeholder: "记录尚未定稿的片段、方向或设定想法……",
|
|
4471
4666
|
readOnly: viewOnly
|
|
4472
4667
|
}) + management;
|
|
4473
|
-
openDialog(item ? viewOnly ? "
|
|
4668
|
+
openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
|
|
4474
4669
|
if (viewOnly) return;
|
|
4475
4670
|
const title = String(form.get("title") ?? "").trim();
|
|
4476
|
-
if (!title) throw new Error("
|
|
4671
|
+
if (!title) throw new Error("请填写想法标题");
|
|
4477
4672
|
const body = {
|
|
4478
4673
|
draftType: form.get("draftType"),
|
|
4479
4674
|
title,
|
|
@@ -4485,12 +4680,12 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4485
4680
|
body
|
|
4486
4681
|
});
|
|
4487
4682
|
await renderDrafts(moduleListPages.drafts);
|
|
4488
|
-
toast(item ? "
|
|
4683
|
+
toast(item ? "想法已保存" : "想法已创建");
|
|
4489
4684
|
}, item ? draftTypeLabel(item.draftType) : "未确认想法", {
|
|
4490
|
-
submitLabel: viewOnly ? "关闭" : "
|
|
4685
|
+
submitLabel: viewOnly ? "关闭" : "保存想法",
|
|
4491
4686
|
hideCancel: viewOnly,
|
|
4492
4687
|
editor: true,
|
|
4493
|
-
errorPrefix: "
|
|
4688
|
+
errorPrefix: "想法保存失败:"
|
|
4494
4689
|
});
|
|
4495
4690
|
if (viewOnly) {
|
|
4496
4691
|
$("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
|
|
@@ -4512,23 +4707,23 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4512
4707
|
const pageResult = paginateModuleItems(drafts, page, "drafts");
|
|
4513
4708
|
moduleListPages.drafts = pageResult.page;
|
|
4514
4709
|
const layout = readModuleLayout();
|
|
4515
|
-
if (drafts.length) mountModuleLayoutToggle(layout, "
|
|
4710
|
+
if (drafts.length) mountModuleLayoutToggle(layout, "想法列表样式");
|
|
4516
4711
|
else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
|
|
4517
4712
|
mountDraftFilterToggle();
|
|
4518
|
-
const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="
|
|
4519
|
-
<label for="draft-type-filter"
|
|
4520
|
-
<select id="draft-type-filter" aria-label="
|
|
4521
|
-
<option value="all" ${draftTypeFilter === "all" ? "selected" : ""}
|
|
4522
|
-
<option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}
|
|
4523
|
-
<option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}
|
|
4713
|
+
const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="想法筛选">
|
|
4714
|
+
<label for="draft-type-filter">想法类型</label>
|
|
4715
|
+
<select id="draft-type-filter" aria-label="按想法类型筛选">
|
|
4716
|
+
<option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部想法</option>
|
|
4717
|
+
<option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文想法</option>
|
|
4718
|
+
<option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定想法</option>
|
|
4524
4719
|
</select>
|
|
4525
|
-
${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length}
|
|
4720
|
+
${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>`}
|
|
4526
4721
|
</section>`;
|
|
4527
4722
|
const actions = (item) => canEditModule("drafts")
|
|
4528
|
-
? `${recordCardEditButton("edit-draft", item.id,
|
|
4723
|
+
? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
|
|
4529
4724
|
: recordHistoryButton("draft", item.id, item.title);
|
|
4530
4725
|
const cards = `<div class="card-grid">${pageResult.items.map((item) => `
|
|
4531
|
-
<article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="
|
|
4726
|
+
<article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
|
|
4532
4727
|
<small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
4533
4728
|
<h3>${esc(item.title)}</h3>
|
|
4534
4729
|
<div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
|
|
@@ -4536,17 +4731,17 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4536
4731
|
</article>`).join("")}</div>`;
|
|
4537
4732
|
const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
|
|
4538
4733
|
const preview = moduleRowPreview(item.contentPreview || "暂无内容");
|
|
4539
|
-
return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="
|
|
4734
|
+
return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
|
|
4540
4735
|
<small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
|
|
4541
4736
|
<h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
4542
4737
|
<div class="card-actions">${actions(item)}</div>
|
|
4543
4738
|
</article>`;
|
|
4544
4739
|
}).join("")}</div>`;
|
|
4545
4740
|
const emptyDrafts = allDrafts.length
|
|
4546
|
-
? emptyModule("
|
|
4547
|
-
: emptyModule("
|
|
4741
|
+
? emptyModule("没有符合筛选条件的想法", "可以切换想法类型查看其他内容。")
|
|
4742
|
+
: emptyModule("还没有想法", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
|
|
4548
4743
|
$("#module-content").innerHTML = filterToolbar + (drafts.length
|
|
4549
|
-
? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "
|
|
4744
|
+
? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "想法列表")}`
|
|
4550
4745
|
: emptyDrafts);
|
|
4551
4746
|
$("#draft-type-filter").addEventListener("change", async (event) => {
|
|
4552
4747
|
draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
|
|
@@ -5057,6 +5252,35 @@ async function renderRelationships(page = moduleListPages.relationships) {
|
|
|
5057
5252
|
bindEntityHistoryButtons(async () => { await renderRelationships(pageResult.page); await loadAiReferences(); });
|
|
5058
5253
|
}
|
|
5059
5254
|
|
|
5255
|
+
async function renderWorkChapterComments(page = moduleListPages.comments) {
|
|
5256
|
+
const pageSize = pageSizeFor("comments");
|
|
5257
|
+
const result = await moduleApiPage(
|
|
5258
|
+
"comments",
|
|
5259
|
+
`/api/works/${encodeURIComponent(state.work.id)}/chapter-annotations`,
|
|
5260
|
+
page,
|
|
5261
|
+
pageSize
|
|
5262
|
+
);
|
|
5263
|
+
if (!result.items.length && page > 1) return renderWorkChapterComments(page - 1);
|
|
5264
|
+
const total = Number(result.total ?? result.items.length);
|
|
5265
|
+
const pageCount = Math.max(1, Math.ceil(total / result.limit));
|
|
5266
|
+
const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
|
|
5267
|
+
moduleListPages.comments = pageResult.page;
|
|
5268
|
+
mountModuleCount(total);
|
|
5269
|
+
$("#module-content").innerHTML = result.items.length
|
|
5270
|
+
? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论列表")}`
|
|
5271
|
+
: emptyModule("还没有正文评论", "在任一正文行上点击右键,即可添加评论或待办。");
|
|
5272
|
+
bindModulePagination("comments", renderWorkChapterComments);
|
|
5273
|
+
bindChapterAnnotationCards($("#module-content"), result.items, {
|
|
5274
|
+
refresh: () => renderWorkChapterComments(pageResult.page),
|
|
5275
|
+
locate: async (annotation) => {
|
|
5276
|
+
await selectChapter(annotation.chapterId);
|
|
5277
|
+
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
|
5278
|
+
paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
|
|
5279
|
+
selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
|
|
5280
|
+
}
|
|
5281
|
+
});
|
|
5282
|
+
}
|
|
5283
|
+
|
|
5060
5284
|
async function renderReviews(page = moduleListPages.reviews) {
|
|
5061
5285
|
const canReadCharacters = canReadModule("characters");
|
|
5062
5286
|
const canResolveReview = canEditModule("reviews");
|
|
@@ -5376,12 +5600,7 @@ async function rerunAnalysisTaskWithModel(task, button) {
|
|
|
5376
5600
|
try {
|
|
5377
5601
|
const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
|
|
5378
5602
|
const currentModelId = String(task.model?.id ?? "");
|
|
5379
|
-
const availableModels = models.filter((model) =>
|
|
5380
|
-
model.id !== currentModelId
|
|
5381
|
-
&& model.enabled
|
|
5382
|
-
&& model.providerStatus === "enabled"
|
|
5383
|
-
&& model.providerConnectionStatus === "success"
|
|
5384
|
-
);
|
|
5603
|
+
const availableModels = models.filter((model) => model.id !== currentModelId && isSelectableModel(model));
|
|
5385
5604
|
if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
|
|
5386
5605
|
$("#form-dialog").close();
|
|
5387
5606
|
const currentModelLabel = task.model?.displayName || "运行时默认模型";
|
|
@@ -5892,11 +6111,28 @@ function openTaskDetailDialog(task, trace) {
|
|
|
5892
6111
|
}
|
|
5893
6112
|
|
|
5894
6113
|
function renderProviderCards(providers, models) {
|
|
5895
|
-
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) =>
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
6114
|
+
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
|
|
6115
|
+
const providerModels = models.filter((model) => model.providerId === provider.id);
|
|
6116
|
+
const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
|
|
6117
|
+
const disabledNotice = provider.status === "disabled"
|
|
6118
|
+
? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
|
|
6119
|
+
: "";
|
|
6120
|
+
return `
|
|
6121
|
+
<article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
|
|
6122
|
+
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
6123
|
+
<div class="provider-models">${providerModels.map((model) => {
|
|
6124
|
+
const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
6125
|
+
const modelStatus = !model.enabled
|
|
6126
|
+
? `<span class="model-status-badge is-disabled">模型已停用</span>`
|
|
6127
|
+
: provider.status !== "enabled"
|
|
6128
|
+
? `<span class="model-status-badge is-disabled">供应商已停用</span>`
|
|
6129
|
+
: provider.connectionStatus !== "success"
|
|
6130
|
+
? `<span class="model-status-badge is-unavailable">连接不可用</span>`
|
|
6131
|
+
: "";
|
|
6132
|
+
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 ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
|
|
6133
|
+
}).join("")}</div>
|
|
6134
|
+
<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-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
6135
|
+
}).join("")}</div>`
|
|
5900
6136
|
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
|
|
5901
6137
|
}
|
|
5902
6138
|
|
|
@@ -5914,23 +6150,37 @@ function bindPlatformProviderActions(host, providers, models) {
|
|
|
5914
6150
|
host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
|
|
5915
6151
|
}
|
|
5916
6152
|
|
|
5917
|
-
function renderTaskDefaults(models, providers, taskDefaults) {
|
|
6153
|
+
function renderTaskDefaults(models, providers, taskDefaults, settings) {
|
|
5918
6154
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
5919
6155
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
5920
|
-
|
|
6156
|
+
const availableModels = models.filter((model) => isSelectableModel(model));
|
|
6157
|
+
const availableModelIds = new Set(availableModels.map((model) => model.id));
|
|
6158
|
+
const currentDefaultModels = taskDefaults
|
|
6159
|
+
.map((item) => item.model)
|
|
6160
|
+
.filter((model) => model && !availableModelIds.has(model.id));
|
|
6161
|
+
const optionModels = [...availableModels, ...currentDefaultModels];
|
|
6162
|
+
return optionModels.length ? `<section class="config-section">
|
|
5921
6163
|
<div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
|
|
5922
|
-
<table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody
|
|
6164
|
+
<table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody><tr><td>创作助手对话标题生成</td><td><select class="default-model-select" data-title-generation-default aria-label="创作助手对话标题生成">
|
|
6165
|
+
<option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
|
|
6166
|
+
${models.map((model) => {
|
|
6167
|
+
const provider = providerById.get(model.providerId);
|
|
6168
|
+
const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
|
|
6169
|
+
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>`;
|
|
6170
|
+
}).join("")}
|
|
6171
|
+
</select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
|
|
5923
6172
|
const currentModelId = defaultModelByTask.get(taskType) ?? "";
|
|
5924
6173
|
return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
|
|
5925
6174
|
<option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
|
|
5926
|
-
${
|
|
6175
|
+
${optionModels.map((model) => {
|
|
5927
6176
|
const provider = providerById.get(model.providerId);
|
|
5928
|
-
const available = model.
|
|
5929
|
-
|
|
6177
|
+
const available = isSelectableModel({ ...model, providerStatus: model.providerStatus ?? provider?.status, providerConnectionStatus: model.providerConnectionStatus ?? provider?.connectionStatus });
|
|
6178
|
+
const unavailableLabel = !model.enabled ? "模型已停用 · " : provider?.status !== "enabled" ? "供应商已停用 · " : "连接不可用 · ";
|
|
6179
|
+
return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available ? "" : "disabled"}>${esc(`${available ? "" : unavailableLabel}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
|
|
5930
6180
|
}).join("")}
|
|
5931
6181
|
</select></td></tr>`;
|
|
5932
6182
|
}).join("")}</tbody></table>
|
|
5933
|
-
</section>` : emptyModule("
|
|
6183
|
+
</section>` : emptyModule("尚未配置可用模型", "请先启用并测试供应商模型,已停用模型不会出现在新任务选择中。");
|
|
5934
6184
|
}
|
|
5935
6185
|
|
|
5936
6186
|
const relationshipIndexStatusLabels = Object.freeze({
|
|
@@ -6324,7 +6574,7 @@ async function renderBookAiSettings() {
|
|
|
6324
6574
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
6325
6575
|
title: "本书 Token 用量",
|
|
6326
6576
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
6327
|
-
})}</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>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</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>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
|
|
6577
|
+
})}</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>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</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>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
|
|
6328
6578
|
scrollUsageCalendarsToLatest(host);
|
|
6329
6579
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
6330
6580
|
"beforebegin",
|
|
@@ -6336,7 +6586,7 @@ async function renderBookAiSettings() {
|
|
|
6336
6586
|
);
|
|
6337
6587
|
host.querySelector(".ai-agent-tools").insertAdjacentHTML(
|
|
6338
6588
|
"beforeend",
|
|
6339
|
-
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong
|
|
6589
|
+
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label>`
|
|
6340
6590
|
);
|
|
6341
6591
|
if (!canEditModule("ai-settings")) {
|
|
6342
6592
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
@@ -6375,7 +6625,7 @@ async function renderBookAiSettings() {
|
|
|
6375
6625
|
try {
|
|
6376
6626
|
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { systemPrompt: $("#work-system-prompt").value } });
|
|
6377
6627
|
toast("本书系统提示词已保存");
|
|
6378
|
-
|
|
6628
|
+
setAiContextMeter(null);
|
|
6379
6629
|
} catch (error) {
|
|
6380
6630
|
toast(error.message, "error");
|
|
6381
6631
|
} finally {
|
|
@@ -6428,7 +6678,7 @@ async function renderBookAiSettings() {
|
|
|
6428
6678
|
try {
|
|
6429
6679
|
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { bookSummaryContextPercent: Number($("#book-summary-context-percent").value) } });
|
|
6430
6680
|
toast("全书概要引用配额已保存");
|
|
6431
|
-
|
|
6681
|
+
setAiContextMeter(null);
|
|
6432
6682
|
} catch (error) {
|
|
6433
6683
|
toast(error.message, "error");
|
|
6434
6684
|
} finally {
|
|
@@ -6441,7 +6691,7 @@ async function renderBookAiSettings() {
|
|
|
6441
6691
|
try {
|
|
6442
6692
|
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
|
|
6443
6693
|
toast("对话上下文 compact 阈值已保存");
|
|
6444
|
-
|
|
6694
|
+
setAiContextMeter(null);
|
|
6445
6695
|
} catch (error) {
|
|
6446
6696
|
toast(error.message, "error");
|
|
6447
6697
|
} finally {
|
|
@@ -6461,6 +6711,18 @@ async function renderBookAiSettings() {
|
|
|
6461
6711
|
button.disabled = false;
|
|
6462
6712
|
}
|
|
6463
6713
|
});
|
|
6714
|
+
host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
|
|
6715
|
+
const select = event.currentTarget;
|
|
6716
|
+
select.disabled = true;
|
|
6717
|
+
try {
|
|
6718
|
+
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { titleGenerationModelId: select.value } });
|
|
6719
|
+
toast("创作助手对话标题生成模型已更新");
|
|
6720
|
+
} catch (error) {
|
|
6721
|
+
toast(error.message, "error");
|
|
6722
|
+
}
|
|
6723
|
+
await renderBookAiSettings();
|
|
6724
|
+
await loadModels();
|
|
6725
|
+
});
|
|
6464
6726
|
host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
|
|
6465
6727
|
select.disabled = true;
|
|
6466
6728
|
try {
|
|
@@ -6480,13 +6742,17 @@ async function loadModels() {
|
|
|
6480
6742
|
const generation = workScopedUiGeneration;
|
|
6481
6743
|
const models = await api(`/api/works/${workId}/models`);
|
|
6482
6744
|
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
6483
|
-
|
|
6745
|
+
applyAiModels(models);
|
|
6484
6746
|
loadedAiModelsWorkId = workId;
|
|
6747
|
+
}
|
|
6748
|
+
|
|
6749
|
+
function applyAiModels(models) {
|
|
6750
|
+
state.models = models.filter((model) => isSelectableModel(model));
|
|
6485
6751
|
const select = $("#ai-model");
|
|
6486
6752
|
select.innerHTML = state.models.length
|
|
6487
|
-
? state.models.map((model) => `<option value="${esc(model.id)}"
|
|
6753
|
+
? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
|
|
6488
6754
|
: '<option value="">请先配置模型</option>';
|
|
6489
|
-
|
|
6755
|
+
setAiContextMeter(null);
|
|
6490
6756
|
}
|
|
6491
6757
|
|
|
6492
6758
|
async function ensureAiModelsLoaded() {
|
|
@@ -6510,9 +6776,6 @@ async function ensureAiModelsLoaded() {
|
|
|
6510
6776
|
}
|
|
6511
6777
|
}
|
|
6512
6778
|
|
|
6513
|
-
let aiContextUsageTimer = null;
|
|
6514
|
-
let aiContextUsageRequest = 0;
|
|
6515
|
-
|
|
6516
6779
|
function currentAiRequestScope() {
|
|
6517
6780
|
if (!state.work) return null;
|
|
6518
6781
|
const taskType = $("#ai-task").value;
|
|
@@ -6532,9 +6795,50 @@ function currentAiRequestScope() {
|
|
|
6532
6795
|
return { taskType, scope, selection };
|
|
6533
6796
|
}
|
|
6534
6797
|
|
|
6798
|
+
function renderAiContextDistribution(usage) {
|
|
6799
|
+
const popover = $("#ai-context-popover");
|
|
6800
|
+
const host = $("#ai-context-distribution");
|
|
6801
|
+
const distribution = normalizeAiContextTokenDistribution(usage);
|
|
6802
|
+
const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
|
|
6803
|
+
$("#ai-context-popover-description").textContent = usage
|
|
6804
|
+
? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok · ${formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow)}`
|
|
6805
|
+
: "选择可用模型后显示当前上下文用量";
|
|
6806
|
+
host.replaceChildren(...distribution.items.map((item) => {
|
|
6807
|
+
const row = document.createElement("div");
|
|
6808
|
+
row.className = "ai-context-distribution-row";
|
|
6809
|
+
row.dataset.key = item.key;
|
|
6810
|
+
row.setAttribute("role", "listitem");
|
|
6811
|
+
row.setAttribute("aria-label", `${item.label}:${item.tokens.toLocaleString("zh-CN")} tok,占 ${item.percent}%`);
|
|
6812
|
+
|
|
6813
|
+
const label = document.createElement("div");
|
|
6814
|
+
label.className = "ai-context-distribution-label";
|
|
6815
|
+
const title = document.createElement("span");
|
|
6816
|
+
title.textContent = item.label;
|
|
6817
|
+
if (item.key === "skills" || item.key === "context") {
|
|
6818
|
+
const description = document.createElement("small");
|
|
6819
|
+
description.textContent = item.key === "skills" ? "待加入" : "用户和 agent 的交互";
|
|
6820
|
+
title.append(" ", description);
|
|
6821
|
+
}
|
|
6822
|
+
const value = document.createElement("strong");
|
|
6823
|
+
value.textContent = `${item.tokens.toLocaleString("zh-CN")} tok · ${item.percent}%`;
|
|
6824
|
+
label.append(title, value);
|
|
6825
|
+
|
|
6826
|
+
const track = document.createElement("div");
|
|
6827
|
+
track.className = "ai-context-distribution-track";
|
|
6828
|
+
track.setAttribute("aria-hidden", "true");
|
|
6829
|
+
const bar = document.createElement("span");
|
|
6830
|
+
bar.style.setProperty("--distribution-percent", String(item.percent));
|
|
6831
|
+
track.append(bar);
|
|
6832
|
+
row.append(label, track);
|
|
6833
|
+
return row;
|
|
6834
|
+
}));
|
|
6835
|
+
popover.dataset.hasUsage = String(Boolean(usage));
|
|
6836
|
+
}
|
|
6837
|
+
|
|
6535
6838
|
function setAiContextMeter(usage) {
|
|
6536
6839
|
const meter = $("#ai-context-meter");
|
|
6537
6840
|
const value = meter.querySelector("b");
|
|
6841
|
+
renderAiContextDistribution(usage);
|
|
6538
6842
|
if (!usage) {
|
|
6539
6843
|
meter.classList.add("is-empty");
|
|
6540
6844
|
meter.classList.remove("is-warning", "is-danger");
|
|
@@ -6556,6 +6860,14 @@ function setAiContextMeter(usage) {
|
|
|
6556
6860
|
meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
|
|
6557
6861
|
}
|
|
6558
6862
|
|
|
6863
|
+
function setAiContextDistributionVisible(visible) {
|
|
6864
|
+
const meter = $("#ai-context-meter");
|
|
6865
|
+
const popover = $("#ai-context-popover");
|
|
6866
|
+
popover.classList.toggle("hidden", !visible);
|
|
6867
|
+
meter.setAttribute("aria-expanded", String(visible));
|
|
6868
|
+
if (!visible && document.activeElement === $("#ai-context-popover-close")) meter.focus();
|
|
6869
|
+
}
|
|
6870
|
+
|
|
6559
6871
|
function showAiContextWarning(usage = null) {
|
|
6560
6872
|
const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
|
|
6561
6873
|
const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
|
|
@@ -6568,57 +6880,6 @@ function hideAiContextWarning() {
|
|
|
6568
6880
|
$("#ai-context-warning").classList.add("hidden");
|
|
6569
6881
|
}
|
|
6570
6882
|
|
|
6571
|
-
async function prepareAiConversationContext({ instruction, scope, modelId, citations }) {
|
|
6572
|
-
const conversationId = await ensureAiConversation();
|
|
6573
|
-
const prepared = await api(`/api/ai-conversations/${conversationId}/context/prepare`, {
|
|
6574
|
-
method: "POST",
|
|
6575
|
-
body: { instruction, scope, modelId, citations }
|
|
6576
|
-
});
|
|
6577
|
-
setAiContextMeter(prepared.usage);
|
|
6578
|
-
if (prepared.action === "warn") {
|
|
6579
|
-
showAiContextWarning(prepared.usage);
|
|
6580
|
-
return false;
|
|
6581
|
-
}
|
|
6582
|
-
hideAiContextWarning();
|
|
6583
|
-
if (prepared.action === "compacted") toast("已自动整理较早对话为长期记忆并继续发送");
|
|
6584
|
-
return true;
|
|
6585
|
-
}
|
|
6586
|
-
|
|
6587
|
-
function scheduleAiContextUsage() {
|
|
6588
|
-
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
6589
|
-
aiContextUsageTimer = setTimeout(() => {
|
|
6590
|
-
aiContextUsageTimer = null;
|
|
6591
|
-
void refreshAiContextUsage();
|
|
6592
|
-
}, 260);
|
|
6593
|
-
}
|
|
6594
|
-
|
|
6595
|
-
async function refreshAiContextUsage() {
|
|
6596
|
-
const requestScope = currentAiRequestScope();
|
|
6597
|
-
const modelId = $("#ai-model").value;
|
|
6598
|
-
if (!requestScope || !modelId || (requestScope.taskType === "polish" && !requestScope.selection)) {
|
|
6599
|
-
setAiContextMeter(null);
|
|
6600
|
-
return;
|
|
6601
|
-
}
|
|
6602
|
-
const requestId = ++aiContextUsageRequest;
|
|
6603
|
-
try {
|
|
6604
|
-
const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
6605
|
-
const usage = await api(`/api/works/${state.work.id}/ai-context-usage`, {
|
|
6606
|
-
method: "POST",
|
|
6607
|
-
body: {
|
|
6608
|
-
modelId,
|
|
6609
|
-
taskType: requestScope.taskType,
|
|
6610
|
-
scope: requestScope.scope,
|
|
6611
|
-
instruction: aiPromptText(),
|
|
6612
|
-
citations,
|
|
6613
|
-
conversationId: state.aiConversationId || undefined
|
|
6614
|
-
}
|
|
6615
|
-
});
|
|
6616
|
-
if (requestId === aiContextUsageRequest) setAiContextMeter(usage);
|
|
6617
|
-
} catch {
|
|
6618
|
-
if (requestId === aiContextUsageRequest) setAiContextMeter(null);
|
|
6619
|
-
}
|
|
6620
|
-
}
|
|
6621
|
-
|
|
6622
6883
|
async function loadAiReferences() {
|
|
6623
6884
|
const workId = state.work?.id;
|
|
6624
6885
|
if (!workId) return;
|
|
@@ -8567,11 +8828,7 @@ async function openTaskDialog() {
|
|
|
8567
8828
|
return;
|
|
8568
8829
|
}
|
|
8569
8830
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
8570
|
-
const availableTaskModels = taskModels.filter((model) =>
|
|
8571
|
-
model.enabled
|
|
8572
|
-
&& model.providerStatus === "enabled"
|
|
8573
|
-
&& model.providerConnectionStatus === "success"
|
|
8574
|
-
);
|
|
8831
|
+
const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
|
|
8575
8832
|
const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
|
|
8576
8833
|
const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
|
|
8577
8834
|
<span id="relationship-character-label">被分析角色(可多选)</span>
|
|
@@ -8877,8 +9134,8 @@ async function openTaskDialog() {
|
|
|
8877
9134
|
function openProviderDialog(item) {
|
|
8878
9135
|
const protocol = item?.protocol ?? "openai-chat-completions";
|
|
8879
9136
|
const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
|
|
8880
|
-
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("
|
|
8881
|
-
const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")),
|
|
9137
|
+
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
|
|
9138
|
+
const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
|
|
8882
9139
|
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
8883
9140
|
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
8884
9141
|
await renderPlatformAiConfig();
|
|
@@ -8897,16 +9154,30 @@ function openProviderDialog(item) {
|
|
|
8897
9154
|
|
|
8898
9155
|
function openModelDialog(providerId, item = null) {
|
|
8899
9156
|
const values = modelFormValues(item);
|
|
9157
|
+
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>`;
|
|
8900
9158
|
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>`;
|
|
8901
|
-
|
|
9159
|
+
const connectionTest = item && item.providerStatus === "enabled"
|
|
9160
|
+
? `<section class="model-connection-test">
|
|
9161
|
+
<div><strong>模型连接测试</strong><p>使用当前已保存的模型标识符和供应商凭据发起最小请求。</p></div>
|
|
9162
|
+
<button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
|
|
9163
|
+
</section>`
|
|
9164
|
+
: "";
|
|
9165
|
+
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("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
8902
9166
|
const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
|
|
8903
9167
|
await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
|
|
8904
9168
|
await renderPlatformAiConfig();
|
|
8905
9169
|
await loadModels();
|
|
8906
9170
|
}, item ? "模型配置" : "供应商模型");
|
|
8907
9171
|
const modelIdInput = $("#dialog-fields input[name='modelId']");
|
|
9172
|
+
const contextWindowInput = $("#model-context-window");
|
|
9173
|
+
const contextWindowHint = $("#model-context-window-hint");
|
|
8908
9174
|
const temperatureInput = $("#dialog-fields input[name='temperature']");
|
|
8909
9175
|
const temperatureHint = $("#model-temperature-hint");
|
|
9176
|
+
const syncModelContextWindowGuidance = () => {
|
|
9177
|
+
const guidance = modelContextWindowGuidance(contextWindowInput.value);
|
|
9178
|
+
contextWindowInput.setCustomValidity(guidance.belowMinimum ? "模型上下文不能低于 32K(32768 Token)。" : "");
|
|
9179
|
+
contextWindowHint.hidden = !guidance.showRecommendation;
|
|
9180
|
+
};
|
|
8910
9181
|
const syncKimiTemperature = () => {
|
|
8911
9182
|
const isKimi = isKimiModelId(modelIdInput.value);
|
|
8912
9183
|
temperatureHint.hidden = !isKimi;
|
|
@@ -8915,13 +9186,31 @@ function openModelDialog(providerId, item = null) {
|
|
|
8915
9186
|
if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
|
|
8916
9187
|
syncKimiTemperature();
|
|
8917
9188
|
});
|
|
9189
|
+
contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
|
|
9190
|
+
$("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
|
|
9191
|
+
const button = event.currentTarget;
|
|
9192
|
+
button.disabled = true;
|
|
9193
|
+
button.textContent = "测试中";
|
|
9194
|
+
try {
|
|
9195
|
+
const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
|
|
9196
|
+
toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
|
|
9197
|
+
await renderPlatformAiConfig();
|
|
9198
|
+
await loadModels();
|
|
9199
|
+
} catch (error) {
|
|
9200
|
+
toast(`模型连接测试失败:${error.message}`, "error");
|
|
9201
|
+
} finally {
|
|
9202
|
+
button.disabled = false;
|
|
9203
|
+
button.textContent = "测试连接";
|
|
9204
|
+
}
|
|
9205
|
+
});
|
|
9206
|
+
syncModelContextWindowGuidance();
|
|
8918
9207
|
syncKimiTemperature();
|
|
8919
9208
|
}
|
|
8920
9209
|
|
|
8921
9210
|
async function sendAi() {
|
|
8922
9211
|
if (!state.work) return toast("请先选择作品", "error");
|
|
8923
9212
|
try {
|
|
8924
|
-
await
|
|
9213
|
+
await ensureAiModelsLoaded();
|
|
8925
9214
|
} catch (error) {
|
|
8926
9215
|
return toast(`创作助手加载失败:${error.message}`, "error");
|
|
8927
9216
|
}
|
|
@@ -8934,30 +9223,18 @@ async function sendAi() {
|
|
|
8934
9223
|
const { taskType, scope, selection } = requestScope;
|
|
8935
9224
|
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
8936
9225
|
const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
$("#ai-send").textContent = "检查上下文";
|
|
9226
|
+
let persistedUserMessage = null;
|
|
9227
|
+
if (taskType !== "chat") {
|
|
8940
9228
|
try {
|
|
8941
|
-
|
|
8942
|
-
if (!mayContinue) return;
|
|
9229
|
+
persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
|
|
8943
9230
|
} catch (error) {
|
|
8944
|
-
toast(
|
|
8945
|
-
return;
|
|
8946
|
-
} finally {
|
|
8947
|
-
$("#ai-send").disabled = false;
|
|
8948
|
-
$("#ai-send").textContent = "发送";
|
|
9231
|
+
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
8949
9232
|
}
|
|
9233
|
+
state.aiPromptSent = true;
|
|
9234
|
+
renderAiQuickActions();
|
|
9235
|
+
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
|
|
9236
|
+
clearAiPromptComposer();
|
|
8950
9237
|
}
|
|
8951
|
-
let persistedUserMessage;
|
|
8952
|
-
try {
|
|
8953
|
-
persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
|
|
8954
|
-
} catch (error) {
|
|
8955
|
-
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
8956
|
-
}
|
|
8957
|
-
state.aiPromptSent = true;
|
|
8958
|
-
renderAiQuickActions();
|
|
8959
|
-
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
|
|
8960
|
-
clearAiPromptComposer();
|
|
8961
9238
|
$("#ai-send").disabled = true;
|
|
8962
9239
|
$("#ai-send").textContent = "发送中";
|
|
8963
9240
|
try {
|
|
@@ -8967,18 +9244,27 @@ async function sendAi() {
|
|
|
8967
9244
|
let persistedStreamMessage = null;
|
|
8968
9245
|
let suggestion = null;
|
|
8969
9246
|
if (taskType === "chat") {
|
|
8970
|
-
const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId
|
|
9247
|
+
const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId || undefined });
|
|
9248
|
+
if (streamed.action === "warn") return;
|
|
8971
9249
|
assistantContent = streamed.content;
|
|
8972
9250
|
assistantMessage = streamed.message;
|
|
8973
9251
|
assistantMetadata = streamed.metadata;
|
|
8974
9252
|
persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
|
|
9253
|
+
applyAiConversationTitle(streamed.conversationTitle);
|
|
8975
9254
|
} else {
|
|
8976
9255
|
suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
|
|
9256
|
+
setAiContextMeter(suggestion.contextUsage);
|
|
8977
9257
|
assistantContent = suggestion.content;
|
|
8978
9258
|
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
|
|
8979
9259
|
}
|
|
8980
9260
|
try {
|
|
8981
9261
|
if (persistedStreamMessage) {
|
|
9262
|
+
updateAiConversationSummaryFromMessage({
|
|
9263
|
+
conversationId: state.aiConversationId,
|
|
9264
|
+
role: "assistant",
|
|
9265
|
+
content: assistantContent,
|
|
9266
|
+
createdAt: persistedStreamMessage.createdAt
|
|
9267
|
+
});
|
|
8982
9268
|
updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
|
|
8983
9269
|
attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
|
|
8984
9270
|
} else {
|
|
@@ -8993,7 +9279,7 @@ async function sendAi() {
|
|
|
8993
9279
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
8994
9280
|
}
|
|
8995
9281
|
} catch (error) {
|
|
8996
|
-
const failureMessage =
|
|
9282
|
+
const failureMessage = formatAiFailureMessage(error);
|
|
8997
9283
|
let persistedFailureMessage = null;
|
|
8998
9284
|
try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
8999
9285
|
appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
|
|
@@ -9008,13 +9294,19 @@ async function streamChat(body) {
|
|
|
9008
9294
|
message.className = "assistant-message is-streaming";
|
|
9009
9295
|
message.dataset.testid = "ai-stream-message";
|
|
9010
9296
|
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
|
|
9011
|
-
attachMessageHeading(message, "助手 · 正在生成");
|
|
9012
|
-
$("#ai-feed").append(message);
|
|
9013
|
-
scrollAiFeedToBottom();
|
|
9014
9297
|
const content = message.querySelector(".message-body");
|
|
9015
9298
|
const meta = message.querySelector(".message-meta");
|
|
9299
|
+
let messageMounted = false;
|
|
9300
|
+
const mountAssistantMessage = () => {
|
|
9301
|
+
if (messageMounted) return;
|
|
9302
|
+
attachMessageHeading(message, "助手 · 正在生成");
|
|
9303
|
+
$("#ai-feed").append(message);
|
|
9304
|
+
messageMounted = true;
|
|
9305
|
+
scrollAiFeedToBottom();
|
|
9306
|
+
};
|
|
9016
9307
|
const typewriter = createStreamTypewriter({
|
|
9017
9308
|
onRender: (text, progress) => {
|
|
9309
|
+
mountAssistantMessage();
|
|
9018
9310
|
content.innerHTML = renderMarkdown(text);
|
|
9019
9311
|
const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
|
|
9020
9312
|
meta.textContent = progress.pendingCharacters > 0
|
|
@@ -9029,6 +9321,9 @@ async function streamChat(body) {
|
|
|
9029
9321
|
let processSteps = [];
|
|
9030
9322
|
let persistedMessageId = null;
|
|
9031
9323
|
let persistedMessageCreatedAt = null;
|
|
9324
|
+
let conversationTitle = null;
|
|
9325
|
+
let persistedUserMessage = null;
|
|
9326
|
+
let contextAction = "ready";
|
|
9032
9327
|
let finalAnswerStarted = false;
|
|
9033
9328
|
const processStartedAt = Date.now();
|
|
9034
9329
|
const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
|
|
@@ -9040,7 +9335,7 @@ async function streamChat(body) {
|
|
|
9040
9335
|
});
|
|
9041
9336
|
if (!response.ok || !response.body) {
|
|
9042
9337
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
9043
|
-
throw
|
|
9338
|
+
throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
|
|
9044
9339
|
}
|
|
9045
9340
|
const reader = response.body.getReader();
|
|
9046
9341
|
const decoder = new TextDecoder();
|
|
@@ -9055,7 +9350,35 @@ async function streamChat(body) {
|
|
|
9055
9350
|
}
|
|
9056
9351
|
if (!dataLines.length) return;
|
|
9057
9352
|
const payload = JSON.parse(dataLines.join("\n"));
|
|
9058
|
-
if (eventName === "
|
|
9353
|
+
if (eventName === "context") {
|
|
9354
|
+
contextAction = typeof payload.action === "string" ? payload.action : "ready";
|
|
9355
|
+
setAiContextMeter(payload.usage);
|
|
9356
|
+
if (payload.conversation?.id) {
|
|
9357
|
+
state.aiConversationId = payload.conversation.id;
|
|
9358
|
+
upsertAiConversationSummary(payload.conversation);
|
|
9359
|
+
$("#ai-conversation-title").textContent = payload.conversation.title;
|
|
9360
|
+
}
|
|
9361
|
+
if (contextAction === "warn") showAiContextWarning(payload.usage);
|
|
9362
|
+
else {
|
|
9363
|
+
hideAiContextWarning();
|
|
9364
|
+
if (contextAction === "compacted") {
|
|
9365
|
+
renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
|
|
9366
|
+
toast("已自动整理较早对话为长期记忆并继续发送");
|
|
9367
|
+
}
|
|
9368
|
+
}
|
|
9369
|
+
} else if (eventName === "user_message") {
|
|
9370
|
+
persistedUserMessage = payload.message;
|
|
9371
|
+
if (persistedUserMessage?.id) {
|
|
9372
|
+
state.aiConversationId = persistedUserMessage.conversationId;
|
|
9373
|
+
updateAiConversationSummaryFromMessage(persistedUserMessage);
|
|
9374
|
+
state.aiPromptSent = true;
|
|
9375
|
+
renderAiQuickActions();
|
|
9376
|
+
appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
|
|
9377
|
+
clearAiPromptComposer();
|
|
9378
|
+
mountAssistantMessage();
|
|
9379
|
+
}
|
|
9380
|
+
} else if (eventName === "delta") {
|
|
9381
|
+
mountAssistantMessage();
|
|
9059
9382
|
const firstFinalDelta = streamedText.length === 0;
|
|
9060
9383
|
const delta = typeof payload.delta === "string" ? payload.delta : "";
|
|
9061
9384
|
streamedText += delta;
|
|
@@ -9064,6 +9387,7 @@ async function streamChat(body) {
|
|
|
9064
9387
|
if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
|
|
9065
9388
|
meta.textContent = "正在生成回复……";
|
|
9066
9389
|
} else if (eventName === "process_step") {
|
|
9390
|
+
mountAssistantMessage();
|
|
9067
9391
|
const step = { ...payload };
|
|
9068
9392
|
const append = step.append === true;
|
|
9069
9393
|
delete step.append;
|
|
@@ -9071,9 +9395,14 @@ async function streamChat(body) {
|
|
|
9071
9395
|
if (existing && typeof step.content === "string") existing.content += step.content;
|
|
9072
9396
|
else processSteps.push(step);
|
|
9073
9397
|
renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
|
|
9074
|
-
meta.textContent = step.type === "thinking"
|
|
9398
|
+
meta.textContent = step.type === "thinking"
|
|
9399
|
+
? `正在思考 · 第 ${Number(step.round) || 1} 轮`
|
|
9400
|
+
: step.type === "context_compaction"
|
|
9401
|
+
? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
|
|
9402
|
+
: `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
|
|
9075
9403
|
scrollAiFeedToBottom();
|
|
9076
9404
|
} else if (eventName === "tool_call") {
|
|
9405
|
+
mountAssistantMessage();
|
|
9077
9406
|
const toolCall = { ...payload };
|
|
9078
9407
|
const round = toolCall.round;
|
|
9079
9408
|
delete toolCall.round;
|
|
@@ -9082,9 +9411,15 @@ async function streamChat(body) {
|
|
|
9082
9411
|
renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
|
|
9083
9412
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
9084
9413
|
scrollAiFeedToBottom();
|
|
9414
|
+
} else if (eventName === "context_compacted") {
|
|
9415
|
+
setAiContextMeter(payload.contextUsage);
|
|
9416
|
+
if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
|
|
9085
9417
|
} else if (eventName === "complete") {
|
|
9418
|
+
mountAssistantMessage();
|
|
9086
9419
|
persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
9087
9420
|
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
9421
|
+
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
9422
|
+
setAiContextMeter(payload.contextUsage);
|
|
9088
9423
|
await typewriter.finish();
|
|
9089
9424
|
message.classList.remove("is-streaming");
|
|
9090
9425
|
content.setAttribute("aria-busy", "false");
|
|
@@ -9098,7 +9433,7 @@ async function streamChat(body) {
|
|
|
9098
9433
|
attachAssistantCopyAction(message, streamedText);
|
|
9099
9434
|
scrollAiFeedToBottom();
|
|
9100
9435
|
} else if (eventName === "error") {
|
|
9101
|
-
streamError =
|
|
9436
|
+
streamError = createClientError(payload, "AI 流式调用失败", response.status);
|
|
9102
9437
|
}
|
|
9103
9438
|
};
|
|
9104
9439
|
while (true) {
|
|
@@ -9112,8 +9447,9 @@ async function streamChat(body) {
|
|
|
9112
9447
|
if (buffer.trim()) await consume(buffer);
|
|
9113
9448
|
await typewriter.finish();
|
|
9114
9449
|
if (streamError) throw streamError;
|
|
9115
|
-
return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt };
|
|
9450
|
+
return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
|
|
9116
9451
|
} catch (error) {
|
|
9452
|
+
mountAssistantMessage();
|
|
9117
9453
|
typewriter.reveal();
|
|
9118
9454
|
message.classList.remove("is-streaming");
|
|
9119
9455
|
content.setAttribute("aria-busy", "false");
|
|
@@ -9127,9 +9463,21 @@ async function streamChat(body) {
|
|
|
9127
9463
|
|
|
9128
9464
|
function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
|
|
9129
9465
|
const message = document.createElement("div");
|
|
9130
|
-
|
|
9131
|
-
message.
|
|
9132
|
-
|
|
9466
|
+
const isFailure = role === "assistant" && text.startsWith("调用失败:");
|
|
9467
|
+
message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
|
|
9468
|
+
const messageBody = isFailure
|
|
9469
|
+
? `<p class="ai-error-text">${esc(text)}</p>`
|
|
9470
|
+
: renderMarkdown(text);
|
|
9471
|
+
message.innerHTML = `<div class="message-body">${messageBody}</div>`;
|
|
9472
|
+
const heading = attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
|
|
9473
|
+
if (isFailure) {
|
|
9474
|
+
message.dataset.status = "failed";
|
|
9475
|
+
const failureBadge = document.createElement("strong");
|
|
9476
|
+
failureBadge.className = "ai-message-status is-error";
|
|
9477
|
+
failureBadge.textContent = "失败";
|
|
9478
|
+
failureBadge.setAttribute("aria-label", "消息状态:失败");
|
|
9479
|
+
heading.firstElementChild?.append(failureBadge);
|
|
9480
|
+
}
|
|
9133
9481
|
if (citations.length) {
|
|
9134
9482
|
const references = document.createElement("div");
|
|
9135
9483
|
references.className = "message-citations";
|
|
@@ -10102,6 +10450,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
|
|
|
10102
10450
|
timeline: Number($("#page-size-timeline").value),
|
|
10103
10451
|
outlines: Number($("#page-size-outlines").value),
|
|
10104
10452
|
relationships: Number($("#page-size-relationships").value),
|
|
10453
|
+
comments: Number($("#page-size-comments").value),
|
|
10105
10454
|
reviews: Number($("#page-size-reviews").value),
|
|
10106
10455
|
analysisTasks: Number($("#page-size-analysis-tasks").value),
|
|
10107
10456
|
fileVersions: Number($("#page-size-file-versions").value)
|
|
@@ -10247,10 +10596,14 @@ $("#chapter-content").addEventListener("input", (event) => {
|
|
|
10247
10596
|
scheduleChapterAutoSave();
|
|
10248
10597
|
clearChapterLineSelection();
|
|
10249
10598
|
scheduleChapterLineNumbers();
|
|
10250
|
-
|
|
10599
|
+
setAiContextMeter(null);
|
|
10251
10600
|
});
|
|
10252
|
-
$("#chapter-content").addEventListener("select",
|
|
10601
|
+
$("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
|
|
10253
10602
|
$("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
|
|
10603
|
+
$("#chapter-content").addEventListener("contextmenu", (event) => {
|
|
10604
|
+
if (!state.chapter) return;
|
|
10605
|
+
showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
|
|
10606
|
+
});
|
|
10254
10607
|
$("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
|
|
10255
10608
|
const row = event.target.closest(".chapter-line-number");
|
|
10256
10609
|
if (!row || event.button !== 0) return;
|
|
@@ -10330,7 +10683,7 @@ $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!m
|
|
|
10330
10683
|
$("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
10331
10684
|
$("#ai-prompt").addEventListener("input", async () => {
|
|
10332
10685
|
updateAiMentionMenu();
|
|
10333
|
-
|
|
10686
|
+
setAiContextMeter(null);
|
|
10334
10687
|
if (!findAiMention(aiPromptTextBeforeCursor())) return;
|
|
10335
10688
|
try {
|
|
10336
10689
|
await ensureAiReferencesLoaded();
|
|
@@ -10345,9 +10698,9 @@ $("#ai-prompt").addEventListener("focus", () => {
|
|
|
10345
10698
|
$("#ai-model").addEventListener("focus", () => {
|
|
10346
10699
|
ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
|
|
10347
10700
|
});
|
|
10348
|
-
$("#ai-model").addEventListener("change",
|
|
10349
|
-
$("#ai-task").addEventListener("change",
|
|
10350
|
-
$("#ai-scope").addEventListener("change",
|
|
10701
|
+
$("#ai-model").addEventListener("change", () => setAiContextMeter(null));
|
|
10702
|
+
$("#ai-task").addEventListener("change", () => setAiContextMeter(null));
|
|
10703
|
+
$("#ai-scope").addEventListener("change", () => setAiContextMeter(null));
|
|
10351
10704
|
$("#ai-mention-menu").addEventListener("click", (event) => {
|
|
10352
10705
|
const button = event.target.closest("[data-ai-reference-id]");
|
|
10353
10706
|
if (button) selectAiMention(button);
|
|
@@ -10487,6 +10840,7 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
10487
10840
|
if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
|
|
10488
10841
|
if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
|
|
10489
10842
|
if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
|
|
10843
|
+
if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
|
|
10490
10844
|
if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
|
|
10491
10845
|
$("#account-menu").classList.add("hidden");
|
|
10492
10846
|
$("#account-button").setAttribute("aria-expanded", "false");
|
|
@@ -10508,6 +10862,7 @@ document.addEventListener("keydown", (event) => {
|
|
|
10508
10862
|
closeLineCitationMenu();
|
|
10509
10863
|
closeMarkdownTableMenu(true);
|
|
10510
10864
|
hideAiMentionMenu();
|
|
10865
|
+
setAiContextDistributionVisible(false);
|
|
10511
10866
|
}
|
|
10512
10867
|
});
|
|
10513
10868
|
document.addEventListener("keydown", (event) => {
|
|
@@ -10517,6 +10872,10 @@ document.addEventListener("keydown", (event) => {
|
|
|
10517
10872
|
if (event.repeat) return;
|
|
10518
10873
|
openSearchDialog().catch((error) => toast(error.message, "error"));
|
|
10519
10874
|
}, { capture: true });
|
|
10875
|
+
$("#ai-context-meter").addEventListener("click", () => {
|
|
10876
|
+
setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
|
|
10877
|
+
});
|
|
10878
|
+
$("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
|
|
10520
10879
|
$("#ai-send").addEventListener("click", sendAi);
|
|
10521
10880
|
$("#ai-new-conversation").addEventListener("click", async () => {
|
|
10522
10881
|
const button = $("#ai-new-conversation");
|
|
@@ -10544,7 +10903,15 @@ $("#ai-context-compact").addEventListener("click", async () => {
|
|
|
10544
10903
|
});
|
|
10545
10904
|
hideAiContextWarning();
|
|
10546
10905
|
toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
|
|
10547
|
-
|
|
10906
|
+
setAiContextMeter(result.usage);
|
|
10907
|
+
if (result.changed) {
|
|
10908
|
+
const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
|
|
10909
|
+
if (current) {
|
|
10910
|
+
current.compactedMessageCount = result.compactedMessageCount;
|
|
10911
|
+
current.hasCompactedSummary = true;
|
|
10912
|
+
}
|
|
10913
|
+
renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
|
|
10914
|
+
}
|
|
10548
10915
|
} catch (error) {
|
|
10549
10916
|
toast(`上下文压缩失败:${error.message}`, "error");
|
|
10550
10917
|
} finally {
|
|
@@ -10570,6 +10937,30 @@ $("#ai-history-toggle").addEventListener("click", async () => {
|
|
|
10570
10937
|
toast(`对话历史加载失败:${error.message}`, "error");
|
|
10571
10938
|
}
|
|
10572
10939
|
});
|
|
10940
|
+
$("#ai-history-previous").addEventListener("click", async () => {
|
|
10941
|
+
const targetPage = aiConversationHistoryPage.page - 1;
|
|
10942
|
+
if (targetPage < 1) return;
|
|
10943
|
+
$("#ai-history-previous").disabled = true;
|
|
10944
|
+
$("#ai-history-next").disabled = true;
|
|
10945
|
+
try {
|
|
10946
|
+
await loadAiConversations(targetPage);
|
|
10947
|
+
} catch (error) {
|
|
10948
|
+
renderAiConversationHistory();
|
|
10949
|
+
toast(`对话历史加载失败:${error.message}`, "error");
|
|
10950
|
+
}
|
|
10951
|
+
});
|
|
10952
|
+
$("#ai-history-next").addEventListener("click", async () => {
|
|
10953
|
+
const targetPage = aiConversationHistoryPage.nextPage;
|
|
10954
|
+
if (!targetPage) return;
|
|
10955
|
+
$("#ai-history-previous").disabled = true;
|
|
10956
|
+
$("#ai-history-next").disabled = true;
|
|
10957
|
+
try {
|
|
10958
|
+
await loadAiConversations(targetPage);
|
|
10959
|
+
} catch (error) {
|
|
10960
|
+
renderAiConversationHistory();
|
|
10961
|
+
toast(`对话历史加载失败:${error.message}`, "error");
|
|
10962
|
+
}
|
|
10963
|
+
});
|
|
10573
10964
|
$("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
|
|
10574
10965
|
$("#ai-history-dialog").addEventListener("close", () => {
|
|
10575
10966
|
$("#ai-history-toggle").setAttribute("aria-expanded", "false");
|