tavern-harness 0.1.0
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/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
|
@@ -0,0 +1,2419 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { db } from '../db/database';
|
|
3
|
+
import {
|
|
4
|
+
type ApiProvider,
|
|
5
|
+
type AppSettings,
|
|
6
|
+
type ProviderModelOption,
|
|
7
|
+
enabledModelIds,
|
|
8
|
+
modelAliases,
|
|
9
|
+
ChatMessage,
|
|
10
|
+
ChatParticipant,
|
|
11
|
+
ChatSession,
|
|
12
|
+
ContextCompression,
|
|
13
|
+
ToolConfirmationRequest,
|
|
14
|
+
WorldBook,
|
|
15
|
+
NpcCharacter,
|
|
16
|
+
McpTool,
|
|
17
|
+
} from '../types/models';
|
|
18
|
+
import { streamChatCompletions, buildChatRequest, summarizeContext } from '../core/openai';
|
|
19
|
+
import {
|
|
20
|
+
buildGroupSystemPrompt,
|
|
21
|
+
buildNetworkMessagesForSession,
|
|
22
|
+
buildNpcSystemPrompt,
|
|
23
|
+
STANDARD_SYSTEM_PROMPT,
|
|
24
|
+
} from '../core/prompts';
|
|
25
|
+
import {
|
|
26
|
+
completeTurn,
|
|
27
|
+
initializeTurnQueue,
|
|
28
|
+
mentionedParticipantIds,
|
|
29
|
+
parseMagicCommand,
|
|
30
|
+
passPlayer,
|
|
31
|
+
queueHistoryJson,
|
|
32
|
+
queueJson,
|
|
33
|
+
refreshQueue,
|
|
34
|
+
requeueSpeaker,
|
|
35
|
+
safeParseQueue,
|
|
36
|
+
safeParseQueueHistory,
|
|
37
|
+
} from '../core/turnLoop';
|
|
38
|
+
import type { ChatCompletionRequest, SessionMode, TurnOrderMode } from '../types/models';
|
|
39
|
+
import {
|
|
40
|
+
MAX_CONSECUTIVE_TOOL_FAILURES,
|
|
41
|
+
MAX_IDENTICAL_TOOL_CALLS,
|
|
42
|
+
MAX_TOOL_CALL_DEPTH,
|
|
43
|
+
MAX_TOOL_CALLS_PER_TURN,
|
|
44
|
+
NEW_TOPIC_MARKER,
|
|
45
|
+
} from '../core/toolDefinitions';
|
|
46
|
+
import { getEnabledToolsForSession, executeToolCall, parseDisplayRef, applySessionWorkspace } from '../core/tools/toolExecutor';
|
|
47
|
+
import { createSessionWorkspaceDir, ensureSessionWorkspaceDir, deleteSessionWorkspace, isCurrentInstanceWorkspaceDir } from '../core/tools/generatedSkillExecutor';
|
|
48
|
+
import { applyTheme as applyThemeManual, cacheThemeMode, watchSystemTheme } from '../theme/theme';
|
|
49
|
+
import { setLanguage, translate } from '../core/i18n';
|
|
50
|
+
import { trimEdgeNewlines } from '../core/strings';
|
|
51
|
+
import {
|
|
52
|
+
isBuiltinTavernHandbook,
|
|
53
|
+
isTavernKeeper,
|
|
54
|
+
localizeBuiltinNpc,
|
|
55
|
+
localizeBuiltinTavernHandbook,
|
|
56
|
+
} from '../db/database';
|
|
57
|
+
import { estimateTokensFromChars, accumulateStats, sessionPreviewText } from '../core/stats';
|
|
58
|
+
import { seedOpeningGreeting } from '../core/openingGreeting';
|
|
59
|
+
import { ACHIEVEMENTS, type AchievementDef } from '../core/achievements';
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// Store(对应 MainViewModel)
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
export type ActiveView = 'chat' | 'characters' | 'settings' | 'stats' | 'achievements';
|
|
66
|
+
|
|
67
|
+
interface StreamingState {
|
|
68
|
+
sessionId: number | null;
|
|
69
|
+
abort: AbortController | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Toast {
|
|
73
|
+
id: number;
|
|
74
|
+
message: string;
|
|
75
|
+
kind: 'info' | 'error';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AchievementState {
|
|
79
|
+
def: AchievementDef;
|
|
80
|
+
unlockedAt: number | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 展示类工具(file_display)当前打开的弹窗载荷 */
|
|
84
|
+
export interface ActiveDisplay {
|
|
85
|
+
path: string;
|
|
86
|
+
kind: 'text' | 'image' | 'html';
|
|
87
|
+
title?: string;
|
|
88
|
+
/** 产生该展示的会话 id(读取文件时按其专属工作目录解析,null = 共享工作区) */
|
|
89
|
+
sessionId: number | null;
|
|
90
|
+
/** 初始展示方式;文件管理器使用大弹窗,file_display 默认使用小窗 */
|
|
91
|
+
presentation?: 'pip' | 'modal';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface AppState {
|
|
95
|
+
initialized: boolean;
|
|
96
|
+
settings: AppSettings | null;
|
|
97
|
+
providers: ApiProvider[];
|
|
98
|
+
npcs: NpcCharacter[];
|
|
99
|
+
sessions: ChatSession[];
|
|
100
|
+
worldBooks: WorldBook[];
|
|
101
|
+
tools: McpTool[];
|
|
102
|
+
messages: Record<number, ChatMessage[]>;
|
|
103
|
+
contextCompressions: Record<number, ContextCompression[]>;
|
|
104
|
+
participants: Record<number, ChatParticipant[]>;
|
|
105
|
+
achievements: AchievementState[];
|
|
106
|
+
/** 生涯总 token(输入 + 输出),实时刷新用于成就进度展示 */
|
|
107
|
+
careerTotalTokens: number | null;
|
|
108
|
+
|
|
109
|
+
activeSessionId: number | null;
|
|
110
|
+
activeView: ActiveView;
|
|
111
|
+
|
|
112
|
+
streaming: StreamingState;
|
|
113
|
+
/** 正在生成或生成结束后尚未进入查看的会话 */
|
|
114
|
+
activitySessionIds: Record<number, true>;
|
|
115
|
+
pendingConfirmation: ToolConfirmationRequest | null;
|
|
116
|
+
|
|
117
|
+
/** 当前打开的展示弹窗(file_display),null = 无 */
|
|
118
|
+
activeDisplay: ActiveDisplay | null;
|
|
119
|
+
/** 打开 / 关闭展示弹窗 */
|
|
120
|
+
setActiveDisplay: (d: ActiveDisplay | null) => void;
|
|
121
|
+
|
|
122
|
+
/** 群聊实时队列快照(与 DB 同步更新,面板订阅此值实现实时指示) */
|
|
123
|
+
liveQueueBySession: Record<number, {
|
|
124
|
+
/** 当前循环剩余队列(队首 = 正在发言/下一发言者) */
|
|
125
|
+
queue: string[];
|
|
126
|
+
/** 各循环完整初始顺序历史(含当前循环) */
|
|
127
|
+
history: string[][];
|
|
128
|
+
loopIndex: number;
|
|
129
|
+
turnOrderMode: TurnOrderMode;
|
|
130
|
+
}>;
|
|
131
|
+
|
|
132
|
+
toasts: Toast[];
|
|
133
|
+
|
|
134
|
+
init: () => Promise<void>;
|
|
135
|
+
setSettings: (partial: Partial<AppSettings>) => Promise<void>;
|
|
136
|
+
/** 删除当前浏览器源上的酒馆数据并重新载入初始状态。 */
|
|
137
|
+
resetTavern: () => Promise<void>;
|
|
138
|
+
setActiveSession: (id: number | null) => void;
|
|
139
|
+
setActiveView: (v: ActiveView) => void;
|
|
140
|
+
|
|
141
|
+
sendMessage: (text: string, attachments?: string[], attachmentNames?: string[]) => Promise<void>;
|
|
142
|
+
/** 重新生成指定消息(右键菜单:右键哪条就重生成哪条)。删除该消息及之后的 tool 消息后重跑 */
|
|
143
|
+
regenerateMessage: (messageId: number) => Promise<void>;
|
|
144
|
+
editMessage: (messageId: number, newContent: string, sessionId: number, newAttachments?: string[], newAttachmentNames?: string[]) => Promise<void>;
|
|
145
|
+
saveMessageOnly: (messageId: number, newContent: string, sessionId: number, newAttachments?: string[], newAttachmentNames?: string[]) => Promise<void>;
|
|
146
|
+
stopStreaming: () => void;
|
|
147
|
+
deleteSession: (id: number) => Promise<void>;
|
|
148
|
+
/** 切换会话置顶状态(置顶会话排列表最前,可跨会话固定) */
|
|
149
|
+
togglePin: (id: number) => Promise<void>;
|
|
150
|
+
/** 从指定消息分叉(Fork):复制该消息及之前的内容到新会话并跳转过去。返回新会话 id */
|
|
151
|
+
forkSession: (messageId: number) => Promise<number | null>;
|
|
152
|
+
updateSessionSettings: (sessionId: number, opts: {
|
|
153
|
+
title?: string;
|
|
154
|
+
npcIds: number[];
|
|
155
|
+
worldBookId?: number | null;
|
|
156
|
+
userPersonaNpcId?: number | null;
|
|
157
|
+
turnOrderMode?: TurnOrderMode;
|
|
158
|
+
participantOrder?: number[];
|
|
159
|
+
enableGreeting?: boolean;
|
|
160
|
+
mentionOnlyMode?: boolean;
|
|
161
|
+
}) => Promise<void>;
|
|
162
|
+
resetSessionConversation: (sessionId: number) => Promise<void>;
|
|
163
|
+
resolveConfirmation: (approved: boolean) => void;
|
|
164
|
+
/** 用 DB 最新数据刷新某会话的队列快照(面板实时源) */
|
|
165
|
+
refreshLiveQueue: (sessionId: number) => Promise<void>;
|
|
166
|
+
/** 清除某会话的队列快照(会话删除时) */
|
|
167
|
+
clearLiveQueue: (sessionId: number) => void;
|
|
168
|
+
|
|
169
|
+
refreshSessions: () => Promise<void>;
|
|
170
|
+
refreshNpcs: () => Promise<void>;
|
|
171
|
+
refreshWorldBooks: () => Promise<void>;
|
|
172
|
+
refreshTools: () => Promise<void>;
|
|
173
|
+
refreshProviders: () => Promise<void>;
|
|
174
|
+
refreshAchievements: () => Promise<void>;
|
|
175
|
+
loadMessages: (sessionId: number) => Promise<void>;
|
|
176
|
+
/** 重新加载当前活动会话的参与者(语言切换后刷新内置角色显示名) */
|
|
177
|
+
refreshParticipants: () => Promise<void>;
|
|
178
|
+
|
|
179
|
+
/** 聚合所有启用 Provider 的模型列表,每个 Provider 的条目独立保留。 */
|
|
180
|
+
modelsList: ProviderModelOption[];
|
|
181
|
+
refreshModelsList: () => Promise<void>;
|
|
182
|
+
selectModel: (option: ProviderModelOption) => Promise<void>;
|
|
183
|
+
|
|
184
|
+
addToast: (message: string, kind?: 'info' | 'error') => void;
|
|
185
|
+
removeToast: (id: number) => void;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let toastSeq = 0;
|
|
189
|
+
let initLock: Promise<void> | null = null;
|
|
190
|
+
/** 用户主动停止生成时置位,用于中断群聊循环 */
|
|
191
|
+
let groupLoopStopped = false;
|
|
192
|
+
const contextCompressionJobs = new Map<number, Promise<void>>();
|
|
193
|
+
const contextCompressionControllers = new Map<number, AbortController>();
|
|
194
|
+
const contextCompressionRevisions = new Map<number, number>();
|
|
195
|
+
const queuedContextCompressionSessions = new Set<number>();
|
|
196
|
+
|
|
197
|
+
function invalidateContextCompressionJob(sessionId: number): void {
|
|
198
|
+
contextCompressionRevisions.set(sessionId, (contextCompressionRevisions.get(sessionId) ?? 0) + 1);
|
|
199
|
+
contextCompressionControllers.get(sessionId)?.abort();
|
|
200
|
+
queuedContextCompressionSessions.delete(sessionId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function waitForContextCompressionIdle(sessionId: number): Promise<void> {
|
|
204
|
+
while (true) {
|
|
205
|
+
const job = contextCompressionJobs.get(sessionId);
|
|
206
|
+
if (!job) return;
|
|
207
|
+
await job;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function deleteContextCompressionsFrom(sessionId: number, timestamp: number): Promise<void> {
|
|
212
|
+
invalidateContextCompressionJob(sessionId);
|
|
213
|
+
const affected = await db.contextCompressions
|
|
214
|
+
.where('sessionId').equals(sessionId)
|
|
215
|
+
.filter((compression) => compression.endTimestamp >= timestamp)
|
|
216
|
+
.toArray();
|
|
217
|
+
if (affected.length > 0) {
|
|
218
|
+
await db.contextCompressions.bulkDelete(affected.map((compression) => compression.id!));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 会话排序:置顶在前、其余在后,组内按 updatedAt 倒序(最新在前) */
|
|
223
|
+
function sortSessionsPinnedFirst(sessions: ChatSession[]): ChatSession[] {
|
|
224
|
+
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
225
|
+
const pinned = sorted.filter((s) => (s as { pinned?: unknown }).pinned);
|
|
226
|
+
const rest = sorted.filter((s) => !(s as { pinned?: unknown }).pinned);
|
|
227
|
+
return [...pinned, ...rest];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sortWorldBooks(worldBooks: WorldBook[]): WorldBook[] {
|
|
231
|
+
return [...worldBooks].sort((a, b) => Number(Boolean(b.isBuiltIn)) - Number(Boolean(a.isBuiltIn)) || a.createdAt - b.createdAt);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sortNpcs(npcs: NpcCharacter[]): NpcCharacter[] {
|
|
235
|
+
return [...npcs].sort((a, b) => Number(b.isBuiltIn) - Number(a.isBuiltIn) || a.createdAt - b.createdAt);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 单回合生成结果:ok 成功 / failed 失败(应回退) / stopped 用户主动停止(不回退) */
|
|
239
|
+
type TurnResult = 'ok' | 'failed' | 'stopped';
|
|
240
|
+
|
|
241
|
+
export const useStore = create<AppState>((set, get) => ({
|
|
242
|
+
initialized: false,
|
|
243
|
+
settings: null,
|
|
244
|
+
providers: [],
|
|
245
|
+
npcs: [],
|
|
246
|
+
sessions: [],
|
|
247
|
+
worldBooks: [],
|
|
248
|
+
tools: [],
|
|
249
|
+
messages: {},
|
|
250
|
+
contextCompressions: {},
|
|
251
|
+
participants: {},
|
|
252
|
+
achievements: [],
|
|
253
|
+
careerTotalTokens: null,
|
|
254
|
+
|
|
255
|
+
activeSessionId: null,
|
|
256
|
+
activeView: 'chat',
|
|
257
|
+
modelsList: [],
|
|
258
|
+
|
|
259
|
+
streaming: { sessionId: null, abort: null },
|
|
260
|
+
activitySessionIds: {},
|
|
261
|
+
pendingConfirmation: null,
|
|
262
|
+
activeDisplay: null,
|
|
263
|
+
|
|
264
|
+
liveQueueBySession: {},
|
|
265
|
+
|
|
266
|
+
toasts: [],
|
|
267
|
+
|
|
268
|
+
init: async () => {
|
|
269
|
+
// StrictMode 双调用保护
|
|
270
|
+
if (initLock) return initLock;
|
|
271
|
+
initLock = (async () => {
|
|
272
|
+
const settings = (await db.settings.get(1))!;
|
|
273
|
+
setLanguage(settings.language ?? null);
|
|
274
|
+
// 内置角色与世界书按当前语言本地化。
|
|
275
|
+
await localizeBuiltinNpc();
|
|
276
|
+
await localizeBuiltinTavernHandbook();
|
|
277
|
+
const npcs = sortNpcs(await db.npcs.toArray());
|
|
278
|
+
const sessions = sortSessionsPinnedFirst(await db.sessions.toArray());
|
|
279
|
+
const worldBooks = sortWorldBooks(await db.worldBooks.toArray());
|
|
280
|
+
const tools = [...(await db.tools.toArray())].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
|
281
|
+
set({ initialized: true, settings, npcs, sessions, worldBooks, tools });
|
|
282
|
+
await get().refreshProviders();
|
|
283
|
+
cacheThemeMode(settings.themeMode);
|
|
284
|
+
applyThemeManual(settings.themeMode);
|
|
285
|
+
// 跟随系统模式下,系统切换深浅色时自动重新应用主题
|
|
286
|
+
watchSystemTheme(() => {
|
|
287
|
+
const s = useStore.getState().settings;
|
|
288
|
+
if (s) applyThemeManual(s.themeMode);
|
|
289
|
+
});
|
|
290
|
+
await get().refreshAchievements();
|
|
291
|
+
// 每次刷新固定回到开始页,会话仅在用户主动选择后加载。
|
|
292
|
+
set({ activeSessionId: null, activeView: 'chat' });
|
|
293
|
+
})();
|
|
294
|
+
return initLock;
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
setSettings: async (partial) => {
|
|
298
|
+
const current = get().settings;
|
|
299
|
+
if (!current) return;
|
|
300
|
+
const next = { ...current, ...partial };
|
|
301
|
+
await db.settings.put(next);
|
|
302
|
+
set({ settings: next });
|
|
303
|
+
if (partial.language !== undefined) {
|
|
304
|
+
setLanguage(next.language ?? null);
|
|
305
|
+
// 切换语言后:内置角色与世界书文本一并本地化并刷新。
|
|
306
|
+
await localizeBuiltinNpc();
|
|
307
|
+
await localizeBuiltinTavernHandbook();
|
|
308
|
+
await get().refreshNpcs();
|
|
309
|
+
await get().refreshWorldBooks();
|
|
310
|
+
await get().refreshSessions();
|
|
311
|
+
await get().refreshParticipants();
|
|
312
|
+
}
|
|
313
|
+
cacheThemeMode(next.themeMode);
|
|
314
|
+
applyThemeManual(next.themeMode);
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
resetTavern: async () => {
|
|
318
|
+
const streaming = get().streaming;
|
|
319
|
+
if (streaming.abort) {
|
|
320
|
+
groupLoopStopped = true;
|
|
321
|
+
streaming.abort.abort();
|
|
322
|
+
}
|
|
323
|
+
for (const session of await db.sessions.toArray()) {
|
|
324
|
+
invalidateContextCompressionJob(session.id!);
|
|
325
|
+
if (isCurrentInstanceWorkspaceDir(session.workspaceDir)) await deleteSessionWorkspace(session.workspaceDir);
|
|
326
|
+
}
|
|
327
|
+
confirmationDeferreds.forEach(({ resolve }) => resolve(false));
|
|
328
|
+
confirmationDeferreds.clear();
|
|
329
|
+
await db.delete();
|
|
330
|
+
localStorage.removeItem('tav-theme-mode');
|
|
331
|
+
localStorage.removeItem('th-builtin-npc-lang');
|
|
332
|
+
localStorage.removeItem('th-workspace-scope');
|
|
333
|
+
window.location.reload();
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
setActiveSession: (id) => {
|
|
337
|
+
set((state) => {
|
|
338
|
+
if (id == null || !state.activitySessionIds[id]) {
|
|
339
|
+
return { activeSessionId: id, activeView: 'chat' };
|
|
340
|
+
}
|
|
341
|
+
const activitySessionIds = { ...state.activitySessionIds };
|
|
342
|
+
delete activitySessionIds[id];
|
|
343
|
+
return { activeSessionId: id, activeView: 'chat', activitySessionIds };
|
|
344
|
+
});
|
|
345
|
+
if (id != null) {
|
|
346
|
+
get().loadMessages(id);
|
|
347
|
+
get().refreshLiveQueue(id);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
setActiveView: (v) => set((state) => {
|
|
352
|
+
const sessionId = state.activeSessionId;
|
|
353
|
+
if (v !== 'chat' || sessionId == null || !state.activitySessionIds[sessionId]) {
|
|
354
|
+
return { activeView: v };
|
|
355
|
+
}
|
|
356
|
+
const activitySessionIds = { ...state.activitySessionIds };
|
|
357
|
+
delete activitySessionIds[sessionId];
|
|
358
|
+
return { activeView: v, activitySessionIds };
|
|
359
|
+
}),
|
|
360
|
+
|
|
361
|
+
refreshSessions: async () => {
|
|
362
|
+
// 置顶会话排最前(各自按 updatedAt 倒序),其次为普通会话(同样按 updatedAt 倒序)
|
|
363
|
+
set({ sessions: sortSessionsPinnedFirst(await db.sessions.toArray()) });
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
refreshNpcs: async () => {
|
|
367
|
+
set({ npcs: sortNpcs(await db.npcs.toArray()) });
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
refreshWorldBooks: async () => {
|
|
371
|
+
set({ worldBooks: sortWorldBooks(await db.worldBooks.toArray()) });
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
refreshTools: async () => {
|
|
375
|
+
// 按 displayOrder 排序:技能表拖拽调整的顺序跨刷新保持
|
|
376
|
+
const tools = [...(await db.tools.toArray())].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
|
377
|
+
set({ tools });
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
refreshAchievements: async () => {
|
|
381
|
+
const unlocked = await db.achievementUnlocks.toArray();
|
|
382
|
+
const byId = new Map(unlocked.map((u) => [u.achievementId, u.unlockedAt]));
|
|
383
|
+
set({
|
|
384
|
+
achievements: ACHIEVEMENTS.map((def) => ({
|
|
385
|
+
def,
|
|
386
|
+
unlockedAt: byId.get(def.id) ?? null,
|
|
387
|
+
})),
|
|
388
|
+
});
|
|
389
|
+
const stats = (await db.careerStats.get(1)) ?? { id: 1, inputTokens: 0, outputTokens: 0, totalRounds: 0 };
|
|
390
|
+
set({ careerTotalTokens: stats.inputTokens + stats.outputTokens });
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
refreshProviders: async () => {
|
|
394
|
+
const providers = await db.providers.toArray();
|
|
395
|
+
set({ providers });
|
|
396
|
+
// 同步聚合模型列表
|
|
397
|
+
const models = aggregateEnabledProviderModels(providers);
|
|
398
|
+
set({ modelsList: models });
|
|
399
|
+
// 若当前默认模型不在列表、但列表非空,自动取第一个
|
|
400
|
+
const current = get().settings;
|
|
401
|
+
if (current) {
|
|
402
|
+
const enabledProviders = providers.filter((p) => p.isEnabled && p.baseUrl.trim());
|
|
403
|
+
const next: Partial<AppSettings> = {};
|
|
404
|
+
// 唯一启用 Provider 时自动设为默认
|
|
405
|
+
if (enabledProviders.length === 1 && current.defaultProviderId !== enabledProviders[0].id) {
|
|
406
|
+
next.defaultProviderId = enabledProviders[0].id ?? null;
|
|
407
|
+
}
|
|
408
|
+
const selected = current.defaultModel.trim();
|
|
409
|
+
const selectedProviderId = current.defaultProviderId;
|
|
410
|
+
if (!models.some((option) => option.providerId === selectedProviderId && option.modelId === selected) && models.length > 0) {
|
|
411
|
+
next.defaultModel = models[0].modelId;
|
|
412
|
+
next.defaultProviderId = models[0].providerId;
|
|
413
|
+
}
|
|
414
|
+
if (Object.keys(next).length > 0) {
|
|
415
|
+
const updated = { ...current, ...next };
|
|
416
|
+
await db.settings.put(updated);
|
|
417
|
+
set({ settings: updated });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
refreshModelsList: async () => {
|
|
423
|
+
const providers = await db.providers.toArray();
|
|
424
|
+
set({ modelsList: aggregateEnabledProviderModels(providers) });
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
selectModel: async (option) => {
|
|
428
|
+
const current = get().settings;
|
|
429
|
+
if (!current) return;
|
|
430
|
+
const provider = await db.providers.get(option.providerId);
|
|
431
|
+
if (!provider || !isEnabledProviderModel(provider, option.modelId)) return;
|
|
432
|
+
const next = { ...current, defaultModel: option.modelId, defaultProviderId: option.providerId };
|
|
433
|
+
await db.settings.put(next);
|
|
434
|
+
set({ settings: next });
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
loadMessages: async (sessionId) => {
|
|
438
|
+
const messages = await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp');
|
|
439
|
+
const compressions = await db.contextCompressions.where('sessionId').equals(sessionId).sortBy('createdAt');
|
|
440
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
441
|
+
set((s) => {
|
|
442
|
+
// 流式过程中 publishDraft 已把最新内容写入内存与 DB;重新加载时以 DB 记录为基准、
|
|
443
|
+
// 叠加内存中的流式字段(content / thinkingContent / toolCallsJson),避免文本回跳。
|
|
444
|
+
// 这样 DB 里已落库的 raw 字段(生成完成后才写入)不会被内存中的旧草稿对象覆盖,
|
|
445
|
+
// —— 否则右键「查看原始日志」要等刷新页面(streaming 清空后从 DB 重载)才会出现。
|
|
446
|
+
const loadedMessages = s.streaming.sessionId === sessionId
|
|
447
|
+
? messages.map((message) => {
|
|
448
|
+
const current = s.messages[sessionId]?.find((c) => c.id === message.id);
|
|
449
|
+
if (!current) return message;
|
|
450
|
+
return {
|
|
451
|
+
...message,
|
|
452
|
+
content: current.content,
|
|
453
|
+
thinkingContent: current.thinkingContent,
|
|
454
|
+
toolCallsJson: current.toolCallsJson,
|
|
455
|
+
};
|
|
456
|
+
})
|
|
457
|
+
: messages;
|
|
458
|
+
return {
|
|
459
|
+
messages: { ...s.messages, [sessionId]: loadedMessages },
|
|
460
|
+
contextCompressions: { ...s.contextCompressions, [sessionId]: compressions },
|
|
461
|
+
participants: { ...s.participants, [sessionId]: participants },
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
/** 重新加载当前活动会话的参与者列表(语言切换本地化内置角色后调用) */
|
|
467
|
+
refreshParticipants: async () => {
|
|
468
|
+
const sessionId = get().activeSessionId;
|
|
469
|
+
if (sessionId == null) return;
|
|
470
|
+
await get().loadMessages(sessionId);
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
sendMessage: async (text, attachments = [], attachmentNames = []) => {
|
|
474
|
+
const sessionId = get().activeSessionId;
|
|
475
|
+
if (sessionId == null) return;
|
|
476
|
+
const session = await db.sessions.get(sessionId);
|
|
477
|
+
if (!session) return;
|
|
478
|
+
if (get().streaming.sessionId != null) {
|
|
479
|
+
get().addToast(translate('toast.busy'), 'error');
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (session.mode === 'GROUP' && session.mentionOnlyMode) {
|
|
483
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
484
|
+
if (mentionedParticipantIds(text, participants, -1).length === 0) {
|
|
485
|
+
get().addToast(translate('toast.mentionRequired'), 'error');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 魔法命令(有附件时不生效)
|
|
491
|
+
const cmd = parseMagicCommand(text, attachments.length > 0);
|
|
492
|
+
if (cmd) {
|
|
493
|
+
await handleMagicCommand(session, cmd.text);
|
|
494
|
+
await get().refreshSessions();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 保存用户消息(若在群聊中轮到玩家,同时将其移出队列)
|
|
499
|
+
const attachInfos = attachments.map((_, i) => ({
|
|
500
|
+
displayName: attachmentNames[i]?.trim() || translate('common.attachment'),
|
|
501
|
+
}));
|
|
502
|
+
const userMsg: ChatMessage = {
|
|
503
|
+
sessionId,
|
|
504
|
+
role: 'user',
|
|
505
|
+
speakerParticipantId: null,
|
|
506
|
+
speakerName: null,
|
|
507
|
+
content: text,
|
|
508
|
+
toolCallsJson: '[]',
|
|
509
|
+
toolCallId: null,
|
|
510
|
+
thinkingContent: null,
|
|
511
|
+
// 群聊:用户发言归属于当前循环(用于对话↔队列联动滚动定位)
|
|
512
|
+
loopIndex: session.mode === 'GROUP' ? currentLoopIndexOf(session) : null,
|
|
513
|
+
timestamp: Date.now(),
|
|
514
|
+
latencyMs: null,
|
|
515
|
+
promptTokens: 0,
|
|
516
|
+
completionTokens: 0,
|
|
517
|
+
totalTokens: 0,
|
|
518
|
+
tokensPerSec: null,
|
|
519
|
+
modelUsed: null,
|
|
520
|
+
attachments,
|
|
521
|
+
attachmentInfos: attachInfos,
|
|
522
|
+
displayRef: null,
|
|
523
|
+
rawRequestBody: null,
|
|
524
|
+
rawResponseBody: null,
|
|
525
|
+
};
|
|
526
|
+
await db.messages.add(userMsg);
|
|
527
|
+
await db.sessions.update(sessionId, { updatedAt: Date.now(), lastMessage: sessionPreviewText(text) });
|
|
528
|
+
await get().loadMessages(sessionId);
|
|
529
|
+
await get().refreshSessions();
|
|
530
|
+
|
|
531
|
+
if (session.mode === 'GROUP') {
|
|
532
|
+
// 群聊:玩家始终是队列正式成员(默认队首)。轮到玩家时输入 → 将玩家移出队列,
|
|
533
|
+
// 其余成员继续发言;@点名也能让目标优先/立即发言。
|
|
534
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
535
|
+
const fresh = (await db.sessions.get(sessionId)) ?? session;
|
|
536
|
+
const { queue, loopIndex, loopStarted } = refreshQueue(fresh, participants);
|
|
537
|
+
const player = participants.find((p) => p.kind === 'PLAYER') ?? { participantId: -1 } as ChatParticipant;
|
|
538
|
+
const playerId = player.participantId.toString();
|
|
539
|
+
const isPlayerQueued = queue.includes(playerId);
|
|
540
|
+
const next = isPlayerQueued
|
|
541
|
+
? completeTurn(queue, player.participantId, mentionedParticipantIds(text, participants, player.participantId))
|
|
542
|
+
: queue;
|
|
543
|
+
// 边界时 queue 为全新完整顺序 → persistQueue 会把它追加进循环历史
|
|
544
|
+
await persistQueue(sessionId, next, loopIndex, loopStarted);
|
|
545
|
+
await get().refreshLiveQueue(sessionId);
|
|
546
|
+
await continueGroupConversation(sessionId);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
await runConversationLoop(session);
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
regenerateMessage: async (messageId) => {
|
|
554
|
+
const message = await db.messages.get(messageId);
|
|
555
|
+
if (!message) return;
|
|
556
|
+
const sessionId = message.sessionId;
|
|
557
|
+
const session = await db.sessions.get(sessionId);
|
|
558
|
+
if (!session) return;
|
|
559
|
+
if (get().streaming.sessionId != null) return;
|
|
560
|
+
|
|
561
|
+
// ---- 生成前快照:完整保留删除前的消息与队列状态,生成失败时整体回退 ----
|
|
562
|
+
const snapshotMessages = await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp');
|
|
563
|
+
const snapshotCompressions = await db.contextCompressions.where('sessionId').equals(sessionId).toArray();
|
|
564
|
+
const snapshotSessionFields = {
|
|
565
|
+
turnQueueJson: session.turnQueueJson,
|
|
566
|
+
turnQueueHistoryJson: session.turnQueueHistoryJson,
|
|
567
|
+
loopIndex: session.loopIndex,
|
|
568
|
+
lastMessage: session.lastMessage,
|
|
569
|
+
updatedAt: session.updatedAt,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// 以目标消息的时间戳为切断点:删除它及之后的所有消息(通常为其附属 tool 结果),
|
|
573
|
+
// 随后重新生成该条回复(重新生成 = 旧回复连同后续内容一起被新回复替换)
|
|
574
|
+
const messages = snapshotMessages;
|
|
575
|
+
const toDelete = messages.filter((m) => m.timestamp >= message.timestamp);
|
|
576
|
+
await deleteContextCompressionsFrom(sessionId, message.timestamp);
|
|
577
|
+
await db.messages.bulkDelete(toDelete.map((m) => m.id!));
|
|
578
|
+
await get().loadMessages(sessionId);
|
|
579
|
+
|
|
580
|
+
// 会话预览回退到删除后最后一条可见消息;空则置空
|
|
581
|
+
const remaining = await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp');
|
|
582
|
+
const lastVisible = [...remaining].reverse().find((m) => m.role !== 'tool');
|
|
583
|
+
await db.sessions.update(sessionId, {
|
|
584
|
+
updatedAt: Date.now(),
|
|
585
|
+
lastMessage: lastVisible ? sessionPreviewText(lastVisible.content) || '…' : '',
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
let result: TurnResult;
|
|
589
|
+
if (session.mode === 'GROUP') {
|
|
590
|
+
// 群聊:把被重生成 assistant 的发言人重新置顶(联动发言队列)。
|
|
591
|
+
// 若该条发言是当前循环某位成员(未知 speakerParticipantId,如收尾回复),
|
|
592
|
+
// 无法映射到具体成员,则直接按当前队列继续。
|
|
593
|
+
const participant = message.speakerParticipantId != null
|
|
594
|
+
? (await db.participants
|
|
595
|
+
.where('sessionId').equals(sessionId)
|
|
596
|
+
.filter((p) => p.participantId === message.speakerParticipantId)
|
|
597
|
+
.first()) ?? null
|
|
598
|
+
: null;
|
|
599
|
+
result = await continueRegeneratedGroupLoop(session, message, participant);
|
|
600
|
+
} else {
|
|
601
|
+
result = await runConversationLoop(session);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (result === 'failed') {
|
|
605
|
+
// 生成失败(网络 / 超时 / 空回复等)→ 回退到原先的状态:
|
|
606
|
+
// 恢复全部消息(含被删除的旧回复及其工具结果)与会话队列 / 预览。
|
|
607
|
+
await db.transaction('rw', db.messages, db.contextCompressions, async () => {
|
|
608
|
+
await db.messages.where('sessionId').equals(sessionId).delete();
|
|
609
|
+
await db.messages.bulkPut(snapshotMessages);
|
|
610
|
+
await db.contextCompressions.where('sessionId').equals(sessionId).delete();
|
|
611
|
+
if (snapshotCompressions.length > 0) await db.contextCompressions.bulkPut(snapshotCompressions);
|
|
612
|
+
});
|
|
613
|
+
await db.sessions.update(sessionId, snapshotSessionFields);
|
|
614
|
+
await get().loadMessages(sessionId);
|
|
615
|
+
}
|
|
616
|
+
await get().refreshSessions();
|
|
617
|
+
await get().refreshLiveQueue(sessionId);
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
editMessage: async (messageId, newContent, sessionId, newAttachments?: string[], newAttachmentNames?: string[]) => {
|
|
621
|
+
if (get().streaming.sessionId != null) return;
|
|
622
|
+
const message = await db.messages.get(messageId);
|
|
623
|
+
if (!message) return;
|
|
624
|
+
// 编辑该消息,删除它之后的所有消息(不含自身)
|
|
625
|
+
const messages = await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp');
|
|
626
|
+
const toDelete = messages.filter(
|
|
627
|
+
(m) => m.timestamp > message.timestamp || (m.timestamp === message.timestamp && m.id !== messageId)
|
|
628
|
+
);
|
|
629
|
+
await deleteContextCompressionsFrom(sessionId, message.timestamp);
|
|
630
|
+
await db.messages.bulkDelete(toDelete.map((m) => m.id!));
|
|
631
|
+
const attachments = newAttachments ?? message.attachments;
|
|
632
|
+
const attachmentInfos = (newAttachments != null ? newAttachments : message.attachments).map((_, i) => ({
|
|
633
|
+
displayName: (newAttachmentNames?.[i]?.trim()) || message.attachmentInfos?.[i]?.displayName || translate('common.attachment'),
|
|
634
|
+
}));
|
|
635
|
+
await db.messages.update(messageId, { content: newContent, attachments, attachmentInfos });
|
|
636
|
+
await get().loadMessages(sessionId);
|
|
637
|
+
|
|
638
|
+
const session = await db.sessions.get(sessionId);
|
|
639
|
+
if (!session) return;
|
|
640
|
+
if (session.mode === 'GROUP' && message.role === 'assistant') {
|
|
641
|
+
// 群聊中编辑的是 NPC 的发言 → 重新生成该发言者的回复,
|
|
642
|
+
// 并把该发言人重新置顶队列(联动发言队列:它还没轮完一轮时优先补发)。
|
|
643
|
+
const participant =
|
|
644
|
+
message.speakerParticipantId != null
|
|
645
|
+
? (await db.participants
|
|
646
|
+
.where('sessionId')
|
|
647
|
+
.equals(sessionId)
|
|
648
|
+
.filter((p) => p.participantId === message.speakerParticipantId)
|
|
649
|
+
.first()) ?? null
|
|
650
|
+
: null;
|
|
651
|
+
await continueRegeneratedGroupLoop(session, message, participant);
|
|
652
|
+
await get().refreshSessions();
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (session.mode === 'GROUP' && message.role === 'user') {
|
|
656
|
+
// 群聊中编辑的是玩家消息 → 发言队列回退到该消息所属「循环与轮次」:
|
|
657
|
+
// 1) 用循环历史里该轮的完整初始顺序重建队列(移除玩家 + 该轮玩家之前已发言者,
|
|
658
|
+
// 后者仅在 RANDOM 模式玩家非队首时可能出现)
|
|
659
|
+
// 2) 循环历史与 loopIndex 截断回退到该轮
|
|
660
|
+
// 3) 应用编辑文本中的 @点名 → 进入群聊主循环重新生成
|
|
661
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
662
|
+
const fresh = (await db.sessions.get(sessionId)) ?? session;
|
|
663
|
+
const player = participants.find((p) => p.kind === 'PLAYER') ?? null;
|
|
664
|
+
const playerId = player?.participantId ?? -1;
|
|
665
|
+
const targetLoop = message.loopIndex ?? 0;
|
|
666
|
+
const history = safeParseQueueHistory(fresh.turnQueueHistoryJson || '[]');
|
|
667
|
+
const curQueue = safeParseQueue(fresh.turnQueueJson);
|
|
668
|
+
const loopOrder =
|
|
669
|
+
history[targetLoop] ??
|
|
670
|
+
(curQueue.length > 0 ? curQueue : initializeTurnQueue(participants, fresh.turnOrderMode));
|
|
671
|
+
// 该轮中玩家发言之前已发言的 NPC(其消息未被删除 → 不重讲)
|
|
672
|
+
const spokenBeforePlayer = new Set(
|
|
673
|
+
messages
|
|
674
|
+
.filter(
|
|
675
|
+
(m) => m.role === 'assistant' && m.loopIndex === targetLoop && m.timestamp < message.timestamp && m.speakerParticipantId != null
|
|
676
|
+
)
|
|
677
|
+
.map((m) => m.speakerParticipantId as number)
|
|
678
|
+
);
|
|
679
|
+
// 重建该轮:移除玩家、被 @ 点名者置顶 → 再剔除玩家之前已发言的 NPC
|
|
680
|
+
let rolledQueue = completeTurn(loopOrder, playerId, mentionedParticipantIds(newContent, participants, playerId));
|
|
681
|
+
rolledQueue = rolledQueue.filter((id) => !spokenBeforePlayer.has(parseInt(id, 10)));
|
|
682
|
+
await db.sessions.update(sessionId, {
|
|
683
|
+
turnQueueJson: queueJson(rolledQueue),
|
|
684
|
+
loopIndex: targetLoop,
|
|
685
|
+
turnQueueHistoryJson: queueHistoryJson(history.slice(0, targetLoop + 1)),
|
|
686
|
+
});
|
|
687
|
+
await get().refreshLiveQueue(sessionId);
|
|
688
|
+
await continueGroupConversation(sessionId);
|
|
689
|
+
await get().refreshSessions();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
await runConversationLoop(session);
|
|
694
|
+
await get().refreshSessions();
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
saveMessageOnly: async (messageId, newContent, sessionId, newAttachments?: string[], newAttachmentNames?: string[]) => {
|
|
698
|
+
if (get().streaming.sessionId != null) return;
|
|
699
|
+
const message = await db.messages.get(messageId);
|
|
700
|
+
if (!message) return;
|
|
701
|
+
const attachments = newAttachments ?? message.attachments;
|
|
702
|
+
const attachmentInfos = (newAttachments != null ? newAttachments : message.attachments).map((_, i) => ({
|
|
703
|
+
displayName: (newAttachmentNames?.[i]?.trim()) || message.attachmentInfos?.[i]?.displayName || translate('common.attachment'),
|
|
704
|
+
}));
|
|
705
|
+
// 仅修改本条消息内容,不删除任何后续消息、不触发重新生成
|
|
706
|
+
await deleteContextCompressionsFrom(sessionId, message.timestamp);
|
|
707
|
+
await db.messages.update(messageId, { content: newContent, attachments, attachmentInfos });
|
|
708
|
+
await get().loadMessages(sessionId);
|
|
709
|
+
await get().refreshSessions();
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
stopStreaming: () => {
|
|
713
|
+
const abort = get().streaming.abort;
|
|
714
|
+
if (abort) {
|
|
715
|
+
groupLoopStopped = true;
|
|
716
|
+
abort.abort();
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
deleteSession: async (id) => {
|
|
721
|
+
// 先取会话记录(工作目录在删除后不可查),再清理目录与数据
|
|
722
|
+
const session = await db.sessions.get(id);
|
|
723
|
+
invalidateContextCompressionJob(id);
|
|
724
|
+
await db.messages.where('sessionId').equals(id).delete();
|
|
725
|
+
await db.contextCompressions.where('sessionId').equals(id).delete();
|
|
726
|
+
await db.participants.where('sessionId').equals(id).delete();
|
|
727
|
+
await db.sessions.delete(id);
|
|
728
|
+
// 删除会话的专属磁盘工作目录;沙箱不可用时静默跳过
|
|
729
|
+
if (session?.workspaceDir) {
|
|
730
|
+
await deleteSessionWorkspace(session.workspaceDir);
|
|
731
|
+
}
|
|
732
|
+
set((state) => {
|
|
733
|
+
const activitySessionIds = { ...state.activitySessionIds };
|
|
734
|
+
delete activitySessionIds[id];
|
|
735
|
+
return state.activeSessionId === id
|
|
736
|
+
? { activeSessionId: null, activitySessionIds }
|
|
737
|
+
: { activitySessionIds };
|
|
738
|
+
});
|
|
739
|
+
get().clearLiveQueue(id);
|
|
740
|
+
await get().refreshSessions();
|
|
741
|
+
},
|
|
742
|
+
|
|
743
|
+
togglePin: async (id) => {
|
|
744
|
+
const session = await db.sessions.get(id);
|
|
745
|
+
if (!session) return;
|
|
746
|
+
const pinned = (session as { pinned?: unknown }).pinned ? 0 : 1;
|
|
747
|
+
await db.sessions.update(id, { pinned });
|
|
748
|
+
await get().refreshSessions();
|
|
749
|
+
},
|
|
750
|
+
|
|
751
|
+
/** 从指定消息分叉:复制会话与该消息之前(含)的消息到新会话,并跳转过去 */
|
|
752
|
+
forkSession: async (messageId: number) => {
|
|
753
|
+
const message = await db.messages.get(messageId);
|
|
754
|
+
if (!message) return null;
|
|
755
|
+
const sessionId = message.sessionId;
|
|
756
|
+
await waitForContextCompressionIdle(sessionId);
|
|
757
|
+
const session = await db.sessions.get(sessionId);
|
|
758
|
+
if (!session) return null;
|
|
759
|
+
const [allMessages, sourceCompressions] = await db.transaction(
|
|
760
|
+
'r',
|
|
761
|
+
db.messages,
|
|
762
|
+
db.contextCompressions,
|
|
763
|
+
async () => Promise.all([
|
|
764
|
+
db.messages.where('sessionId').equals(sessionId).sortBy('timestamp'),
|
|
765
|
+
db.contextCompressions.where('sessionId').equals(sessionId).sortBy('createdAt'),
|
|
766
|
+
]),
|
|
767
|
+
);
|
|
768
|
+
const snapshotMessage = allMessages.find((candidate) => candidate.id === messageId);
|
|
769
|
+
if (!snapshotMessage) return null;
|
|
770
|
+
|
|
771
|
+
// 复制会话(保留模式 / 角色 / 世界书 / 人设 / 队列设置;置顶与顺序“未置顶”)
|
|
772
|
+
// 新 Fork 会话分配当前酒馆实例专属的独立工作目录,与原会话互不影响
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
const newSession: ChatSession = {
|
|
775
|
+
...session,
|
|
776
|
+
id: undefined,
|
|
777
|
+
title: `${session.title} #fork`,
|
|
778
|
+
turnQueueJson: '[]',
|
|
779
|
+
turnQueueHistoryJson: '[]',
|
|
780
|
+
loopIndex: 0,
|
|
781
|
+
pinned: 0,
|
|
782
|
+
lastMessage: '',
|
|
783
|
+
updatedAt: now,
|
|
784
|
+
createdAt: now,
|
|
785
|
+
};
|
|
786
|
+
const newId = await db.sessions.add(newSession);
|
|
787
|
+
const workspaceDir = createSessionWorkspaceDir(newId);
|
|
788
|
+
await db.sessions.update(newId, { workspaceDir });
|
|
789
|
+
// 创建对话的同时预建其专属工作目录(会话间隔离,Fork 后互不影响)
|
|
790
|
+
await ensureSessionWorkspaceDir(workspaceDir);
|
|
791
|
+
|
|
792
|
+
// 复制参与者(PLAYER 保留 -1 编号,NPC 按 npcId 映射;重建 seatOrder 保持一致)
|
|
793
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).sortBy('seatOrder');
|
|
794
|
+
const mappedParticipants = participants.map((p, i) => ({
|
|
795
|
+
sessionId: newId,
|
|
796
|
+
participantId: p.participantId,
|
|
797
|
+
kind: p.kind,
|
|
798
|
+
npcId: p.npcId,
|
|
799
|
+
displayName: p.displayName,
|
|
800
|
+
seatOrder: i,
|
|
801
|
+
}));
|
|
802
|
+
await db.participants.bulkAdd(mappedParticipants);
|
|
803
|
+
|
|
804
|
+
// 复制消息:取该消息及之前的全部消息(按时间戳顺序)
|
|
805
|
+
// 消息 id 不可复用(自增),重新按时间顺序写库,保持展示顺序一致。
|
|
806
|
+
// 若分叉点在带工具调用的 assistant 消息上,其 tool 结果消息的时间戳晚于分叉点,
|
|
807
|
+
// 但属于该回合(缺失会导致模型侧 tool_calls 悬空)→ 一并复制。
|
|
808
|
+
const toCopy = allMessages.filter((m) => m.timestamp <= snapshotMessage.timestamp);
|
|
809
|
+
// 分叉点消息及其之前 assistant 声明的工具调用 id
|
|
810
|
+
const declaredCallIds = new Set<string>();
|
|
811
|
+
for (const m of toCopy) {
|
|
812
|
+
if (m.role !== 'assistant') continue;
|
|
813
|
+
try {
|
|
814
|
+
for (const tc of JSON.parse(m.toolCallsJson || '[]') as { id?: string }[]) {
|
|
815
|
+
if (tc.id) declaredCallIds.add(tc.id);
|
|
816
|
+
}
|
|
817
|
+
} catch {
|
|
818
|
+
/* ignore */
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// 追加这些工具调用对应的结果消息(若尚未被时间戳条件覆盖)
|
|
822
|
+
const toolResultsToInclude = allMessages.filter(
|
|
823
|
+
(m) => m.role === 'tool' && m.toolCallId != null && declaredCallIds.has(m.toolCallId) && !toCopy.includes(m)
|
|
824
|
+
);
|
|
825
|
+
const finalCopy = [...toCopy, ...toolResultsToInclude].sort((a, b) => a.timestamp - b.timestamp);
|
|
826
|
+
const copiedMessageIds = new Map<number, number>();
|
|
827
|
+
for (const m of finalCopy) {
|
|
828
|
+
const { id: oldId, ...rest } = m;
|
|
829
|
+
const copiedId = await db.messages.add({ ...rest, sessionId: newId });
|
|
830
|
+
if (oldId != null) copiedMessageIds.set(oldId, copiedId);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// 复制分叉范围内的完整压缩记录,并将压缩边界映射到新消息 id。
|
|
834
|
+
const copiedCompressions = sourceCompressions.flatMap((compression) => {
|
|
835
|
+
const endMessageId = copiedMessageIds.get(compression.endMessageId);
|
|
836
|
+
if (endMessageId == null) return [];
|
|
837
|
+
const { id: _oldId, ...rest } = compression;
|
|
838
|
+
return [{ ...rest, sessionId: newId, endMessageId }];
|
|
839
|
+
});
|
|
840
|
+
if (copiedCompressions.length > 0) {
|
|
841
|
+
await db.contextCompressions.bulkAdd(copiedCompressions);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 新会话预览取最后一条可见消息(工具消息不计入)
|
|
845
|
+
const lastVisible = [...finalCopy].reverse().find((m) => m.role !== 'tool');
|
|
846
|
+
const lastMessage = lastVisible ? sessionPreviewText(lastVisible.content) || '…' : '';
|
|
847
|
+
await db.sessions.update(newId, { lastMessage });
|
|
848
|
+
|
|
849
|
+
await get().refreshSessions();
|
|
850
|
+
await get().loadMessages(newId);
|
|
851
|
+
await get().setActiveSession(newId);
|
|
852
|
+
return newId;
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
updateSessionSettings: async (sessionId, opts) => {
|
|
856
|
+
const session = await db.sessions.get(sessionId);
|
|
857
|
+
if (!session) return;
|
|
858
|
+
const npcIds = [...new Set((opts.npcIds ?? []).filter((id) => Number.isFinite(id)).map(Number))].slice(0, 5);
|
|
859
|
+
if (npcIds.length === 0) return;
|
|
860
|
+
const participantOrder = opts.participantOrder ?? [-1, ...npcIds];
|
|
861
|
+
const participants = await buildSessionParticipants(sessionId, npcIds, participantOrder);
|
|
862
|
+
const finalNpcIds = participants.filter((p) => p.kind === 'NPC' && p.npcId != null).map((p) => p.npcId!);
|
|
863
|
+
if (finalNpcIds.length === 0) return;
|
|
864
|
+
const finalTitle = opts.title?.trim() || participants
|
|
865
|
+
.filter((p) => p.kind === 'NPC' && p.npcId != null)
|
|
866
|
+
.map((p) => p.displayName)
|
|
867
|
+
.join('、');
|
|
868
|
+
const turnOrderMode = opts.turnOrderMode === 'RANDOM' ? 'RANDOM' : 'PRESET';
|
|
869
|
+
const existingParticipants = (await db.participants.where('sessionId').equals(sessionId).toArray())
|
|
870
|
+
.sort((left, right) => left.seatOrder - right.seatOrder);
|
|
871
|
+
const queueSettingsChanged = turnOrderMode !== session.turnOrderMode
|
|
872
|
+
|| existingParticipants.length !== participants.length
|
|
873
|
+
|| existingParticipants.some((existing, index) => {
|
|
874
|
+
const next = participants[index];
|
|
875
|
+
return existing.participantId !== next.participantId
|
|
876
|
+
|| existing.kind !== next.kind
|
|
877
|
+
|| existing.npcId !== next.npcId
|
|
878
|
+
|| existing.displayName !== next.displayName
|
|
879
|
+
|| existing.seatOrder !== next.seatOrder;
|
|
880
|
+
});
|
|
881
|
+
const queue = queueSettingsChanged ? initializeTurnQueue(participants, turnOrderMode) : safeParseQueue(session.turnQueueJson);
|
|
882
|
+
if (queueSettingsChanged) {
|
|
883
|
+
await db.participants.where('sessionId').equals(sessionId).delete();
|
|
884
|
+
await db.participants.bulkAdd(participants);
|
|
885
|
+
}
|
|
886
|
+
await db.sessions.update(sessionId, {
|
|
887
|
+
title: finalTitle,
|
|
888
|
+
mode: finalNpcIds.length === 1 ? 'NPC' : 'GROUP',
|
|
889
|
+
associatedId: finalNpcIds.length === 1 ? finalNpcIds[0] : null,
|
|
890
|
+
worldBookId: opts.worldBookId ?? null,
|
|
891
|
+
userPersonaNpcId: opts.userPersonaNpcId ?? null,
|
|
892
|
+
enableGreeting: opts.enableGreeting !== false,
|
|
893
|
+
mentionOnlyMode: opts.mentionOnlyMode === true && finalNpcIds.length > 1,
|
|
894
|
+
turnOrderMode,
|
|
895
|
+
...(queueSettingsChanged ? {
|
|
896
|
+
turnQueueJson: queueJson(queue),
|
|
897
|
+
turnQueueHistoryJson: queueHistoryJson([queue]),
|
|
898
|
+
loopIndex: 0,
|
|
899
|
+
} : {}),
|
|
900
|
+
updatedAt: Date.now(),
|
|
901
|
+
});
|
|
902
|
+
await get().loadMessages(sessionId);
|
|
903
|
+
await get().refreshSessions();
|
|
904
|
+
await get().refreshLiveQueue(sessionId);
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
resetSessionConversation: async (sessionId) => {
|
|
908
|
+
const session = await db.sessions.get(sessionId);
|
|
909
|
+
if (!session) return;
|
|
910
|
+
invalidateContextCompressionJob(sessionId);
|
|
911
|
+
const participants = (await db.participants.where('sessionId').equals(sessionId).toArray()).sort((a, b) => a.seatOrder - b.seatOrder);
|
|
912
|
+
const queue = initializeTurnQueue(participants, session.turnOrderMode);
|
|
913
|
+
await db.messages.where('sessionId').equals(sessionId).delete();
|
|
914
|
+
await db.contextCompressions.where('sessionId').equals(sessionId).delete();
|
|
915
|
+
const greetingSpeakerId = pickGreetingSpeakerId(session.mode, session.associatedId, queue, session.enableGreeting !== false);
|
|
916
|
+
const lastMessage = await seedOpeningGreetingForSpeaker(sessionId, greetingSpeakerId);
|
|
917
|
+
const greeting = greetingSpeakerId != null
|
|
918
|
+
? (await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp')).at(-1)
|
|
919
|
+
: null;
|
|
920
|
+
const greetingMentions = greeting && greetingSpeakerId != null
|
|
921
|
+
? mentionedParticipantIds(greeting.content, participants, greetingSpeakerId)
|
|
922
|
+
: [];
|
|
923
|
+
await db.sessions.update(sessionId, {
|
|
924
|
+
turnQueueJson: queueJson(session.mode === 'GROUP' && greetingSpeakerId != null
|
|
925
|
+
? completeTurn(queue, greetingSpeakerId, greetingMentions.length > 0 ? greetingMentions : (session.mentionOnlyMode ? [-1] : []))
|
|
926
|
+
: queue),
|
|
927
|
+
turnQueueHistoryJson: queueHistoryJson([queue]),
|
|
928
|
+
loopIndex: 0,
|
|
929
|
+
lastMessage,
|
|
930
|
+
updatedAt: Date.now(),
|
|
931
|
+
});
|
|
932
|
+
await get().loadMessages(sessionId);
|
|
933
|
+
await get().refreshSessions();
|
|
934
|
+
await get().refreshLiveQueue(sessionId);
|
|
935
|
+
if (session.mode === 'GROUP' && greetingSpeakerId != null) {
|
|
936
|
+
void continueGroupConversation(sessionId);
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
|
|
940
|
+
resolveConfirmation: (approved) => {
|
|
941
|
+
const req = get().pendingConfirmation;
|
|
942
|
+
if (!req) return;
|
|
943
|
+
const d = confirmationDeferreds.get(req);
|
|
944
|
+
if (d) {
|
|
945
|
+
confirmationDeferreds.delete(req);
|
|
946
|
+
d.resolve(approved);
|
|
947
|
+
}
|
|
948
|
+
set({ pendingConfirmation: null });
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
setActiveDisplay: (d) => set({ activeDisplay: d }),
|
|
952
|
+
|
|
953
|
+
/** 用 DB 最新队列刷新快照(群聊面板的实时数据源) */
|
|
954
|
+
refreshLiveQueue: async (sessionId) => {
|
|
955
|
+
const session = await db.sessions.get(sessionId);
|
|
956
|
+
if (!session) {
|
|
957
|
+
get().clearLiveQueue(sessionId);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const history = await ensureLoopHistory(sessionId);
|
|
961
|
+
const parts = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
962
|
+
// 展示用:队首仅用于「正在发言」指示;队列本身以循环历史为准
|
|
963
|
+
const queue = safeParseQueue(session.turnQueueJson).filter((id) =>
|
|
964
|
+
parts.some((p) => String(p.participantId) === id)
|
|
965
|
+
);
|
|
966
|
+
set((s) => ({
|
|
967
|
+
liveQueueBySession: {
|
|
968
|
+
...s.liveQueueBySession,
|
|
969
|
+
[sessionId]: {
|
|
970
|
+
queue,
|
|
971
|
+
history,
|
|
972
|
+
loopIndex: session.loopIndex,
|
|
973
|
+
turnOrderMode: session.turnOrderMode,
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
}));
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
/** 清除某会话的队列快照 */
|
|
980
|
+
clearLiveQueue: (sessionId) => {
|
|
981
|
+
set((s) => {
|
|
982
|
+
if (!(sessionId in s.liveQueueBySession)) return {};
|
|
983
|
+
const next = { ...s.liveQueueBySession };
|
|
984
|
+
delete next[sessionId];
|
|
985
|
+
return { liveQueueBySession: next };
|
|
986
|
+
});
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
addToast: (message, kind = 'info') => {
|
|
990
|
+
const id = ++toastSeq;
|
|
991
|
+
set({ toasts: [...get().toasts, { id, message, kind }] });
|
|
992
|
+
setTimeout(() => set({ toasts: get().toasts.filter((t) => t.id !== id) }), 3500);
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
removeToast: (id) => set({ toasts: get().toasts.filter((t) => t.id !== id) }),
|
|
996
|
+
}));
|
|
997
|
+
|
|
998
|
+
// ============================================================
|
|
999
|
+
// 内部实现
|
|
1000
|
+
// ============================================================
|
|
1001
|
+
|
|
1002
|
+
const confirmationDeferreds = new Map<ToolConfirmationRequest, { resolve: (v: boolean) => void }>();
|
|
1003
|
+
|
|
1004
|
+
function isEnabledProviderModel(provider: ApiProvider, modelId: string): boolean {
|
|
1005
|
+
return provider.isEnabled &&
|
|
1006
|
+
Boolean(provider.baseUrl.trim()) &&
|
|
1007
|
+
enabledModelIds(provider).has(modelId) &&
|
|
1008
|
+
provider.cachedModelsCsv.split(',').some((candidateModel) => candidateModel.trim() === modelId);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** 聚合启用 Provider 的模型列表,每个 Provider 保留独立的选择项。 */
|
|
1012
|
+
function aggregateEnabledProviderModels(providers: ApiProvider[]): ProviderModelOption[] {
|
|
1013
|
+
return providers
|
|
1014
|
+
.flatMap((provider) => {
|
|
1015
|
+
if (provider.id == null || !provider.isEnabled || !provider.baseUrl.trim()) return [];
|
|
1016
|
+
const aliases = modelAliases(provider);
|
|
1017
|
+
const enabledModels = enabledModelIds(provider);
|
|
1018
|
+
return provider.cachedModelsCsv
|
|
1019
|
+
.split(',')
|
|
1020
|
+
.map((modelId) => modelId.trim())
|
|
1021
|
+
.filter((modelId) => modelId && enabledModels.has(modelId))
|
|
1022
|
+
.map((modelId) => ({ providerId: provider.id!, modelId, label: aliases[modelId]?.trim() || modelId }));
|
|
1023
|
+
})
|
|
1024
|
+
.sort((left, right) => left.label.localeCompare(right.label) || left.providerId - right.providerId);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** 创建会话(公开给 UI 用) */
|
|
1028
|
+
export async function createSession(
|
|
1029
|
+
mode: 'STANDARD' | 'NPC' | 'GROUP',
|
|
1030
|
+
opts?: {
|
|
1031
|
+
associatedId?: number;
|
|
1032
|
+
npcIds?: number[];
|
|
1033
|
+
title?: string;
|
|
1034
|
+
worldBookId?: number | null;
|
|
1035
|
+
userPersonaNpcId?: number | null;
|
|
1036
|
+
turnOrderMode?: TurnOrderMode;
|
|
1037
|
+
participantOrder?: number[];
|
|
1038
|
+
enableGreeting?: boolean;
|
|
1039
|
+
mentionOnlyMode?: boolean;
|
|
1040
|
+
}
|
|
1041
|
+
): Promise<number> {
|
|
1042
|
+
let title = opts?.title;
|
|
1043
|
+
if (!title) {
|
|
1044
|
+
title = mode === 'STANDARD' ? translate('nav.newStandardTitle') : mode === 'NPC' ? translate('nav.newNpcTitle') : translate('nav.newGroupTitle');
|
|
1045
|
+
}
|
|
1046
|
+
const turnOrderMode = opts?.turnOrderMode === 'RANDOM' ? 'RANDOM' : 'PRESET';
|
|
1047
|
+
const requestedNpcIds = mode === 'NPC'
|
|
1048
|
+
? (opts?.associatedId != null ? [opts.associatedId] : [])
|
|
1049
|
+
: [...new Set((opts?.npcIds ?? []).filter((id) => Number.isFinite(id)).map(Number))].slice(0, 5);
|
|
1050
|
+
const now = Date.now();
|
|
1051
|
+
const npcId = mode === 'NPC' ? (requestedNpcIds[0] ?? null) : null;
|
|
1052
|
+
const npc = npcId != null ? await db.npcs.get(npcId) : null;
|
|
1053
|
+
let worldBookId = opts?.worldBookId ?? null;
|
|
1054
|
+
// 默认将内置经营手册附加到酒馆老板的单聊;保留显式指定的世界书。
|
|
1055
|
+
if (worldBookId == null && npc && isTavernKeeper(npc)) {
|
|
1056
|
+
const handbook = await db.worldBooks.filter(isBuiltinTavernHandbook).first();
|
|
1057
|
+
if (handbook?.id != null) worldBookId = handbook.id;
|
|
1058
|
+
}
|
|
1059
|
+
// 会话专属工作目录:先写入会话拿自增 id,再用 id 生成当前酒馆实例专属目录并回填。
|
|
1060
|
+
// 该会话的所有工具调用(shell / 文件读写 / 脚本执行等)都在此目录下进行,会话间及酒馆实例间互相隔离。
|
|
1061
|
+
const id = await db.sessions.add({
|
|
1062
|
+
title,
|
|
1063
|
+
mode,
|
|
1064
|
+
associatedId: npcId,
|
|
1065
|
+
worldBookId,
|
|
1066
|
+
userPersonaNpcId: opts?.userPersonaNpcId ?? null,
|
|
1067
|
+
enableGreeting: opts?.enableGreeting !== false,
|
|
1068
|
+
mentionOnlyMode: opts?.mentionOnlyMode === true && mode === 'GROUP',
|
|
1069
|
+
turnOrderMode,
|
|
1070
|
+
workspaceDir: null,
|
|
1071
|
+
turnQueueJson: '[]',
|
|
1072
|
+
turnQueueHistoryJson: '[]',
|
|
1073
|
+
loopIndex: 0,
|
|
1074
|
+
pinned: 0,
|
|
1075
|
+
lastMessage: '',
|
|
1076
|
+
updatedAt: now,
|
|
1077
|
+
createdAt: now,
|
|
1078
|
+
});
|
|
1079
|
+
// 用当前酒馆实例标识和会话 id 生成单层工作目录,确保不同酒馆实例间互不影响。
|
|
1080
|
+
const workspaceDir = createSessionWorkspaceDir(id);
|
|
1081
|
+
await db.sessions.update(id, { workspaceDir });
|
|
1082
|
+
// 创建对话的同时预建其专属工作目录(磁盘沙箱启动时会真实创建;
|
|
1083
|
+
// 未启动/失败时静默跳过,首次工具调用时仍会自动补建)
|
|
1084
|
+
await ensureSessionWorkspaceDir(workspaceDir);
|
|
1085
|
+
|
|
1086
|
+
// 参与者
|
|
1087
|
+
const participantsById = new Map<number, ChatParticipant>();
|
|
1088
|
+
participantsById.set(-1, {
|
|
1089
|
+
sessionId: id,
|
|
1090
|
+
participantId: -1,
|
|
1091
|
+
kind: 'PLAYER',
|
|
1092
|
+
npcId: null,
|
|
1093
|
+
displayName: translate('common.user'),
|
|
1094
|
+
seatOrder: 0,
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
if (mode === 'NPC' && requestedNpcIds[0] != null) {
|
|
1098
|
+
const npc = await db.npcs.get(requestedNpcIds[0]);
|
|
1099
|
+
if (npc) {
|
|
1100
|
+
participantsById.set(requestedNpcIds[0], {
|
|
1101
|
+
sessionId: id,
|
|
1102
|
+
participantId: requestedNpcIds[0],
|
|
1103
|
+
kind: 'NPC',
|
|
1104
|
+
npcId: requestedNpcIds[0],
|
|
1105
|
+
displayName: npc.name,
|
|
1106
|
+
seatOrder: 1,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
} else if (mode === 'GROUP') {
|
|
1110
|
+
for (const npcId of requestedNpcIds) {
|
|
1111
|
+
const npc = await db.npcs.get(npcId);
|
|
1112
|
+
if (!npc) continue;
|
|
1113
|
+
participantsById.set(npcId, {
|
|
1114
|
+
sessionId: id,
|
|
1115
|
+
participantId: npcId,
|
|
1116
|
+
kind: 'NPC',
|
|
1117
|
+
npcId,
|
|
1118
|
+
displayName: npc.name,
|
|
1119
|
+
seatOrder: participantsById.size,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const defaultParticipantOrder = [...participantsById.keys()];
|
|
1124
|
+
const validParticipantIds = new Set(defaultParticipantOrder);
|
|
1125
|
+
const normalizedParticipantOrder: number[] = [];
|
|
1126
|
+
for (const rawId of opts?.participantOrder ?? []) {
|
|
1127
|
+
const participantId = Number(rawId);
|
|
1128
|
+
if (!validParticipantIds.has(participantId) || normalizedParticipantOrder.includes(participantId)) continue;
|
|
1129
|
+
normalizedParticipantOrder.push(participantId);
|
|
1130
|
+
}
|
|
1131
|
+
for (const participantId of defaultParticipantOrder) {
|
|
1132
|
+
if (!normalizedParticipantOrder.includes(participantId)) normalizedParticipantOrder.push(participantId);
|
|
1133
|
+
}
|
|
1134
|
+
const participants = normalizedParticipantOrder
|
|
1135
|
+
.map((participantId, seatOrder) => {
|
|
1136
|
+
const participant = participantsById.get(participantId);
|
|
1137
|
+
return participant ? { ...participant, seatOrder } : null;
|
|
1138
|
+
})
|
|
1139
|
+
.filter(Boolean) as ChatParticipant[];
|
|
1140
|
+
await db.participants.bulkAdd(participants);
|
|
1141
|
+
|
|
1142
|
+
// 初始化队列(含玩家;玩家默认队首)— 群聊的循环由全体成员组成
|
|
1143
|
+
const queue = initializeTurnQueue(participants, turnOrderMode);
|
|
1144
|
+
await db.sessions.update(id, {
|
|
1145
|
+
turnQueueJson: queueJson(queue),
|
|
1146
|
+
turnQueueHistoryJson: queueHistoryJson([queue]),
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const enableGreeting = opts?.enableGreeting !== false;
|
|
1150
|
+
const greetingSpeakerId = pickGreetingSpeakerId(mode, mode === 'NPC' ? (requestedNpcIds[0] ?? null) : null, queue, enableGreeting);
|
|
1151
|
+
const greetingPreview = await seedOpeningGreetingForSpeaker(id, greetingSpeakerId);
|
|
1152
|
+
if (greetingPreview) {
|
|
1153
|
+
const patch: Partial<ChatSession> = { lastMessage: greetingPreview };
|
|
1154
|
+
if (mode === 'GROUP' && greetingSpeakerId != null) {
|
|
1155
|
+
const greeting = (await db.messages.where('sessionId').equals(id).sortBy('timestamp')).at(-1);
|
|
1156
|
+
const mentions = greeting
|
|
1157
|
+
? mentionedParticipantIds(greeting.content, participants, greetingSpeakerId)
|
|
1158
|
+
: [];
|
|
1159
|
+
patch.turnQueueJson = queueJson(completeTurn(
|
|
1160
|
+
queue,
|
|
1161
|
+
greetingSpeakerId,
|
|
1162
|
+
mentions.length > 0 ? mentions : (opts?.mentionOnlyMode ? [-1] : []),
|
|
1163
|
+
));
|
|
1164
|
+
}
|
|
1165
|
+
await db.sessions.update(id, patch);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// 同步 store
|
|
1169
|
+
await useStore.getState().refreshSessions();
|
|
1170
|
+
await useStore.getState().refreshLiveQueue(id);
|
|
1171
|
+
if (mode === 'GROUP' && greetingSpeakerId != null) {
|
|
1172
|
+
void continueGroupConversation(id);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return id;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function buildSessionParticipants(sessionId: number, npcIds: number[], participantOrder?: number[]): Promise<ChatParticipant[]> {
|
|
1179
|
+
const participantsById = new Map<number, ChatParticipant>();
|
|
1180
|
+
participantsById.set(-1, {
|
|
1181
|
+
sessionId,
|
|
1182
|
+
participantId: -1,
|
|
1183
|
+
kind: 'PLAYER',
|
|
1184
|
+
npcId: null,
|
|
1185
|
+
displayName: translate('common.user'),
|
|
1186
|
+
seatOrder: 0,
|
|
1187
|
+
});
|
|
1188
|
+
for (const npcId of npcIds) {
|
|
1189
|
+
const npc = await db.npcs.get(npcId);
|
|
1190
|
+
if (!npc) continue;
|
|
1191
|
+
participantsById.set(npcId, {
|
|
1192
|
+
sessionId,
|
|
1193
|
+
participantId: npcId,
|
|
1194
|
+
kind: 'NPC',
|
|
1195
|
+
npcId,
|
|
1196
|
+
displayName: npc.name,
|
|
1197
|
+
seatOrder: participantsById.size,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
const defaultParticipantOrder = [...participantsById.keys()];
|
|
1201
|
+
const validParticipantIds = new Set(defaultParticipantOrder);
|
|
1202
|
+
const normalizedParticipantOrder: number[] = [];
|
|
1203
|
+
for (const rawId of participantOrder ?? []) {
|
|
1204
|
+
const participantId = Number(rawId);
|
|
1205
|
+
if (!validParticipantIds.has(participantId) || normalizedParticipantOrder.includes(participantId)) continue;
|
|
1206
|
+
normalizedParticipantOrder.push(participantId);
|
|
1207
|
+
}
|
|
1208
|
+
for (const participantId of defaultParticipantOrder) {
|
|
1209
|
+
if (!normalizedParticipantOrder.includes(participantId)) normalizedParticipantOrder.push(participantId);
|
|
1210
|
+
}
|
|
1211
|
+
return normalizedParticipantOrder
|
|
1212
|
+
.map((participantId, seatOrder) => {
|
|
1213
|
+
const participant = participantsById.get(participantId);
|
|
1214
|
+
return participant ? { ...participant, seatOrder } : null;
|
|
1215
|
+
})
|
|
1216
|
+
.filter(Boolean) as ChatParticipant[];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function seedOpeningGreetingForSpeaker(
|
|
1220
|
+
sessionId: number,
|
|
1221
|
+
greetingSpeakerId: number | null,
|
|
1222
|
+
): Promise<string> {
|
|
1223
|
+
if (greetingSpeakerId == null) return '';
|
|
1224
|
+
const [session, npc, participants] = await Promise.all([
|
|
1225
|
+
db.sessions.get(sessionId),
|
|
1226
|
+
db.npcs.get(greetingSpeakerId),
|
|
1227
|
+
db.participants.where('sessionId').equals(sessionId).toArray(),
|
|
1228
|
+
]);
|
|
1229
|
+
if (!npc) return '';
|
|
1230
|
+
const preview = await seedOpeningGreeting(sessionId, npc);
|
|
1231
|
+
const player = participants.find((participant) => participant.kind === 'PLAYER');
|
|
1232
|
+
if (session?.mentionOnlyMode && player) {
|
|
1233
|
+
const greeting = (await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp'))
|
|
1234
|
+
.filter((message) => message.role === 'assistant' && message.speakerParticipantId === greetingSpeakerId)
|
|
1235
|
+
.at(-1);
|
|
1236
|
+
if (greeting?.id != null && mentionedParticipantIds(greeting.content, participants, greetingSpeakerId).length === 0) {
|
|
1237
|
+
await db.messages.update(greeting.id, { displayHandoffMention: `@${player.displayName}` });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
return preview;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function pickGreetingSpeakerId(
|
|
1244
|
+
mode: SessionMode,
|
|
1245
|
+
associatedId: number | null,
|
|
1246
|
+
queue: string[],
|
|
1247
|
+
enableGreeting: boolean
|
|
1248
|
+
): number | null {
|
|
1249
|
+
if (!enableGreeting) return null;
|
|
1250
|
+
if (mode === 'NPC' && associatedId != null) return associatedId;
|
|
1251
|
+
if (mode === 'GROUP') {
|
|
1252
|
+
const firstSpeakerId = Number(queue[0] ?? NaN);
|
|
1253
|
+
if (Number.isFinite(firstSpeakerId) && firstSpeakerId !== -1) return firstSpeakerId;
|
|
1254
|
+
}
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function handleMagicCommand(session: ChatSession, cmd: string): Promise<void> {
|
|
1259
|
+
if (cmd === '/new') {
|
|
1260
|
+
const marker: ChatMessage = {
|
|
1261
|
+
sessionId: session.id!,
|
|
1262
|
+
role: 'system',
|
|
1263
|
+
speakerParticipantId: null,
|
|
1264
|
+
speakerName: null,
|
|
1265
|
+
content: NEW_TOPIC_MARKER,
|
|
1266
|
+
toolCallsJson: '[]',
|
|
1267
|
+
toolCallId: null,
|
|
1268
|
+
thinkingContent: null,
|
|
1269
|
+
loopIndex: session.mode === 'GROUP' ? currentLoopIndexOf(session) : null,
|
|
1270
|
+
timestamp: Date.now(),
|
|
1271
|
+
latencyMs: null,
|
|
1272
|
+
promptTokens: 0,
|
|
1273
|
+
completionTokens: 0,
|
|
1274
|
+
totalTokens: 0,
|
|
1275
|
+
tokensPerSec: null,
|
|
1276
|
+
modelUsed: null,
|
|
1277
|
+
attachments: [],
|
|
1278
|
+
attachmentInfos: [],
|
|
1279
|
+
displayRef: null,
|
|
1280
|
+
rawRequestBody: null,
|
|
1281
|
+
rawResponseBody: null,
|
|
1282
|
+
};
|
|
1283
|
+
await db.messages.add(marker);
|
|
1284
|
+
await db.sessions.update(session.id!, { updatedAt: Date.now(), lastMessage: NEW_TOPIC_MARKER });
|
|
1285
|
+
await useStore.getState().loadMessages(session.id!);
|
|
1286
|
+
} else if (cmd === '/pass') {
|
|
1287
|
+
// 魔法指令 /pass:将玩家移出队列,不修改任何对话历史
|
|
1288
|
+
if (session.mode === 'GROUP') {
|
|
1289
|
+
const participants = await db.participants.where('sessionId').equals(session.id!).toArray();
|
|
1290
|
+
const session1 = await db.sessions.get(session.id!);
|
|
1291
|
+
if (session1) {
|
|
1292
|
+
const player = participants.find((p) => p.kind === 'PLAYER');
|
|
1293
|
+
const playerId = player ? player.participantId : -1;
|
|
1294
|
+
const { queue, loopIndex, loopStarted } = refreshQueue(session1, participants);
|
|
1295
|
+
// 将玩家移出队列;边界时把新循环完整顺序写入历史
|
|
1296
|
+
await persistQueue(session1.id!, passPlayer(queue, playerId), loopIndex, loopStarted);
|
|
1297
|
+
// 实时刷新队列面板(玩家已移出)
|
|
1298
|
+
await useStore.getState().refreshLiveQueue(session1.id!);
|
|
1299
|
+
// 继续群聊(其余成员依次发言)
|
|
1300
|
+
await continueGroupConversation(session.id!);
|
|
1301
|
+
}
|
|
1302
|
+
} else {
|
|
1303
|
+
useStore.getState().addToast(translate('toast.passOnlyGroup'), 'error');
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** 对话主循环:NPC / STANDARD 直接回复;GROUP 依队列轮转。
|
|
1309
|
+
* 返回该轮生成结果(供「重新生成」失败回退判断)。 */
|
|
1310
|
+
async function runConversationLoop(session: ChatSession): Promise<TurnResult> {
|
|
1311
|
+
if (session.mode === 'STANDARD') {
|
|
1312
|
+
return streamAssistantTurn(session, null, null);
|
|
1313
|
+
} else if (session.mode === 'NPC') {
|
|
1314
|
+
const npc = session.associatedId ? await db.npcs.get(session.associatedId) : null;
|
|
1315
|
+
if (npc) {
|
|
1316
|
+
return streamAssistantTurn(session, npc, session.associatedId);
|
|
1317
|
+
}
|
|
1318
|
+
return 'failed';
|
|
1319
|
+
} else if (session.mode === 'GROUP') {
|
|
1320
|
+
return continueGroupConversation(session.id!);
|
|
1321
|
+
}
|
|
1322
|
+
return 'ok';
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* 统一持久化队列与循环历史:
|
|
1327
|
+
* - 保存剩余队列(队首 = 下一发言者)
|
|
1328
|
+
* - loopStarted(刚进入新循环)→ 把新循环完整顺序追加到历史末尾
|
|
1329
|
+
* 所有队列变更点(玩家发言 / /pass / 回合推进 / 循环结束重初始化)都经由它落库,
|
|
1330
|
+
* 保证面板的「循环历史」完整可观测。
|
|
1331
|
+
*/
|
|
1332
|
+
async function persistQueue(
|
|
1333
|
+
sessionId: number,
|
|
1334
|
+
queue: string[],
|
|
1335
|
+
loopIndex: number,
|
|
1336
|
+
loopStarted: boolean
|
|
1337
|
+
): Promise<void> {
|
|
1338
|
+
const session = await db.sessions.get(sessionId);
|
|
1339
|
+
if (!session) return;
|
|
1340
|
+
const history = safeParseQueueHistory(session.turnQueueHistoryJson || '[]');
|
|
1341
|
+
const nextHistory = loopStarted ? [...history, queue] : history;
|
|
1342
|
+
await db.sessions.update(sessionId, {
|
|
1343
|
+
turnQueueJson: queueJson(queue),
|
|
1344
|
+
loopIndex,
|
|
1345
|
+
turnQueueHistoryJson: queueHistoryJson(nextHistory),
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
async function refreshQueueAndSave(sessionId: number): Promise<void> {
|
|
1350
|
+
const session = await db.sessions.get(sessionId);
|
|
1351
|
+
if (!session) return;
|
|
1352
|
+
const parts = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
1353
|
+
const { queue, loopIndex, loopStarted } = refreshQueue(session, parts);
|
|
1354
|
+
await persistQueue(sessionId, queue, loopIndex, loopStarted);
|
|
1355
|
+
await useStore.getState().refreshLiveQueue(sessionId);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* 读取循环历史(带兼容兜底:无历史记录时用当前队列作为第 1 轮)。
|
|
1360
|
+
* 历史的新循环追加由各队列变更点(persistQueue)完成。
|
|
1361
|
+
*/
|
|
1362
|
+
async function ensureLoopHistory(sessionId: number): Promise<string[][]> {
|
|
1363
|
+
const session = await db.sessions.get(sessionId);
|
|
1364
|
+
if (!session) return [];
|
|
1365
|
+
const history = safeParseQueueHistory(session.turnQueueHistoryJson || '[]');
|
|
1366
|
+
if (history.length > 0) return history;
|
|
1367
|
+
// 兼容旧数据 / 极早阶段:用当前队列作为唯一一轮
|
|
1368
|
+
const parts = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
1369
|
+
const { queue } = refreshQueue(session, parts);
|
|
1370
|
+
if (queue.length > 0) {
|
|
1371
|
+
await db.sessions.update(sessionId, { turnQueueHistoryJson: queueHistoryJson([queue]) });
|
|
1372
|
+
return [queue];
|
|
1373
|
+
}
|
|
1374
|
+
return [];
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** 最近一次某 NPC 的发言文本(用于解析该 NPC 发言中的 @ 点名) */
|
|
1378
|
+
function lastAssistantTextBySpeaker(sessionId: number, speakerParticipantId: number): string {
|
|
1379
|
+
const list = useStore.getState().messages[sessionId] ?? [];
|
|
1380
|
+
const last = [...list]
|
|
1381
|
+
.reverse()
|
|
1382
|
+
.find((m) => m.role === 'assistant' && m.speakerParticipantId === speakerParticipantId);
|
|
1383
|
+
return last?.content ?? '';
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* 群聊当前循环号(0 起):以会话最新持久化的 loopIndex 为准。
|
|
1388
|
+
* 玩家在某循环发言时,消息归属该循环;会话刚进入新循环(loopIndex 未变)时,
|
|
1389
|
+
* 新循环的“领首”消息仍归属当前 loopIndex,保证对话与队列循环历史逐轮对齐。
|
|
1390
|
+
*/
|
|
1391
|
+
function currentLoopIndexOf(session: ChatSession): number {
|
|
1392
|
+
return session.loopIndex ?? 0;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* 解析当前生效的 API 端点:
|
|
1397
|
+
* 1. 若 settings.defaultProviderId 指向已启用 Provider → 用其 baseUrl/apiKey
|
|
1398
|
+
* 2. 否则回退 settings 中的 baseUrl/apiKey
|
|
1399
|
+
*/
|
|
1400
|
+
async function resolveActiveEndpoint(): Promise<{
|
|
1401
|
+
baseUrl: string;
|
|
1402
|
+
apiKey: string;
|
|
1403
|
+
model: string;
|
|
1404
|
+
} | null> {
|
|
1405
|
+
const settings = useStore.getState().settings;
|
|
1406
|
+
if (!settings) return null;
|
|
1407
|
+
const providers = await db.providers.toArray();
|
|
1408
|
+
const model = settings.defaultModel?.trim() || '';
|
|
1409
|
+
if (!model) return null;
|
|
1410
|
+
|
|
1411
|
+
const activeProvider = settings.defaultProviderId != null
|
|
1412
|
+
? providers.find((provider) => provider.id === settings.defaultProviderId && isEnabledProviderModel(provider, model))
|
|
1413
|
+
: null;
|
|
1414
|
+
|
|
1415
|
+
if (!activeProvider) return null;
|
|
1416
|
+
return { baseUrl: activeProvider.baseUrl, apiKey: activeProvider.apiKey, model };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* 执行一次 assistant 流式回合(含 ReAct 工具调用链,最多 4 层)。
|
|
1421
|
+
* 返回该回合结果:'ok' 已产出回复 / 'failed' 生成失败(调用方据此回退)
|
|
1422
|
+
* / 'stopped' 用户主动停止(视为成功保留部分内容,不回退)。
|
|
1423
|
+
*/
|
|
1424
|
+
function toPromptMessage(message: ChatMessage) {
|
|
1425
|
+
return {
|
|
1426
|
+
role: message.role,
|
|
1427
|
+
content: message.content,
|
|
1428
|
+
speakerParticipantId: message.speakerParticipantId,
|
|
1429
|
+
speakerName: message.speakerName,
|
|
1430
|
+
thinkingContent: message.thinkingContent,
|
|
1431
|
+
toolCallsJson: message.toolCallsJson,
|
|
1432
|
+
toolCallId: message.toolCallId,
|
|
1433
|
+
attachments: message.attachments,
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
type ConversationTurn = { start: number; end: number };
|
|
1438
|
+
|
|
1439
|
+
function completedConversationTurns(messages: ChatMessage[]): ConversationTurn[] {
|
|
1440
|
+
const turns: ConversationTurn[] = [];
|
|
1441
|
+
let pendingTurn: { start: number; end: number; phase: 'user' | 'assistant' } | null = null;
|
|
1442
|
+
|
|
1443
|
+
for (let index = 0; index < messages.length; index++) {
|
|
1444
|
+
const role = messages[index].role;
|
|
1445
|
+
if (role === 'user') {
|
|
1446
|
+
if (pendingTurn?.phase === 'assistant') turns.push({ start: pendingTurn.start, end: index - 1 });
|
|
1447
|
+
if (pendingTurn?.phase !== 'user') pendingTurn = { start: index, end: index, phase: 'user' };
|
|
1448
|
+
else pendingTurn.end = index;
|
|
1449
|
+
} else if (role === 'assistant' && pendingTurn) {
|
|
1450
|
+
pendingTurn.phase = 'assistant';
|
|
1451
|
+
pendingTurn.end = index;
|
|
1452
|
+
} else if (pendingTurn?.phase === 'assistant') {
|
|
1453
|
+
pendingTurn.end = index;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
if (pendingTurn?.phase === 'assistant') turns.push({ start: pendingTurn.start, end: pendingTurn.end });
|
|
1457
|
+
return turns;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function messagesAfterLastNewTopic(messages: ChatMessage[]): ChatMessage[] {
|
|
1461
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
1462
|
+
if (messages[index].role === 'system' && messages[index].content === NEW_TOPIC_MARKER) {
|
|
1463
|
+
return messages.slice(index + 1);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return messages;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function groupCurrentTurnHasAssistantReply(messages: ChatMessage[]): boolean {
|
|
1470
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
1471
|
+
if (messages[index].role === 'assistant') return true;
|
|
1472
|
+
if (messages[index].role === 'user') return false;
|
|
1473
|
+
}
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function selectActiveContextCompression(
|
|
1478
|
+
messages: ChatMessage[],
|
|
1479
|
+
turns: ConversationTurn[],
|
|
1480
|
+
compressions: ContextCompression[],
|
|
1481
|
+
currentThreshold: AppSettings['contextCompressionThreshold'],
|
|
1482
|
+
excludeCurrentGroupTurn: boolean,
|
|
1483
|
+
): ContextCompression | undefined {
|
|
1484
|
+
if (typeof currentThreshold !== 'number') return undefined;
|
|
1485
|
+
|
|
1486
|
+
const turnIndexByMessageId = new Map<number, number>();
|
|
1487
|
+
turns.forEach(({ start, end }, turnIndex) => {
|
|
1488
|
+
for (let messageIndex = start; messageIndex <= end; messageIndex++) {
|
|
1489
|
+
const messageId = messages[messageIndex].id;
|
|
1490
|
+
if (messageId != null) turnIndexByMessageId.set(messageId, turnIndex);
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
for (let index = compressions.length - 1; index >= 0; index--) {
|
|
1495
|
+
const candidate = compressions[index];
|
|
1496
|
+
const compressedTurnIndex = turnIndexByMessageId.get(candidate.endMessageId);
|
|
1497
|
+
const completedTurnCount = turns.length - (excludeCurrentGroupTurn && groupCurrentTurnHasAssistantReply(messages) ? 1 : 0);
|
|
1498
|
+
if (compressedTurnIndex != null && completedTurnCount >= compressedTurnIndex + 1 + currentThreshold) {
|
|
1499
|
+
return candidate;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
return undefined;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function scheduleContextCompression(
|
|
1506
|
+
sessionId: number,
|
|
1507
|
+
settings: AppSettings,
|
|
1508
|
+
baseUrl: string,
|
|
1509
|
+
apiKey: string,
|
|
1510
|
+
model: string,
|
|
1511
|
+
): void {
|
|
1512
|
+
if (contextCompressionJobs.has(sessionId)) {
|
|
1513
|
+
queuedContextCompressionSessions.add(sessionId);
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
const controller = new AbortController();
|
|
1517
|
+
const revision = contextCompressionRevisions.get(sessionId) ?? 0;
|
|
1518
|
+
contextCompressionControllers.set(sessionId, controller);
|
|
1519
|
+
const job = compressCompletedConversationIfNeeded(sessionId, settings, baseUrl, apiKey, model, revision, controller.signal)
|
|
1520
|
+
.finally(async () => {
|
|
1521
|
+
contextCompressionJobs.delete(sessionId);
|
|
1522
|
+
if (contextCompressionControllers.get(sessionId) === controller) {
|
|
1523
|
+
contextCompressionControllers.delete(sessionId);
|
|
1524
|
+
}
|
|
1525
|
+
if (queuedContextCompressionSessions.delete(sessionId)) {
|
|
1526
|
+
const [currentSettings, endpoint] = [useStore.getState().settings, await resolveActiveEndpoint()];
|
|
1527
|
+
if (currentSettings && endpoint) {
|
|
1528
|
+
scheduleContextCompression(sessionId, currentSettings, endpoint.baseUrl, endpoint.apiKey, endpoint.model);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
contextCompressionJobs.set(sessionId, job);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async function compressCompletedConversationIfNeeded(
|
|
1536
|
+
sessionId: number,
|
|
1537
|
+
settings: AppSettings,
|
|
1538
|
+
baseUrl: string,
|
|
1539
|
+
apiKey: string,
|
|
1540
|
+
model: string,
|
|
1541
|
+
revision: number,
|
|
1542
|
+
signal: AbortSignal,
|
|
1543
|
+
): Promise<void> {
|
|
1544
|
+
const threshold = settings.contextCompressionThreshold;
|
|
1545
|
+
if (typeof threshold !== 'number' || threshold < 1) return;
|
|
1546
|
+
|
|
1547
|
+
const [messages, existingCompressions, session] = await Promise.all([
|
|
1548
|
+
db.messages.where('sessionId').equals(sessionId).sortBy('timestamp'),
|
|
1549
|
+
db.contextCompressions.where('sessionId').equals(sessionId).sortBy('createdAt'),
|
|
1550
|
+
db.sessions.get(sessionId),
|
|
1551
|
+
]);
|
|
1552
|
+
if (!session) return;
|
|
1553
|
+
const topicMessages = messagesAfterLastNewTopic(messages);
|
|
1554
|
+
const topicMessageIds = new Set(topicMessages.flatMap((message) => message.id == null ? [] : [message.id]));
|
|
1555
|
+
const existingCompression = [...existingCompressions]
|
|
1556
|
+
.reverse()
|
|
1557
|
+
.find((compression) => topicMessageIds.has(compression.endMessageId));
|
|
1558
|
+
const turns = completedConversationTurns(topicMessages);
|
|
1559
|
+
const compressedTurnCount = existingCompression
|
|
1560
|
+
? turns.findIndex(({ start, end }) =>
|
|
1561
|
+
topicMessages.slice(start, end + 1).some((message) => message.id === existingCompression.endMessageId)
|
|
1562
|
+
) + 1
|
|
1563
|
+
: 0;
|
|
1564
|
+
const nextCompressionTurnCount = compressedTurnCount + threshold;
|
|
1565
|
+
if (turns.length < nextCompressionTurnCount) return;
|
|
1566
|
+
|
|
1567
|
+
const firstTurnIndex = compressedTurnCount;
|
|
1568
|
+
const lastTurnIndex = nextCompressionTurnCount - 1;
|
|
1569
|
+
const newMessages = topicMessages.slice(turns[firstTurnIndex].start, turns[lastTurnIndex].end + 1);
|
|
1570
|
+
|
|
1571
|
+
try {
|
|
1572
|
+
const compressionResult = await summarizeContext(baseUrl, apiKey, model, [{
|
|
1573
|
+
role: 'user',
|
|
1574
|
+
content: [
|
|
1575
|
+
'BEGIN_MEMORY_INPUT',
|
|
1576
|
+
existingCompression ? `<prior_memory>\n${existingCompression.summary}\n</prior_memory>` : '',
|
|
1577
|
+
`<conversation_history>\n${formatMessagesForContextSummary(newMessages, session.mode === 'GROUP')}\n</conversation_history>`,
|
|
1578
|
+
'END_MEMORY_INPUT',
|
|
1579
|
+
].filter(Boolean).join('\n\n'),
|
|
1580
|
+
}], signal);
|
|
1581
|
+
if (signal.aborted || (contextCompressionRevisions.get(sessionId) ?? 0) !== revision) return;
|
|
1582
|
+
const endMessage = await db.messages.get(newMessages[newMessages.length - 1].id!);
|
|
1583
|
+
if (!endMessage || endMessage.sessionId !== sessionId) return;
|
|
1584
|
+
const compression: ContextCompression = {
|
|
1585
|
+
sessionId,
|
|
1586
|
+
summary: compressionResult.summary,
|
|
1587
|
+
endMessageId: newMessages[newMessages.length - 1].id!,
|
|
1588
|
+
endTimestamp: newMessages[newMessages.length - 1].timestamp,
|
|
1589
|
+
createdAt: Date.now(),
|
|
1590
|
+
threshold,
|
|
1591
|
+
rawRequestBody: compressionResult.rawRequestBody,
|
|
1592
|
+
rawResponseBody: compressionResult.rawResponseBody,
|
|
1593
|
+
};
|
|
1594
|
+
const compressionId = await db.contextCompressions.add(compression);
|
|
1595
|
+
compression.id = compressionId;
|
|
1596
|
+
if (signal.aborted || (contextCompressionRevisions.get(sessionId) ?? 0) !== revision) {
|
|
1597
|
+
await db.contextCompressions.delete(compressionId);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
useStore.setState((state) => ({
|
|
1601
|
+
contextCompressions: {
|
|
1602
|
+
...state.contextCompressions,
|
|
1603
|
+
[sessionId]: [...(state.contextCompressions[sessionId] ?? []), compression],
|
|
1604
|
+
},
|
|
1605
|
+
}));
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
if (signal.aborted) return;
|
|
1608
|
+
useStore.getState().addToast(translate('toast.contextCompressionFailed', { msg: (error as Error).message || String(error) }), 'error');
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function formatMessagesForContextSummary(messages: ChatMessage[], isGroup: boolean): string {
|
|
1613
|
+
return messages.map((message) => {
|
|
1614
|
+
const toolCalls = message.toolCallsJson && message.toolCallsJson !== '[]'
|
|
1615
|
+
? `\nTool calls: ${message.toolCallsJson}`
|
|
1616
|
+
: '';
|
|
1617
|
+
if (isGroup && message.speakerName) {
|
|
1618
|
+
return `${message.speakerName}: ${message.content}${toolCalls}`;
|
|
1619
|
+
}
|
|
1620
|
+
return `${message.role}: ${message.content}${toolCalls}`;
|
|
1621
|
+
}).join('\n\n');
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
async function streamAssistantTurn(
|
|
1625
|
+
session: ChatSession,
|
|
1626
|
+
npc: NpcCharacter | null,
|
|
1627
|
+
participantId: number | null = null,
|
|
1628
|
+
participant?: ChatParticipant,
|
|
1629
|
+
turnLoopIndex: number | null = null,
|
|
1630
|
+
): Promise<TurnResult> {
|
|
1631
|
+
const settings = useStore.getState().settings;
|
|
1632
|
+
if (!settings) return 'failed';
|
|
1633
|
+
|
|
1634
|
+
const endpoint = await resolveActiveEndpoint();
|
|
1635
|
+
if (!endpoint) {
|
|
1636
|
+
useStore.getState().addToast(translate('toast.noEndpoint'), 'error');
|
|
1637
|
+
return 'failed';
|
|
1638
|
+
}
|
|
1639
|
+
const { baseUrl, apiKey, model } = endpoint;
|
|
1640
|
+
|
|
1641
|
+
const sessionId = session.id!;
|
|
1642
|
+
const abortController = new AbortController();
|
|
1643
|
+
useStore.setState((state) => ({
|
|
1644
|
+
streaming: { sessionId, abort: abortController },
|
|
1645
|
+
activitySessionIds: { ...state.activitySessionIds, [sessionId]: true },
|
|
1646
|
+
}));
|
|
1647
|
+
// 面板实时指示:该角色开始发言(队列首位高亮)
|
|
1648
|
+
await useStore.getState().refreshLiveQueue(sessionId);
|
|
1649
|
+
|
|
1650
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
1651
|
+
const activeParticipantId = participantId;
|
|
1652
|
+
// 用户点击「停止生成」(stopStreaming → abort.abort())后置位,用于中断正在进行的流式拉取
|
|
1653
|
+
let stopped = false;
|
|
1654
|
+
// 是否已成功持久化一条回复(含工具调用消息);false 且非停止 → 视为生成失败
|
|
1655
|
+
let produced = false;
|
|
1656
|
+
// 本回合是否已计入 1 轮对话(ReAct 多层循环只计一次,避免一轮生成被重复统计)
|
|
1657
|
+
let roundCounted = false;
|
|
1658
|
+
let toolCallCount = 0;
|
|
1659
|
+
let consecutiveToolFailures = 0;
|
|
1660
|
+
let lastToolCallSignature = '';
|
|
1661
|
+
let identicalToolCallCount = 0;
|
|
1662
|
+
let limitDeclined = false;
|
|
1663
|
+
let toolCancelled = false;
|
|
1664
|
+
const resetToolBudgets = () => {
|
|
1665
|
+
depth = 0;
|
|
1666
|
+
toolCallCount = 0;
|
|
1667
|
+
consecutiveToolFailures = 0;
|
|
1668
|
+
lastToolCallSignature = '';
|
|
1669
|
+
identicalToolCallCount = 0;
|
|
1670
|
+
};
|
|
1671
|
+
const confirmLimitContinuation = async (reason: string): Promise<boolean> => {
|
|
1672
|
+
const approved = await requestLimitConfirmation(sessionId, reason, {
|
|
1673
|
+
depth,
|
|
1674
|
+
toolCallCount,
|
|
1675
|
+
identicalToolCallCount,
|
|
1676
|
+
consecutiveToolFailures,
|
|
1677
|
+
});
|
|
1678
|
+
if (approved) resetToolBudgets();
|
|
1679
|
+
else limitDeclined = true;
|
|
1680
|
+
return approved;
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
// ---- ReAct 深度循环 ----
|
|
1684
|
+
let depth = 0;
|
|
1685
|
+
while (true) {
|
|
1686
|
+
if (depth >= MAX_TOOL_CALL_DEPTH) {
|
|
1687
|
+
const continued = await confirmLimitContinuation(
|
|
1688
|
+
translate('confirm.depthLimit', { limit: MAX_TOOL_CALL_DEPTH })
|
|
1689
|
+
);
|
|
1690
|
+
if (!continued) break;
|
|
1691
|
+
}
|
|
1692
|
+
const allMessages = await db.messages.where('sessionId').equals(sessionId).sortBy('timestamp');
|
|
1693
|
+
const currentCompressionThreshold = useStore.getState().settings?.contextCompressionThreshold ?? 'off';
|
|
1694
|
+
const compressions = typeof currentCompressionThreshold === 'number'
|
|
1695
|
+
? await db.contextCompressions.where('sessionId').equals(sessionId).sortBy('createdAt')
|
|
1696
|
+
: [];
|
|
1697
|
+
const topicMessages = messagesAfterLastNewTopic(allMessages);
|
|
1698
|
+
const activeCompression = selectActiveContextCompression(
|
|
1699
|
+
topicMessages,
|
|
1700
|
+
completedConversationTurns(topicMessages),
|
|
1701
|
+
compressions,
|
|
1702
|
+
currentCompressionThreshold,
|
|
1703
|
+
session.mode === 'GROUP',
|
|
1704
|
+
);
|
|
1705
|
+
const requestMessages = activeCompression
|
|
1706
|
+
? [
|
|
1707
|
+
{
|
|
1708
|
+
role: 'user',
|
|
1709
|
+
content: `[Context summary / long-term memory]\n${activeCompression.summary}`,
|
|
1710
|
+
speakerParticipantId: null,
|
|
1711
|
+
speakerName: null,
|
|
1712
|
+
thinkingContent: null,
|
|
1713
|
+
toolCallsJson: '[]',
|
|
1714
|
+
toolCallId: null,
|
|
1715
|
+
attachments: [],
|
|
1716
|
+
},
|
|
1717
|
+
...allMessages.filter((message) => message.id! > activeCompression.endMessageId).map(toPromptMessage),
|
|
1718
|
+
]
|
|
1719
|
+
: allMessages.map(toPromptMessage);
|
|
1720
|
+
const nmessages = buildNetworkMessagesForSession({
|
|
1721
|
+
session,
|
|
1722
|
+
participants,
|
|
1723
|
+
activeSpeakerParticipantId: activeParticipantId,
|
|
1724
|
+
messages: requestMessages,
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
// system prompt
|
|
1728
|
+
const worldBook = session.worldBookId ? await db.worldBooks.get(session.worldBookId) : null;
|
|
1729
|
+
const userPersona = session.userPersonaNpcId ? await db.npcs.get(session.userPersonaNpcId) : null;
|
|
1730
|
+
let systemPrompt: string;
|
|
1731
|
+
if (session.mode === 'STANDARD') systemPrompt = STANDARD_SYSTEM_PROMPT;
|
|
1732
|
+
else if (session.mode === 'NPC') {
|
|
1733
|
+
systemPrompt = buildNpcSystemPrompt(npc?.prompt ?? '', worldBook?.content, userPersona?.prompt);
|
|
1734
|
+
} else {
|
|
1735
|
+
const name = participant?.displayName ?? npc?.name ?? '';
|
|
1736
|
+
// 传入全体成员、玩家称谓与当前回合队列顺序,让模型感知轮次与 @ 呼叫
|
|
1737
|
+
const allSpeakerNames = participants
|
|
1738
|
+
.filter((p) => p.kind === 'NPC')
|
|
1739
|
+
.map((p) => p.displayName);
|
|
1740
|
+
systemPrompt = buildGroupSystemPrompt(name, npc?.prompt ?? '', worldBook?.content, userPersona?.prompt, {
|
|
1741
|
+
allSpeakerNames,
|
|
1742
|
+
mentionOnlyMode: session.mentionOnlyMode === true,
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
nmessages.unshift({ role: 'system', content: systemPrompt });
|
|
1746
|
+
|
|
1747
|
+
// 工具
|
|
1748
|
+
const tools = await getEnabledToolsForSession(sessionId, activeParticipantId ?? null);
|
|
1749
|
+
|
|
1750
|
+
const request = buildChatRequest({
|
|
1751
|
+
model,
|
|
1752
|
+
messages: nmessages,
|
|
1753
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
1754
|
+
temperature: settings.temperature,
|
|
1755
|
+
topP: settings.topP,
|
|
1756
|
+
maxTokens: settings.maxTokens,
|
|
1757
|
+
topK: settings.topK,
|
|
1758
|
+
frequencyPenalty: settings.frequencyPenalty,
|
|
1759
|
+
presencePenalty: settings.presencePenalty,
|
|
1760
|
+
repetitionPenalty: settings.repetitionPenalty,
|
|
1761
|
+
reasoningEffort: settings.reasoningEffort,
|
|
1762
|
+
// 流式输出与工具调用默认开启(无 UI 开关);思考强度由 Reasoning Effort 控制
|
|
1763
|
+
isThinkingModeEnabled: true,
|
|
1764
|
+
streaming: true,
|
|
1765
|
+
baseUrl,
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
// 本次循环已因用户停止而中断 → 不再发起新一轮请求
|
|
1769
|
+
if (abortController.signal.aborted) break;
|
|
1770
|
+
|
|
1771
|
+
// 草稿消息(流式更新)
|
|
1772
|
+
const startTime = Date.now();
|
|
1773
|
+
const draftId = await db.messages.add({
|
|
1774
|
+
sessionId,
|
|
1775
|
+
role: 'assistant',
|
|
1776
|
+
speakerParticipantId: participantId,
|
|
1777
|
+
speakerName: participant?.displayName ?? npc?.name ?? null,
|
|
1778
|
+
content: '',
|
|
1779
|
+
toolCallsJson: '[]',
|
|
1780
|
+
toolCallId: null,
|
|
1781
|
+
thinkingContent: null,
|
|
1782
|
+
loopIndex: turnLoopIndex,
|
|
1783
|
+
timestamp: Date.now(),
|
|
1784
|
+
latencyMs: null,
|
|
1785
|
+
promptTokens: 0,
|
|
1786
|
+
completionTokens: 0,
|
|
1787
|
+
totalTokens: 0,
|
|
1788
|
+
tokensPerSec: null,
|
|
1789
|
+
modelUsed: model,
|
|
1790
|
+
attachments: [],
|
|
1791
|
+
attachmentInfos: [],
|
|
1792
|
+
displayRef: null,
|
|
1793
|
+
rawRequestBody: null,
|
|
1794
|
+
rawResponseBody: null,
|
|
1795
|
+
});
|
|
1796
|
+
await useStore.getState().loadMessages(sessionId);
|
|
1797
|
+
|
|
1798
|
+
let content = '';
|
|
1799
|
+
let thinking = '';
|
|
1800
|
+
let promptTokens = 0;
|
|
1801
|
+
let completionTokens = 0;
|
|
1802
|
+
const rawLines: string[] = [];
|
|
1803
|
+
const toolCalls: Array<{ id: string; name: string; argumentsJson: string; contentOffset: number }> = [];
|
|
1804
|
+
let errorMsg = '';
|
|
1805
|
+
|
|
1806
|
+
// Large file_write arguments arrive as a growing JSON string. Publishing every token
|
|
1807
|
+
// repeatedly parses/reconciles the whole value and can monopolize the browser main thread.
|
|
1808
|
+
// Persist the same snapshots so a reload can recover an in-progress draft.
|
|
1809
|
+
const DRAFT_PUBLISH_INTERVAL_MS = 100;
|
|
1810
|
+
let publishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1811
|
+
let lastPublishedAt = 0;
|
|
1812
|
+
let draftWrite = Promise.resolve();
|
|
1813
|
+
const publishDraft = () => {
|
|
1814
|
+
publishTimer = null;
|
|
1815
|
+
lastPublishedAt = Date.now();
|
|
1816
|
+
const contentSnapshot = content;
|
|
1817
|
+
const thinkingContent = thinking || null;
|
|
1818
|
+
const toolCallsJson = JSON.stringify(
|
|
1819
|
+
toolCalls.map((tc) => ({ id: tc.id, name: tc.name, argumentsJson: tc.argumentsJson, contentOffset: tc.contentOffset }))
|
|
1820
|
+
);
|
|
1821
|
+
useStore.setState((s) => {
|
|
1822
|
+
const list = [...(s.messages[sessionId] ?? [])];
|
|
1823
|
+
const idx = list.findIndex((m) => m.id === draftId);
|
|
1824
|
+
if (idx < 0) return {};
|
|
1825
|
+
list[idx] = { ...list[idx], content: contentSnapshot, thinkingContent, toolCallsJson };
|
|
1826
|
+
return { messages: { ...s.messages, [sessionId]: list } };
|
|
1827
|
+
});
|
|
1828
|
+
draftWrite = draftWrite
|
|
1829
|
+
.then(() => db.messages.update(draftId, { content: contentSnapshot, thinkingContent, toolCallsJson }))
|
|
1830
|
+
.then(() => undefined);
|
|
1831
|
+
};
|
|
1832
|
+
const scheduleDraftPublish = () => {
|
|
1833
|
+
if (publishTimer) return;
|
|
1834
|
+
const delay = Math.max(0, DRAFT_PUBLISH_INTERVAL_MS - (Date.now() - lastPublishedAt));
|
|
1835
|
+
publishTimer = setTimeout(publishDraft, delay);
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1838
|
+
await streamChatCompletions(baseUrl, apiKey, request, (chunk) => {
|
|
1839
|
+
switch (chunk.type) {
|
|
1840
|
+
case 'raw':
|
|
1841
|
+
rawLines.push(chunk.line);
|
|
1842
|
+
break;
|
|
1843
|
+
case 'content':
|
|
1844
|
+
content += chunk.text;
|
|
1845
|
+
break;
|
|
1846
|
+
case 'thinking':
|
|
1847
|
+
thinking += chunk.text;
|
|
1848
|
+
break;
|
|
1849
|
+
case 'tool_call':
|
|
1850
|
+
{
|
|
1851
|
+
const idx = toolCalls.findIndex((tc) => tc.id === chunk.id);
|
|
1852
|
+
if (idx >= 0) {
|
|
1853
|
+
toolCalls[idx] = {
|
|
1854
|
+
...toolCalls[idx],
|
|
1855
|
+
name: chunk.name || toolCalls[idx].name,
|
|
1856
|
+
argumentsJson: chunk.argJson || toolCalls[idx].argumentsJson,
|
|
1857
|
+
};
|
|
1858
|
+
} else {
|
|
1859
|
+
toolCalls.push({
|
|
1860
|
+
id: chunk.id,
|
|
1861
|
+
name: chunk.name,
|
|
1862
|
+
argumentsJson: chunk.argJson,
|
|
1863
|
+
contentOffset: content.length,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
break;
|
|
1868
|
+
case 'usage':
|
|
1869
|
+
promptTokens = chunk.prompt;
|
|
1870
|
+
completionTokens = chunk.completion;
|
|
1871
|
+
break;
|
|
1872
|
+
case 'error':
|
|
1873
|
+
errorMsg = chunk.message;
|
|
1874
|
+
break;
|
|
1875
|
+
case 'done':
|
|
1876
|
+
// 用户点击「停止」(stopStreaming → abort.abort())导致读取被中断时,
|
|
1877
|
+
// attemptStream 会以 done 结束;不再继续累积内容/工具调用
|
|
1878
|
+
if (abortController.signal.aborted) stopped = true;
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
scheduleDraftPublish();
|
|
1882
|
+
}, abortController.signal);
|
|
1883
|
+
|
|
1884
|
+
if (publishTimer) clearTimeout(publishTimer);
|
|
1885
|
+
publishDraft();
|
|
1886
|
+
await draftWrite;
|
|
1887
|
+
|
|
1888
|
+
// 出错时:撤销草稿消息并提示
|
|
1889
|
+
if (errorMsg) {
|
|
1890
|
+
await db.messages.delete(draftId);
|
|
1891
|
+
await useStore.getState().loadMessages(sessionId);
|
|
1892
|
+
finishSessionActivity(sessionId);
|
|
1893
|
+
useStore.getState().addToast(translate('toast.genFailed', { msg: errorMsg }), 'error');
|
|
1894
|
+
return 'failed';
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// 用户点击「停止」时:中断后续处理(工具调用、ReAct 下一层)
|
|
1898
|
+
if (stopped || abortController.signal.aborted) {
|
|
1899
|
+
await persistPartialDraft(draftId, sessionId, content, thinking, toolCalls, request, rawLines, promptTokens, completionTokens, startTime, model, !roundCounted, npc);
|
|
1900
|
+
break;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// 最终持久化草稿
|
|
1904
|
+
const latencyMs = Date.now() - startTime;
|
|
1905
|
+
const finalTokens = completionTokens > 0 ? completionTokens : estimateTokensFromChars(content.length);
|
|
1906
|
+
const tokensPerSec = latencyMs > 0 ? finalTokens / (latencyMs / 1000) : null;
|
|
1907
|
+
const finalToolCalls = toolCalls.map((tc, i) => ({
|
|
1908
|
+
...tc,
|
|
1909
|
+
id: tc.id || `call-${Date.now()}-${i}`,
|
|
1910
|
+
}));
|
|
1911
|
+
const normalizedContent = trimEdgeNewlines(content);
|
|
1912
|
+
const isEmptyAssistantReply =
|
|
1913
|
+
!normalizedContent &&
|
|
1914
|
+
!thinking.trim() &&
|
|
1915
|
+
finalToolCalls.length === 0;
|
|
1916
|
+
if (isEmptyAssistantReply) {
|
|
1917
|
+
await db.messages.delete(draftId);
|
|
1918
|
+
await useStore.getState().loadMessages(sessionId);
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
produced = true;
|
|
1922
|
+
|
|
1923
|
+
await db.messages.update(draftId, {
|
|
1924
|
+
content: normalizedContent,
|
|
1925
|
+
thinkingContent: thinking || null,
|
|
1926
|
+
toolCallsJson: JSON.stringify(
|
|
1927
|
+
finalToolCalls.map((tc) => ({ id: tc.id, name: tc.name, argumentsJson: tc.argumentsJson, contentOffset: tc.contentOffset }))
|
|
1928
|
+
),
|
|
1929
|
+
latencyMs: errorMsg ? null : latencyMs,
|
|
1930
|
+
promptTokens,
|
|
1931
|
+
completionTokens: finalTokens,
|
|
1932
|
+
totalTokens: promptTokens + finalTokens,
|
|
1933
|
+
tokensPerSec,
|
|
1934
|
+
rawRequestBody: JSON.stringify(request),
|
|
1935
|
+
rawResponseBody: rawLines.join('\n'),
|
|
1936
|
+
});
|
|
1937
|
+
await useStore.getState().loadMessages(sessionId);
|
|
1938
|
+
|
|
1939
|
+
// 生涯统计:1 个回复回合 = 1 轮对话(ReAct 多层只计一次);
|
|
1940
|
+
// 群聊中 NPC 发言会把该轮计入对应角色的 careerNpcStats(最活跃角色统计)
|
|
1941
|
+
const { rounds: assistantRounds, npcRounds } = assistantRoundDelta(!roundCounted, npc);
|
|
1942
|
+
roundCounted = roundCounted || assistantRounds > 0;
|
|
1943
|
+
await accumulateStats(
|
|
1944
|
+
{
|
|
1945
|
+
inputTokens: promptTokens,
|
|
1946
|
+
outputTokens: finalTokens,
|
|
1947
|
+
rounds: assistantRounds,
|
|
1948
|
+
npcRounds,
|
|
1949
|
+
},
|
|
1950
|
+
sessionId,
|
|
1951
|
+
null
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
// 更新会话预览
|
|
1955
|
+
const preview = sessionPreviewText(normalizedContent);
|
|
1956
|
+
await db.sessions.update(sessionId, {
|
|
1957
|
+
updatedAt: Date.now(),
|
|
1958
|
+
lastMessage: preview || '…',
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
if (finalToolCalls.length === 0 && session.mode !== 'GROUP') {
|
|
1962
|
+
scheduleContextCompression(sessionId, settings, baseUrl, apiKey, model);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
if (errorMsg) {
|
|
1966
|
+
useStore.getState().addToast(translate('toast.genFailed', { msg: errorMsg }), 'error');
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// ---- 工具调用处理(默认开启) ----
|
|
1970
|
+
if (finalToolCalls.length > 0) {
|
|
1971
|
+
for (const tc of finalToolCalls) {
|
|
1972
|
+
let result: string;
|
|
1973
|
+
const signature = toolCallSignature(tc.name, tc.argumentsJson);
|
|
1974
|
+
const nextIdenticalCount = signature === lastToolCallSignature ? identicalToolCallCount + 1 : 1;
|
|
1975
|
+
|
|
1976
|
+
let allowed = !limitDeclined;
|
|
1977
|
+
let limitReason = '';
|
|
1978
|
+
if (allowed && toolCallCount >= MAX_TOOL_CALLS_PER_TURN) {
|
|
1979
|
+
limitReason = translate('confirm.callLimit', { limit: MAX_TOOL_CALLS_PER_TURN });
|
|
1980
|
+
} else if (allowed && nextIdenticalCount >= MAX_IDENTICAL_TOOL_CALLS) {
|
|
1981
|
+
limitReason = translate('confirm.identicalLimit', {
|
|
1982
|
+
name: tc.name,
|
|
1983
|
+
limit: MAX_IDENTICAL_TOOL_CALLS,
|
|
1984
|
+
});
|
|
1985
|
+
} else if (allowed && consecutiveToolFailures >= MAX_CONSECUTIVE_TOOL_FAILURES) {
|
|
1986
|
+
limitReason = translate('confirm.failureLimit', { limit: MAX_CONSECUTIVE_TOOL_FAILURES });
|
|
1987
|
+
}
|
|
1988
|
+
if (limitReason) allowed = await confirmLimitContinuation(limitReason);
|
|
1989
|
+
|
|
1990
|
+
if (!allowed) {
|
|
1991
|
+
result = translate('confirm.limitCanceled');
|
|
1992
|
+
} else try {
|
|
1993
|
+
identicalToolCallCount = signature === lastToolCallSignature ? identicalToolCallCount + 1 : 1;
|
|
1994
|
+
lastToolCallSignature = signature;
|
|
1995
|
+
toolCallCount++;
|
|
1996
|
+
// 普通角色限制在会话目录;内置酒馆老板的单人会话固定使用 public
|
|
1997
|
+
await applySessionWorkspace(sessionId, npc?.id ?? null);
|
|
1998
|
+
// 确认收口:修改/删除组在外层白名单确认(执行前),shell 组在执行中按需确认,
|
|
1999
|
+
// 两条路径共用同一个 requestConfirmation 回调(requestGeneratedToolConfirmation)
|
|
2000
|
+
const needsConfirm = ['update_skill', 'delete_skill', 'update_character', 'delete_character', 'update_lorebook', 'delete_lorebook'].includes(tc.name);
|
|
2001
|
+
let approved = true;
|
|
2002
|
+
if (needsConfirm) {
|
|
2003
|
+
approved = await requestToolConfirmation(sessionId, tc);
|
|
2004
|
+
}
|
|
2005
|
+
if (!approved) {
|
|
2006
|
+
result = translate('toast.canceled', { name: tc.name });
|
|
2007
|
+
} else {
|
|
2008
|
+
result = await executeToolCall(tc.name, tc.argumentsJson, {
|
|
2009
|
+
sessionId,
|
|
2010
|
+
npcId: npc?.id ?? null,
|
|
2011
|
+
requestConfirmation: (req) => requestGeneratedToolConfirmation(sessionId, req),
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
} catch (e) {
|
|
2015
|
+
// 工具执行本身抛异常(网络 / 沙箱 / 安全拦截等)→ 立即落库为失败结果,
|
|
2016
|
+
// 避免整个回合中断、失败不可见(只有用户下一条消息后才显示)
|
|
2017
|
+
result = `ERROR: ${(e as Error).message || String(e)}`;
|
|
2018
|
+
}
|
|
2019
|
+
// 工具没有返回任何内容(空模板 / 空文件 / 空输出等)→ 补占位结果,
|
|
2020
|
+
// 保证调用卡片立即显示「无结果」而不是空白
|
|
2021
|
+
if (!result || !result.trim()) result = translate('tool.noResult');
|
|
2022
|
+
// 数据变更类工具执行后即时刷新 store,保证界面(角色工坊等)无需刷新即可看到最新数据
|
|
2023
|
+
if (result.startsWith('OK:')) {
|
|
2024
|
+
if (tc.name.includes('character')) await useStore.getState().refreshNpcs();
|
|
2025
|
+
if (tc.name.includes('world_book')) await useStore.getState().refreshWorldBooks();
|
|
2026
|
+
if (tc.name.includes('skill')) {
|
|
2027
|
+
await useStore.getState().refreshTools();
|
|
2028
|
+
await useStore.getState().refreshNpcs();
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
await db.messages.add({
|
|
2032
|
+
sessionId,
|
|
2033
|
+
role: 'tool',
|
|
2034
|
+
speakerParticipantId: null,
|
|
2035
|
+
speakerName: null,
|
|
2036
|
+
content: result,
|
|
2037
|
+
toolCallsJson: '[]',
|
|
2038
|
+
toolCallId: tc.id,
|
|
2039
|
+
thinkingContent: null,
|
|
2040
|
+
loopIndex: turnLoopIndex,
|
|
2041
|
+
timestamp: Date.now(),
|
|
2042
|
+
latencyMs: null,
|
|
2043
|
+
promptTokens: 0,
|
|
2044
|
+
completionTokens: 0,
|
|
2045
|
+
totalTokens: 0,
|
|
2046
|
+
tokensPerSec: null,
|
|
2047
|
+
modelUsed: null,
|
|
2048
|
+
attachments: [],
|
|
2049
|
+
attachmentInfos: [],
|
|
2050
|
+
displayRef: displayRefForToolName(tc.name, result),
|
|
2051
|
+
rawRequestBody: null,
|
|
2052
|
+
rawResponseBody: null,
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
// 展示类工具(file_display):执行成功后自动打开展示弹窗
|
|
2056
|
+
const displayPayload = tc.name === 'file_display' ? parseDisplayRef(result) : null;
|
|
2057
|
+
if (displayPayload) {
|
|
2058
|
+
useStore.getState().setActiveDisplay({
|
|
2059
|
+
path: displayPayload.path,
|
|
2060
|
+
kind: displayPayload.kind,
|
|
2061
|
+
title: displayPayload.title,
|
|
2062
|
+
sessionId,
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// 结果前缀语义约定:ERROR: = 执行失败(计入连续失败);
|
|
2067
|
+
// CANCELLED: = 用户主动停止(外层白名单取消 toast.canceled / shell 拒绝 tool.shellDenied /
|
|
2068
|
+
// Agent 限额停止 confirm.limitCanceled,全部产生点仅此三类),一律不计失败,
|
|
2069
|
+
// 清零失败计数并终止当前 ReAct 回合,把控制权交还用户;其余为成功结果(部分工具成功时返回非 OK: 文本,
|
|
2070
|
+
// 如掷骰结果、JSON 快照、模板输出,不能简单用「非 OK:」判定失败)。
|
|
2071
|
+
// 历史版本曾按「与 toast.canceled 字符串全等」判定用户取消,导致 shell 拒绝(tool.shellDenied,
|
|
2072
|
+
// 名字硬编码为 shell)被误判为失败,现统一按前缀豁免。
|
|
2073
|
+
const isToolError = result.startsWith('ERROR:');
|
|
2074
|
+
const isToolCancelled = result.startsWith('CANCELLED:');
|
|
2075
|
+
if (isToolError) {
|
|
2076
|
+
consecutiveToolFailures++;
|
|
2077
|
+
} else {
|
|
2078
|
+
consecutiveToolFailures = 0;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
if (isToolCancelled) {
|
|
2082
|
+
toolCancelled = true;
|
|
2083
|
+
break;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
if (
|
|
2087
|
+
!limitDeclined &&
|
|
2088
|
+
consecutiveToolFailures >= MAX_CONSECUTIVE_TOOL_FAILURES &&
|
|
2089
|
+
!(await confirmLimitContinuation(
|
|
2090
|
+
translate('confirm.failureLimit', { limit: MAX_CONSECUTIVE_TOOL_FAILURES })
|
|
2091
|
+
))
|
|
2092
|
+
) {
|
|
2093
|
+
limitDeclined = true;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// 工具结果在 DB 落库后立刻刷新 UI(无论是否继续下一层 ReAct),
|
|
2098
|
+
// 保证「失败 / 无结果」在工具执行完成时立即可见,而不是等用户下一条消息
|
|
2099
|
+
await useStore.getState().loadMessages(sessionId);
|
|
2100
|
+
|
|
2101
|
+
if (limitDeclined || toolCancelled) break;
|
|
2102
|
+
|
|
2103
|
+
// 有工具结果 → 下一层 ReAct
|
|
2104
|
+
depth++;
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
break; // 无工具调用则结束
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
finishSessionActivity(sessionId);
|
|
2112
|
+
await refreshQueueAndSave(sessionId);
|
|
2113
|
+
|
|
2114
|
+
// 用户主动停止 → 保留部分内容(不回退);未产出任何回复 → 视为失败
|
|
2115
|
+
if (stopped || abortController.signal.aborted) return 'stopped';
|
|
2116
|
+
return produced ? 'ok' : 'failed';
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function finishSessionActivity(sessionId: number) {
|
|
2120
|
+
useStore.setState((state) => {
|
|
2121
|
+
if (state.activeView !== 'chat' || state.activeSessionId !== sessionId) {
|
|
2122
|
+
return {
|
|
2123
|
+
streaming: { sessionId: null, abort: null },
|
|
2124
|
+
activitySessionIds: { ...state.activitySessionIds, [sessionId]: true },
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const activitySessionIds = { ...state.activitySessionIds };
|
|
2128
|
+
delete activitySessionIds[sessionId];
|
|
2129
|
+
return {
|
|
2130
|
+
streaming: { sessionId: null, abort: null },
|
|
2131
|
+
activitySessionIds,
|
|
2132
|
+
};
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function toolCallSignature(name: string, argumentsJson: string): string {
|
|
2137
|
+
try {
|
|
2138
|
+
return `${name}:${JSON.stringify(sortJsonValue(JSON.parse(argumentsJson || '{}')))}`;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return `${name}:${argumentsJson.trim()}`;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function sortJsonValue(value: unknown): unknown {
|
|
2145
|
+
if (Array.isArray(value)) return value.map(sortJsonValue);
|
|
2146
|
+
if (value && typeof value === 'object') {
|
|
2147
|
+
return Object.fromEntries(
|
|
2148
|
+
Object.entries(value as Record<string, unknown>)
|
|
2149
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
2150
|
+
.map(([key, child]) => [key, sortJsonValue(child)])
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
return value;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* 用户停止生成时,把已流式收到的部分内容持久化为最终消息,
|
|
2158
|
+
* 避免中断后内容丢失(停止而非报错:不加 latency/raw 等数据)。
|
|
2159
|
+
* 返回是否真的持久化了一条非空回复(用于轮数统计)。
|
|
2160
|
+
*/
|
|
2161
|
+
async function persistPartialDraft(
|
|
2162
|
+
draftId: number,
|
|
2163
|
+
sessionId: number,
|
|
2164
|
+
content: string,
|
|
2165
|
+
thinking: string,
|
|
2166
|
+
toolCalls: Array<{ id: string; name: string; argumentsJson: string; contentOffset: number }>,
|
|
2167
|
+
request: ChatCompletionRequest,
|
|
2168
|
+
rawLines: string[],
|
|
2169
|
+
promptTokens: number,
|
|
2170
|
+
completionTokens: number,
|
|
2171
|
+
startTime: number,
|
|
2172
|
+
model: string,
|
|
2173
|
+
countRound: boolean,
|
|
2174
|
+
npc: NpcCharacter | null
|
|
2175
|
+
): Promise<void> {
|
|
2176
|
+
const latencyMs = Date.now() - startTime;
|
|
2177
|
+
const normalizedContent = trimEdgeNewlines(content);
|
|
2178
|
+
const isEmptyAssistantReply =
|
|
2179
|
+
!normalizedContent &&
|
|
2180
|
+
!thinking.trim() &&
|
|
2181
|
+
toolCalls.length === 0;
|
|
2182
|
+
if (isEmptyAssistantReply) {
|
|
2183
|
+
await db.messages.delete(draftId);
|
|
2184
|
+
await useStore.getState().loadMessages(sessionId);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
const finalTokens = completionTokens > 0 ? completionTokens : estimateTokensFromChars(normalizedContent.length);
|
|
2188
|
+
await db.messages.update(draftId, {
|
|
2189
|
+
content: normalizedContent,
|
|
2190
|
+
thinkingContent: thinking || null,
|
|
2191
|
+
toolCallsJson: JSON.stringify(toolCalls.map((tc) => ({ id: tc.id, name: tc.name, argumentsJson: tc.argumentsJson, contentOffset: tc.contentOffset }))),
|
|
2192
|
+
latencyMs,
|
|
2193
|
+
promptTokens,
|
|
2194
|
+
completionTokens: finalTokens,
|
|
2195
|
+
totalTokens: promptTokens + finalTokens,
|
|
2196
|
+
tokensPerSec: latencyMs > 0 ? finalTokens / (latencyMs / 1000) : null,
|
|
2197
|
+
modelUsed: model,
|
|
2198
|
+
rawRequestBody: JSON.stringify(request),
|
|
2199
|
+
rawResponseBody: rawLines.join('\n'),
|
|
2200
|
+
});
|
|
2201
|
+
await useStore.getState().loadMessages(sessionId);
|
|
2202
|
+
|
|
2203
|
+
// 生涯统计与会话预览仍同步(与正常回合一致);
|
|
2204
|
+
// 用户停止但保留了部分内容 → 仍算 1 轮对话(未停止前产生的内容也计)
|
|
2205
|
+
const { rounds: partialRounds, npcRounds } = assistantRoundDelta(countRound, npc);
|
|
2206
|
+
await accumulateStats(
|
|
2207
|
+
{ inputTokens: promptTokens, outputTokens: finalTokens, rounds: partialRounds, npcRounds },
|
|
2208
|
+
sessionId,
|
|
2209
|
+
null
|
|
2210
|
+
);
|
|
2211
|
+
const preview = sessionPreviewText(normalizedContent);
|
|
2212
|
+
await db.sessions.update(sessionId, {
|
|
2213
|
+
updatedAt: Date.now(),
|
|
2214
|
+
lastMessage: preview || '…',
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/** 当前 assistant 回合产生的「一轮对话」对应到生涯统计的增量 */
|
|
2219
|
+
function assistantRoundDelta(
|
|
2220
|
+
round: boolean,
|
|
2221
|
+
npc: NpcCharacter | null
|
|
2222
|
+
): { rounds: number; npcRounds?: { npcId: number; npcName: string; rounds: number } } {
|
|
2223
|
+
if (!round) return { rounds: 0 };
|
|
2224
|
+
if (npc) {
|
|
2225
|
+
return {
|
|
2226
|
+
rounds: 1,
|
|
2227
|
+
npcRounds: { npcId: npc.id!, npcName: npc.name, rounds: 1 },
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
return { rounds: 1 };
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/** 群聊主循环:持续从队列取发言者。
|
|
2234
|
+
* 返回整段循环的结果:任一回合成败决定整体结果(供「重新生成」失败回退判断)。 */
|
|
2235
|
+
async function continueGroupConversation(sessionId: number): Promise<TurnResult> {
|
|
2236
|
+
groupLoopStopped = false;
|
|
2237
|
+
let guard = 0;
|
|
2238
|
+
let failed = false;
|
|
2239
|
+
let stopped = false;
|
|
2240
|
+
|
|
2241
|
+
while (guard < 20) {
|
|
2242
|
+
if (groupLoopStopped) break;
|
|
2243
|
+
const session = await db.sessions.get(sessionId);
|
|
2244
|
+
if (!session) break;
|
|
2245
|
+
const players = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
2246
|
+
const { queue, loopIndex, loopStarted } = refreshQueue(session, players);
|
|
2247
|
+
if (loopStarted) {
|
|
2248
|
+
// 循环边界:把新循环的完整顺序写入历史(队首可能直接是 NPC,需先落库),
|
|
2249
|
+
// 并立刻刷新面板 → 新循环立即加入显示
|
|
2250
|
+
await persistQueue(sessionId, queue, loopIndex, true);
|
|
2251
|
+
await useStore.getState().refreshLiveQueue(sessionId);
|
|
2252
|
+
}
|
|
2253
|
+
if (queue.length === 0) {
|
|
2254
|
+
// 队列空 = 循环结束:刷新面板
|
|
2255
|
+
await refreshQueueAndSave(sessionId);
|
|
2256
|
+
break;
|
|
2257
|
+
}
|
|
2258
|
+
const nextId = parseInt(queue[0], 10);
|
|
2259
|
+
const next = players.find((p) => p.participantId === nextId);
|
|
2260
|
+
if (!next || next.kind !== 'NPC') {
|
|
2261
|
+
// 轮到玩家 → 等待输入,结束本轮
|
|
2262
|
+
await refreshQueueAndSave(sessionId);
|
|
2263
|
+
break;
|
|
2264
|
+
}
|
|
2265
|
+
// 面板实时指示:正在发言者 = 队列首位
|
|
2266
|
+
await useStore.getState().refreshLiveQueue(sessionId);
|
|
2267
|
+
const npc = next.npcId ? await db.npcs.get(next.npcId) : null;
|
|
2268
|
+
if (!npc) {
|
|
2269
|
+
await persistQueue(sessionId, completeTurn(queue, nextId, []), loopIndex, false);
|
|
2270
|
+
guard++;
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
// 先不推进队列:让流式发言期间队列首位 = 正在发言的角色(面板实时高亮)
|
|
2274
|
+
const turnResult = await streamAssistantTurn(session, npc, nextId, next, loopIndex);
|
|
2275
|
+
if (turnResult === 'failed') {
|
|
2276
|
+
failed = true;
|
|
2277
|
+
break;
|
|
2278
|
+
}
|
|
2279
|
+
const responseText = lastAssistantTextBySpeaker(sessionId, nextId);
|
|
2280
|
+
const mentioned = mentionedParticipantIds(responseText, players, nextId);
|
|
2281
|
+
if (session.mentionOnlyMode && mentioned.length === 0) {
|
|
2282
|
+
const player = players.find((participant) => participant.kind === 'PLAYER');
|
|
2283
|
+
const playerName = player?.displayName ?? translate('common.user');
|
|
2284
|
+
const message = [...(useStore.getState().messages[sessionId] ?? [])]
|
|
2285
|
+
.reverse()
|
|
2286
|
+
.find((candidate) => candidate.role === 'assistant' && candidate.speakerParticipantId === nextId);
|
|
2287
|
+
if (message?.id != null) {
|
|
2288
|
+
await db.messages.update(message.id, { displayHandoffMention: `@${playerName}` });
|
|
2289
|
+
await useStore.getState().loadMessages(sessionId);
|
|
2290
|
+
}
|
|
2291
|
+
// 未点名时自动将对话权交还给玩家。
|
|
2292
|
+
await persistQueue(sessionId, completeTurn(queue, nextId, [player?.participantId ?? -1]), loopIndex, false);
|
|
2293
|
+
} else {
|
|
2294
|
+
// 回合结束:移出该发言者 + 被 @ 点名者插入/移到队首(历史轮次不受影响)
|
|
2295
|
+
await persistQueue(sessionId, completeTurn(queue, nextId, mentioned), loopIndex, false);
|
|
2296
|
+
}
|
|
2297
|
+
if (turnResult === 'stopped') {
|
|
2298
|
+
stopped = true;
|
|
2299
|
+
break;
|
|
2300
|
+
}
|
|
2301
|
+
guard++;
|
|
2302
|
+
// 用户主动 stop 时中断
|
|
2303
|
+
if (groupLoopStopped) break;
|
|
2304
|
+
}
|
|
2305
|
+
if (!failed && !stopped && !groupLoopStopped) {
|
|
2306
|
+
const [settings, endpoint] = [useStore.getState().settings, await resolveActiveEndpoint()];
|
|
2307
|
+
if (settings && endpoint) {
|
|
2308
|
+
scheduleContextCompression(sessionId, settings, endpoint.baseUrl, endpoint.apiKey, endpoint.model);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
if (groupLoopStopped || stopped) return 'stopped';
|
|
2312
|
+
return failed ? 'failed' : 'ok';
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* 群聊中「编辑消息 / 重新生成」后的续跑:
|
|
2317
|
+
* 把被编辑的 assistant 发言人重新置顶当前队列(联动发言队列),
|
|
2318
|
+
* 再进入群聊主循环 —— 因此新回复会由「刚才被编辑的角色」生成,
|
|
2319
|
+
* 其余轮次按队列自然推进,队列面板历史也会同步更新。
|
|
2320
|
+
*
|
|
2321
|
+
* 参数 editedSpeaker 可为 null(如发言者已被删除 / 未知 speakerParticipantId),
|
|
2322
|
+
* 此时退化为普通 continueGroupConversation。
|
|
2323
|
+
*/
|
|
2324
|
+
async function continueRegeneratedGroupLoop(
|
|
2325
|
+
session: ChatSession,
|
|
2326
|
+
editedMessage: ChatMessage,
|
|
2327
|
+
editedSpeaker: ChatParticipant | null
|
|
2328
|
+
): Promise<TurnResult> {
|
|
2329
|
+
const sessionId = session.id!;
|
|
2330
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
2331
|
+
const speakerParticipantId = editedSpeaker?.participantId ?? editedMessage.speakerParticipantId;
|
|
2332
|
+
if (speakerParticipantId == null) {
|
|
2333
|
+
// 发言者不可映射 → 仅刷新队列并照常续跑
|
|
2334
|
+
await refreshQueueAndSave(sessionId);
|
|
2335
|
+
return continueGroupConversation(sessionId);
|
|
2336
|
+
}
|
|
2337
|
+
const { queue, loopIndex, loopStarted } = refreshQueue(session, participants);
|
|
2338
|
+
// 编辑后:该角色“重讲一遍” → 置顶队列,联动发言队列面板与下一轮顺序
|
|
2339
|
+
const nextQueue = requeueSpeaker(queue, speakerParticipantId);
|
|
2340
|
+
await persistQueue(sessionId, nextQueue, loopIndex, loopStarted);
|
|
2341
|
+
await useStore.getState().refreshLiveQueue(sessionId);
|
|
2342
|
+
return continueGroupConversation(sessionId);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
/** 挂起一个确认请求,直到用户在弹窗上应答(resolveConfirmation)。所有确认共用此唯一挂起点。 */
|
|
2346
|
+
function suspendConfirmation(req: ToolConfirmationRequest): Promise<boolean> {
|
|
2347
|
+
return new Promise((resolve) => {
|
|
2348
|
+
confirmationDeferreds.set(req, { resolve });
|
|
2349
|
+
useStore.setState({ pendingConfirmation: req });
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/** 外层白名单确认(修改/删除组,executeToolCall 之前按工具名拦截)。 */
|
|
2354
|
+
function requestToolConfirmation(
|
|
2355
|
+
sessionId: number,
|
|
2356
|
+
tc: { id: string; name: string; argumentsJson: string }
|
|
2357
|
+
): Promise<boolean> {
|
|
2358
|
+
// message 留空 → 弹窗回退到已本地化的 confirm.modify 文案(工具名由弹窗单独一行展示)
|
|
2359
|
+
return suspendConfirmation({
|
|
2360
|
+
sessionId,
|
|
2361
|
+
toolName: tc.name,
|
|
2362
|
+
title: translate('confirm.modifyTitle', { name: tc.name }),
|
|
2363
|
+
message: '',
|
|
2364
|
+
argsJson: tc.argumentsJson,
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/** shell 执行中确认(沙箱服务判定后透传 ToolConfirmationRequest)。 */
|
|
2369
|
+
function requestGeneratedToolConfirmation(
|
|
2370
|
+
sessionId: number,
|
|
2371
|
+
request: ToolConfirmationRequest
|
|
2372
|
+
): Promise<boolean> {
|
|
2373
|
+
return suspendConfirmation({ ...request, sessionId });
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
function requestLimitConfirmation(
|
|
2377
|
+
sessionId: number,
|
|
2378
|
+
reason: string,
|
|
2379
|
+
counters: {
|
|
2380
|
+
depth: number;
|
|
2381
|
+
toolCallCount: number;
|
|
2382
|
+
identicalToolCallCount: number;
|
|
2383
|
+
consecutiveToolFailures: number;
|
|
2384
|
+
}
|
|
2385
|
+
): Promise<boolean> {
|
|
2386
|
+
return suspendConfirmation({
|
|
2387
|
+
sessionId,
|
|
2388
|
+
toolName: 'agent_limit',
|
|
2389
|
+
title: translate('confirm.limitTitle'),
|
|
2390
|
+
message: translate('confirm.limitMessage', { reason }),
|
|
2391
|
+
argsJson: JSON.stringify({
|
|
2392
|
+
depth: counters.depth,
|
|
2393
|
+
tool_calls: counters.toolCallCount,
|
|
2394
|
+
identical_calls: counters.identicalToolCallCount,
|
|
2395
|
+
consecutive_failures: counters.consecutiveToolFailures,
|
|
2396
|
+
}),
|
|
2397
|
+
kind: 'limit',
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* 展示类工具(file_display)的结果会携带展示引用(DISPLAY_REF 前缀)。
|
|
2403
|
+
* 解析成功 → 返回序列化后的 DisplayFileRef(写入工具结果消息的 displayRef 字段,
|
|
2404
|
+
* 供对话流中的「查看」按钮回看);其他工具一律返回 null。
|
|
2405
|
+
*/
|
|
2406
|
+
function displayRefForToolName(toolName: string, result: string): string | null {
|
|
2407
|
+
if (toolName !== 'file_display') return null;
|
|
2408
|
+
const payload = parseDisplayRef(result);
|
|
2409
|
+
if (!payload) return null;
|
|
2410
|
+
// path 前面去展示前缀的剩余文本是给模型看的(留在 content 中)。
|
|
2411
|
+
// displayRef 只保存展示所需的最小信息;sessionId 用于回看时定位会话工作目录。
|
|
2412
|
+
const sessionId = useStore.getState().activeSessionId;
|
|
2413
|
+
return JSON.stringify({
|
|
2414
|
+
path: payload.path,
|
|
2415
|
+
kind: payload.kind,
|
|
2416
|
+
title: payload.title,
|
|
2417
|
+
sessionId,
|
|
2418
|
+
});
|
|
2419
|
+
}
|