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,647 @@
|
|
|
1
|
+
import Dexie, { type Table } from 'dexie';
|
|
2
|
+
import type {
|
|
3
|
+
ApiProvider,
|
|
4
|
+
AppSettings,
|
|
5
|
+
AchievementUnlock,
|
|
6
|
+
CareerNpcStat,
|
|
7
|
+
CareerStatsTotal,
|
|
8
|
+
ChatMessage,
|
|
9
|
+
ChatParticipant,
|
|
10
|
+
ChatSession,
|
|
11
|
+
ContextCompression,
|
|
12
|
+
McpTool,
|
|
13
|
+
NpcCharacter,
|
|
14
|
+
WorldBook,
|
|
15
|
+
AppLanguage,
|
|
16
|
+
} from '../types/models';
|
|
17
|
+
import { BUILTIN_TOOLS, ALL_BUILTIN_TOOL_NAMES } from '../core/toolDefinitions';
|
|
18
|
+
import { currentLanguage, translate } from '../core/i18n';
|
|
19
|
+
import BUILTIN_TAVERN_HANDBOOK_CONTENT from '../assets/tavern-handbook.md?raw';
|
|
20
|
+
|
|
21
|
+
export class TavernDB extends Dexie {
|
|
22
|
+
settings!: Table<AppSettings, number>;
|
|
23
|
+
providers!: Table<ApiProvider, number>;
|
|
24
|
+
npcs!: Table<NpcCharacter, number>;
|
|
25
|
+
sessions!: Table<ChatSession, number>;
|
|
26
|
+
participants!: Table<ChatParticipant, number>;
|
|
27
|
+
messages!: Table<ChatMessage, number>;
|
|
28
|
+
contextCompressions!: Table<ContextCompression, number>;
|
|
29
|
+
tools!: Table<McpTool, number>;
|
|
30
|
+
worldBooks!: Table<WorldBook, number>;
|
|
31
|
+
careerStats!: Table<CareerStatsTotal, number>;
|
|
32
|
+
careerNpcStats!: Table<CareerNpcStat, number>;
|
|
33
|
+
achievementUnlocks!: Table<AchievementUnlock, number>;
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
super('tavern-harness');
|
|
37
|
+
this.version(1).stores({
|
|
38
|
+
settings: 'id',
|
|
39
|
+
providers: '++id, name, isEnabled',
|
|
40
|
+
npcs: '++id, name, isBuiltIn',
|
|
41
|
+
sessions: '++id, mode, updatedAt, associatedId',
|
|
42
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
43
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
44
|
+
tools: '++id, name, isBuiltIn',
|
|
45
|
+
worldBooks: '++id, name',
|
|
46
|
+
careerStats: 'id',
|
|
47
|
+
careerNpcStats: 'npcId',
|
|
48
|
+
workspaceFiles: 'path, updatedAt',
|
|
49
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
50
|
+
});
|
|
51
|
+
// v2:sessions 增加 pinned 索引(置顶会话);存量记录的 pinned 在打开后归一化
|
|
52
|
+
this.version(2).stores({
|
|
53
|
+
settings: 'id',
|
|
54
|
+
providers: '++id, name, isEnabled',
|
|
55
|
+
npcs: '++id, name, isBuiltIn',
|
|
56
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned',
|
|
57
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
58
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
59
|
+
tools: '++id, name, isBuiltIn',
|
|
60
|
+
worldBooks: '++id, name',
|
|
61
|
+
careerStats: 'id',
|
|
62
|
+
careerNpcStats: 'npcId',
|
|
63
|
+
workspaceFiles: 'path, updatedAt',
|
|
64
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
65
|
+
});
|
|
66
|
+
// v3:移除已退役的定时消息表 tasks(升级时 Dexie 自动 deleteObjectStore('tasks'),物理清理旧库残留)
|
|
67
|
+
this.version(3).stores({
|
|
68
|
+
settings: 'id',
|
|
69
|
+
providers: '++id, name, isEnabled',
|
|
70
|
+
npcs: '++id, name, isBuiltIn',
|
|
71
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned',
|
|
72
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
73
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
74
|
+
tools: '++id, name, isBuiltIn',
|
|
75
|
+
worldBooks: '++id, name',
|
|
76
|
+
careerStats: 'id',
|
|
77
|
+
careerNpcStats: 'npcId',
|
|
78
|
+
workspaceFiles: 'path, updatedAt',
|
|
79
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
80
|
+
});
|
|
81
|
+
// v4:sessions 增加 workspaceDir 索引(会话专属沙箱工作目录,以会话 id 为名)
|
|
82
|
+
this.version(4).stores({
|
|
83
|
+
settings: 'id',
|
|
84
|
+
providers: '++id, name, isEnabled',
|
|
85
|
+
npcs: '++id, name, isBuiltIn',
|
|
86
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
87
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
88
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
89
|
+
tools: '++id, name, isBuiltIn',
|
|
90
|
+
worldBooks: '++id, name',
|
|
91
|
+
careerStats: 'id',
|
|
92
|
+
careerNpcStats: 'npcId',
|
|
93
|
+
workspaceFiles: 'path, updatedAt',
|
|
94
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
95
|
+
});
|
|
96
|
+
// v5:移除已由会话沙箱文件 API 取代的 workspaceFiles 表
|
|
97
|
+
this.version(5).stores({
|
|
98
|
+
settings: 'id',
|
|
99
|
+
providers: '++id, name, isEnabled',
|
|
100
|
+
npcs: '++id, name, isBuiltIn',
|
|
101
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
102
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
103
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
104
|
+
tools: '++id, name, isBuiltIn',
|
|
105
|
+
worldBooks: '++id, name',
|
|
106
|
+
careerStats: 'id',
|
|
107
|
+
careerNpcStats: 'npcId',
|
|
108
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
109
|
+
});
|
|
110
|
+
// v6:新增上下文压缩记录。
|
|
111
|
+
this.version(6).stores({
|
|
112
|
+
settings: 'id',
|
|
113
|
+
providers: '++id, name, isEnabled',
|
|
114
|
+
npcs: '++id, name, isBuiltIn',
|
|
115
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
116
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
117
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
118
|
+
contextCompressions: '++id, sessionId, endMessageId, createdAt',
|
|
119
|
+
tools: '++id, name, isBuiltIn',
|
|
120
|
+
worldBooks: '++id, name',
|
|
121
|
+
careerStats: 'id',
|
|
122
|
+
careerNpcStats: 'npcId',
|
|
123
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
124
|
+
});
|
|
125
|
+
// v7 已在本地运行,保留版本边界以避免 IndexedDB 降级导致 VersionError。
|
|
126
|
+
this.version(7).stores({
|
|
127
|
+
settings: 'id',
|
|
128
|
+
providers: '++id, name, isEnabled',
|
|
129
|
+
npcs: '++id, name, isBuiltIn',
|
|
130
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
131
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
132
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
133
|
+
contextCompressions: '++id, sessionId, endMessageId, createdAt',
|
|
134
|
+
tools: '++id, name, isBuiltIn',
|
|
135
|
+
worldBooks: '++id, name',
|
|
136
|
+
careerStats: 'id',
|
|
137
|
+
careerNpcStats: 'npcId',
|
|
138
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
139
|
+
});
|
|
140
|
+
this.version(8).stores({
|
|
141
|
+
settings: 'id',
|
|
142
|
+
providers: '++id, name, isEnabled',
|
|
143
|
+
npcs: '++id, name, isBuiltIn',
|
|
144
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
145
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
146
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
147
|
+
contextCompressions: '++id, sessionId, endMessageId, createdAt',
|
|
148
|
+
tools: '++id, name, isBuiltIn',
|
|
149
|
+
worldBooks: '++id, name',
|
|
150
|
+
careerStats: 'id',
|
|
151
|
+
careerNpcStats: 'npcId',
|
|
152
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
153
|
+
});
|
|
154
|
+
this.version(9).stores({
|
|
155
|
+
settings: 'id',
|
|
156
|
+
providers: '++id, name, isEnabled',
|
|
157
|
+
npcs: '++id, name, isBuiltIn',
|
|
158
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
159
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
160
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
161
|
+
contextCompressions: '++id, sessionId, endMessageId, createdAt',
|
|
162
|
+
tools: '++id, name, isBuiltIn',
|
|
163
|
+
worldBooks: '++id, name, isBuiltIn',
|
|
164
|
+
careerStats: 'id',
|
|
165
|
+
careerNpcStats: 'npcId',
|
|
166
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
167
|
+
});
|
|
168
|
+
this.version(10).stores({
|
|
169
|
+
settings: 'id',
|
|
170
|
+
providers: '++id, name, isEnabled',
|
|
171
|
+
npcs: '++id, name, isBuiltIn, isTavernKeeper',
|
|
172
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
173
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
174
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
175
|
+
contextCompressions: '++id, sessionId, endMessageId, createdAt',
|
|
176
|
+
tools: '++id, name, isBuiltIn',
|
|
177
|
+
worldBooks: '++id, name, isBuiltIn',
|
|
178
|
+
careerStats: 'id',
|
|
179
|
+
careerNpcStats: 'npcId',
|
|
180
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
181
|
+
});
|
|
182
|
+
// v11:sessions 增加仅 @ 模式设置(无需索引)。
|
|
183
|
+
this.version(11).stores({
|
|
184
|
+
settings: 'id',
|
|
185
|
+
providers: '++id, name, isEnabled',
|
|
186
|
+
npcs: '++id, name, isBuiltIn, isTavernKeeper',
|
|
187
|
+
sessions: '++id, mode, updatedAt, associatedId, pinned, workspaceDir',
|
|
188
|
+
participants: '[sessionId+participantId], sessionId, participantId',
|
|
189
|
+
messages: '++id, [sessionId+timestamp], sessionId, timestamp',
|
|
190
|
+
contextCompressions: '++id, sessionId, endMessageId, createdAt',
|
|
191
|
+
tools: '++id, name, isBuiltIn',
|
|
192
|
+
worldBooks: '++id, name, isBuiltIn',
|
|
193
|
+
careerStats: 'id',
|
|
194
|
+
careerNpcStats: 'npcId',
|
|
195
|
+
achievementUnlocks: '++id, achievementId, unlockedAt',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 打开数据库后立即执行:把 pinned 字段归一化为 0/1(旧记录为 undefined) */
|
|
200
|
+
normalizePinned(): Promise<void> {
|
|
201
|
+
return db.transaction('rw', db.sessions, async () => {
|
|
202
|
+
const sessions = await db.sessions.toArray();
|
|
203
|
+
for (const s of sessions) {
|
|
204
|
+
const v = (s as unknown as { pinned?: unknown }).pinned;
|
|
205
|
+
const pinned = v ? 1 : 0;
|
|
206
|
+
if ((s as unknown as { pinned?: unknown }).pinned !== pinned) {
|
|
207
|
+
await db.sessions.update(s.id!, { pinned });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const db = new TavernDB();
|
|
215
|
+
|
|
216
|
+
export { BUILTIN_TAVERN_HANDBOOK_CONTENT };
|
|
217
|
+
|
|
218
|
+
export function builtinTavernHandbookName(): string {
|
|
219
|
+
return translate('builtinWorldbook.name');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function isBuiltinTavernHandbook(book: Pick<WorldBook, 'name' | 'isBuiltIn'>): boolean {
|
|
223
|
+
return book.isBuiltIn === true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function isTavernKeeper(npc: Pick<NpcCharacter, 'isTavernKeeper'>): boolean {
|
|
227
|
+
return npc.isTavernKeeper === true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const DEFAULT_SETTINGS: AppSettings = {
|
|
231
|
+
id: 1,
|
|
232
|
+
baseUrl: 'https://api.openai.com/v1/',
|
|
233
|
+
apiKey: '',
|
|
234
|
+
defaultModel: '',
|
|
235
|
+
defaultProviderId: null,
|
|
236
|
+
themeMode: 'system',
|
|
237
|
+
language: null,
|
|
238
|
+
temperature: 1.0,
|
|
239
|
+
topP: 0.95,
|
|
240
|
+
maxTokens: 0,
|
|
241
|
+
topK: 20,
|
|
242
|
+
frequencyPenalty: 0,
|
|
243
|
+
presencePenalty: 0,
|
|
244
|
+
repetitionPenalty: 1,
|
|
245
|
+
reasoningEffort: 'auto',
|
|
246
|
+
contextCompressionThreshold: 'off',
|
|
247
|
+
seed: -1,
|
|
248
|
+
stop: '',
|
|
249
|
+
isStreaming: true,
|
|
250
|
+
isThinkingModeEnabled: true,
|
|
251
|
+
isToolCallsEnabled: true,
|
|
252
|
+
statsResetTime: null,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const DEFAULT_STATS: CareerStatsTotal = {
|
|
256
|
+
id: 1,
|
|
257
|
+
inputTokens: 0,
|
|
258
|
+
outputTokens: 0,
|
|
259
|
+
totalRounds: 0,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** 内置酒馆老板:默认启用所有内置技能(名称/人设/开场白随界面语言本地化) */
|
|
263
|
+
const DEFAULT_NPC: NpcCharacter = {
|
|
264
|
+
name: translate('builtinNpc.name'),
|
|
265
|
+
prompt: translate('builtinNpc.prompt'),
|
|
266
|
+
greeting: translate('builtinNpc.greeting'),
|
|
267
|
+
avatarColorOrdinal: 3,
|
|
268
|
+
avatarDataUrl: null,
|
|
269
|
+
enabledToolNames: [...ALL_BUILTIN_TOOL_NAMES],
|
|
270
|
+
isBuiltIn: true,
|
|
271
|
+
isTavernKeeper: true,
|
|
272
|
+
createdAt: Date.now(),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** 数据库初始化:种子数据 + 迁移兼容 */
|
|
276
|
+
export async function initDatabase(): Promise<void> {
|
|
277
|
+
const count = await db.settings.count();
|
|
278
|
+
if (count === 0) {
|
|
279
|
+
await db.settings.add(DEFAULT_SETTINGS);
|
|
280
|
+
} else {
|
|
281
|
+
const settings = await db.settings.get(1);
|
|
282
|
+
if (settings && settings.contextCompressionThreshold === undefined) {
|
|
283
|
+
await db.settings.update(1, { contextCompressionThreshold: 'off' });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const statsCount = await db.careerStats.count();
|
|
287
|
+
if (statsCount === 0) {
|
|
288
|
+
await db.careerStats.add(DEFAULT_STATS);
|
|
289
|
+
}
|
|
290
|
+
const npcCount = await db.npcs.count();
|
|
291
|
+
if (npcCount === 0) {
|
|
292
|
+
await db.npcs.add(DEFAULT_NPC);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 为旧数据中的唯一内置角色补充酒馆老板标识;多个内置角色时不猜测其身份。
|
|
296
|
+
const tavernKeeper = await db.npcs.filter(isTavernKeeper).first();
|
|
297
|
+
if (tavernKeeper && !tavernKeeper.isBuiltIn) {
|
|
298
|
+
await db.npcs.update(tavernKeeper.id!, { isBuiltIn: true });
|
|
299
|
+
} else if (!tavernKeeper) {
|
|
300
|
+
const builtinNpcs = await db.npcs.filter((n) => n.isBuiltIn).toArray();
|
|
301
|
+
if (builtinNpcs.length === 1) {
|
|
302
|
+
await db.npcs.update(builtinNpcs[0].id!, { isTavernKeeper: true });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// 内置技能改名迁移:display_file → file_display(须在 seedBuiltinTools 之前,
|
|
306
|
+
// 否则种子先写入 file_display、迁移再把旧 display_file 改同名,会产生重复记录)
|
|
307
|
+
await migrateDisplayFileRename();
|
|
308
|
+
await migrateBuiltinToolRenames();
|
|
309
|
+
// 退役内置技能(web_search / manage_timer)已从默认工具列表移除:清理旧库残留
|
|
310
|
+
await retireRemovedBuiltinTools();
|
|
311
|
+
// 预载内置技能表
|
|
312
|
+
await seedBuiltinTools();
|
|
313
|
+
// 旧数据兼容:enabledToolNames 可能是 CSV 字符串
|
|
314
|
+
await migrateLegacyFields();
|
|
315
|
+
// 补全技能 origin 字段(旧记录:内置 → builtin,其余 → custom)
|
|
316
|
+
await backfillToolOrigins();
|
|
317
|
+
// 内置角色「酒馆老板」默认启用所有内置技能(老数据升级)
|
|
318
|
+
await ensureBossDefaultSkills();
|
|
319
|
+
// 内置世界书始终存在,名称随当前界面语言本地化,内容可由用户编辑。
|
|
320
|
+
await localizeBuiltinTavernHandbook();
|
|
321
|
+
// v2 升级:把存量会话的 pinned 归一化为 0/1
|
|
322
|
+
await db.normalizePinned();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 内置角色「酒馆老板」文本本地化:按当前界面语言写入本地化名称/人设/开场白。
|
|
327
|
+
* 调用时机:store 应用语言设置(setLanguage)之后,启动时与切换语言时都会执行。
|
|
328
|
+
* 只把仍是内置原始文案(中文/繁体/英文种子之一)的字段替换为当前语言——
|
|
329
|
+
* 用户手动编辑过的内容不会被覆盖。
|
|
330
|
+
*/
|
|
331
|
+
export async function localizeBuiltinNpc(): Promise<void> {
|
|
332
|
+
const boss = await db.npcs.filter(isTavernKeeper).first();
|
|
333
|
+
if (!boss) return;
|
|
334
|
+
|
|
335
|
+
const lang = currentLanguage();
|
|
336
|
+
const prevLang = localStorage.getItem(LOCALIZE_LANG_KEY) as AppLanguage | null;
|
|
337
|
+
// 语言未变化,无需处理
|
|
338
|
+
if (prevLang === lang) return;
|
|
339
|
+
|
|
340
|
+
const isPristine = (v: string) => {
|
|
341
|
+
const s = v.trim();
|
|
342
|
+
return (
|
|
343
|
+
s === '酒馆老板' || s === '神秘的酒馆老板,可以响应客人的任何需求' || s === '你来啦!快坐下~' ||
|
|
344
|
+
s === '酒館老闆' || s === '神秘的酒館老闆,可以回應客人的任何需求' || s === '你來啦!快坐下~' ||
|
|
345
|
+
s === 'Tavern Keeper' || s === 'A mysterious tavern keeper who can fulfill any request from guests' || s === 'Welcome! Have a seat~'
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
const tName = translate('builtinNpc.name');
|
|
349
|
+
const tPrompt = translate('builtinNpc.prompt');
|
|
350
|
+
const tGreeting = translate('builtinNpc.greeting');
|
|
351
|
+
|
|
352
|
+
const updates: Partial<NpcCharacter> = {};
|
|
353
|
+
if (isPristine(boss.name)) updates.name = tName;
|
|
354
|
+
if (isPristine(boss.prompt || '')) updates.prompt = tPrompt;
|
|
355
|
+
if (isPristine(boss.greeting || '')) updates.greeting = tGreeting;
|
|
356
|
+
if (Object.keys(updates).length > 0) {
|
|
357
|
+
await db.npcs.update(boss.id!, updates);
|
|
358
|
+
// 会话标题 / 最后一条消息 / 参与者 / 消息里的旧内置文案一并更新(仅限仍是内置原文的记录)
|
|
359
|
+
const sessions = await db.sessions.toArray();
|
|
360
|
+
for (const s of sessions) {
|
|
361
|
+
const changes: Partial<ChatSession> = {};
|
|
362
|
+
if (s.title === '酒馆老板' || s.title === '酒館老闆' || s.title === 'Tavern Keeper') {
|
|
363
|
+
changes.title = tName;
|
|
364
|
+
}
|
|
365
|
+
if (s.lastMessage && isPristine(s.lastMessage)) {
|
|
366
|
+
changes.lastMessage = tGreeting;
|
|
367
|
+
}
|
|
368
|
+
if (Object.keys(changes).length > 0) {
|
|
369
|
+
await db.sessions.update(s.id!, changes);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// 参与者 / 消息里的旧内置文案一并更新(仅限仍是内置原文的记录)
|
|
373
|
+
const participants = await db.participants.toArray();
|
|
374
|
+
for (const p of participants) {
|
|
375
|
+
if (
|
|
376
|
+
p.kind === 'NPC' &&
|
|
377
|
+
p.npcId === boss.id &&
|
|
378
|
+
(p.displayName === '酒馆老板' || p.displayName === '酒館老闆' || p.displayName === 'Tavern Keeper')
|
|
379
|
+
) {
|
|
380
|
+
await db.participants.update(p, { displayName: tName });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const messages = await db.messages.toArray();
|
|
384
|
+
for (const m of messages) {
|
|
385
|
+
if (
|
|
386
|
+
m.speakerParticipantId === boss.id &&
|
|
387
|
+
(m.speakerName === '酒馆老板' || m.speakerName === '酒館老闆' || m.speakerName === 'Tavern Keeper')
|
|
388
|
+
) {
|
|
389
|
+
await db.messages.update(m.id!, { speakerName: tName });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (prevLang !== lang) {
|
|
395
|
+
localStorage.setItem(LOCALIZE_LANG_KEY, lang);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 内置世界书:识别任意已知语言名称,避免升级或语言切换时创建重复条目。 */
|
|
400
|
+
export async function localizeBuiltinTavernHandbook(): Promise<void> {
|
|
401
|
+
const book = await db.worldBooks.filter(isBuiltinTavernHandbook).first();
|
|
402
|
+
const name = builtinTavernHandbookName();
|
|
403
|
+
if (book) {
|
|
404
|
+
if (!book.isBuiltIn || book.name !== name) await db.worldBooks.update(book.id!, { isBuiltIn: true, name });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
await db.worldBooks.add({
|
|
408
|
+
name,
|
|
409
|
+
content: BUILTIN_TAVERN_HANDBOOK_CONTENT,
|
|
410
|
+
imageUri: null,
|
|
411
|
+
isBuiltIn: true,
|
|
412
|
+
createdAt: Date.now(),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 内置角色本地化标记:记录上次写入内置角色文本时使用的语言 */
|
|
417
|
+
const LOCALIZE_LANG_KEY = 'th-builtin-npc-lang';
|
|
418
|
+
|
|
419
|
+
/** 已退役的内置技能:从默认工具列表中移除后,旧库里的记录不再被 seed 覆盖同步。
|
|
420
|
+
* 迁移时删除工具记录,并从 NPC 启用列表里剔除(历史消息中的工具调用保留原文,仅作展示)。 */
|
|
421
|
+
const RETIRED_BUILTIN_TOOL_NAMES = ['web_search', 'manage_timer'];
|
|
422
|
+
|
|
423
|
+
const RENAMED_BUILTIN_TOOLS: Record<string, string> = {
|
|
424
|
+
get_tavern_status: 'get_tavern_info',
|
|
425
|
+
create_world_book: 'create_lorebook',
|
|
426
|
+
update_world_book: 'update_lorebook',
|
|
427
|
+
delete_world_book: 'delete_lorebook',
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
async function migrateBuiltinToolRenames(): Promise<void> {
|
|
431
|
+
for (const [oldName, newName] of Object.entries(RENAMED_BUILTIN_TOOLS)) {
|
|
432
|
+
const oldTools = (await db.tools.where('name').equals(oldName).toArray()).filter((tool) => tool.isBuiltIn);
|
|
433
|
+
const existingNew = await db.tools.where('name').equals(newName).first();
|
|
434
|
+
for (const oldTool of oldTools) {
|
|
435
|
+
if (existingNew) {
|
|
436
|
+
await db.tools.delete(oldTool.id!);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
let jsonContent = oldTool.jsonContent;
|
|
440
|
+
try {
|
|
441
|
+
const parsed = JSON.parse(jsonContent) as { function?: { name?: string } };
|
|
442
|
+
if (parsed.function?.name === oldName) {
|
|
443
|
+
parsed.function.name = newName;
|
|
444
|
+
jsonContent = JSON.stringify(parsed);
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
/* seedBuiltinTools will replace malformed built-in definitions */
|
|
448
|
+
}
|
|
449
|
+
await db.tools.update(oldTool.id!, { name: newName, jsonContent });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const npcs = await db.npcs.toArray();
|
|
454
|
+
for (const npc of npcs) {
|
|
455
|
+
if (!Array.isArray(npc.enabledToolNames)) continue;
|
|
456
|
+
const renamed = npc.enabledToolNames.map((name) => RENAMED_BUILTIN_TOOLS[name] ?? name);
|
|
457
|
+
if (renamed.some((name, index) => name !== npc.enabledToolNames[index])) {
|
|
458
|
+
await db.npcs.update(npc.id!, { enabledToolNames: [...new Set(renamed)] });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const messages = await db.messages.toArray();
|
|
463
|
+
for (const message of messages) {
|
|
464
|
+
if (!message.toolCallsJson || !Object.keys(RENAMED_BUILTIN_TOOLS).some((name) => message.toolCallsJson.includes(`"${name}"`))) continue;
|
|
465
|
+
try {
|
|
466
|
+
const calls = JSON.parse(message.toolCallsJson) as Array<{ name?: string }>;
|
|
467
|
+
let changed = false;
|
|
468
|
+
for (const call of calls) {
|
|
469
|
+
if (call?.name && RENAMED_BUILTIN_TOOLS[call.name]) {
|
|
470
|
+
call.name = RENAMED_BUILTIN_TOOLS[call.name];
|
|
471
|
+
changed = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (changed) await db.messages.update(message.id!, { toolCallsJson: JSON.stringify(calls) });
|
|
475
|
+
} catch {
|
|
476
|
+
/* Preserve malformed historical payloads unchanged. */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function retireRemovedBuiltinTools(): Promise<void> {
|
|
482
|
+
for (const name of RETIRED_BUILTIN_TOOL_NAMES) {
|
|
483
|
+
// 删除内置工具记录(仅限 isBuiltIn 的内置记录,同名自定义技能不删)
|
|
484
|
+
const existing = await db.tools.where('name').equals(name).toArray();
|
|
485
|
+
for (const t of existing) {
|
|
486
|
+
if (t.isBuiltIn) await db.tools.delete(t.id!);
|
|
487
|
+
}
|
|
488
|
+
// 从 NPC 启用列表剔除
|
|
489
|
+
const npcs = await db.npcs.toArray();
|
|
490
|
+
for (const n of npcs) {
|
|
491
|
+
if (Array.isArray(n.enabledToolNames) && n.enabledToolNames.includes(name)) {
|
|
492
|
+
await db.npcs.update(n.id!, {
|
|
493
|
+
enabledToolNames: n.enabledToolNames.filter((s) => s !== name),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 内置技能(只读保护)第一次使用时写库,已存在的同步更新 schema */
|
|
501
|
+
async function seedBuiltinTools(): Promise<void> {
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
for (const tool of BUILTIN_TOOLS) {
|
|
504
|
+
const name = tool.function.name;
|
|
505
|
+
const exists = await db.tools.where('name').equals(name).first();
|
|
506
|
+
if (exists) {
|
|
507
|
+
if (exists.isBuiltIn) {
|
|
508
|
+
// 已存在的内置技能:同步最新定义
|
|
509
|
+
await db.tools.update(exists.id!, { jsonContent: JSON.stringify(tool) });
|
|
510
|
+
} else {
|
|
511
|
+
// 同名自定义技能 → 升级为内置(新增内置技能覆盖旧自定义记录)
|
|
512
|
+
await db.tools.update(exists.id!, {
|
|
513
|
+
jsonContent: JSON.stringify(tool),
|
|
514
|
+
executionJson: null,
|
|
515
|
+
isBuiltIn: true,
|
|
516
|
+
origin: 'builtin',
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
await db.tools.add({
|
|
522
|
+
name,
|
|
523
|
+
jsonContent: JSON.stringify(tool),
|
|
524
|
+
executionJson: null,
|
|
525
|
+
isBuiltIn: true,
|
|
526
|
+
origin: 'builtin',
|
|
527
|
+
createdAt: now,
|
|
528
|
+
displayOrder: await db.tools.count(),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 内置角色「酒馆老板」默认启用全部内置技能(老数据只启用了 roll_dice 等) */
|
|
534
|
+
async function ensureBossDefaultSkills(): Promise<void> {
|
|
535
|
+
const boss = await db.npcs.filter(isTavernKeeper).first();
|
|
536
|
+
if (!boss) return;
|
|
537
|
+
const enabled = new Set(Array.isArray(boss.enabledToolNames) ? boss.enabledToolNames : []);
|
|
538
|
+
let changed = false;
|
|
539
|
+
for (const name of ALL_BUILTIN_TOOL_NAMES) {
|
|
540
|
+
if (!enabled.has(name)) {
|
|
541
|
+
enabled.add(name);
|
|
542
|
+
changed = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (changed) {
|
|
546
|
+
await db.npcs.update(boss.id!, { enabledToolNames: [...enabled] });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function migrateLegacyFields(): Promise<void> {
|
|
551
|
+
const npcs = await db.npcs.toArray();
|
|
552
|
+
for (const npc of npcs) {
|
|
553
|
+
const v = npc as unknown as { enabledToolNames?: unknown };
|
|
554
|
+
if (v.enabledToolNames != null && typeof v.enabledToolNames !== 'object') {
|
|
555
|
+
await db.npcs.update(npc.id!, {
|
|
556
|
+
enabledToolNames: String(v.enabledToolNames).split(',').filter(Boolean),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 补全历史技能的 origin 字段(origin 加入前创建的工具没有该字段):
|
|
564
|
+
* 内置技能 → 'builtin';其余旧工具无法追溯来源,一律归为 'custom'。
|
|
565
|
+
*/
|
|
566
|
+
async function backfillToolOrigins(): Promise<void> {
|
|
567
|
+
const tools = await db.tools.toArray();
|
|
568
|
+
for (const t of tools) {
|
|
569
|
+
if (t.origin) continue;
|
|
570
|
+
await db.tools.update(t.id!, { origin: t.isBuiltIn ? 'builtin' : 'custom' });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 内置技能改名/去重迁移:display_file → file_display。
|
|
576
|
+
* 处理两种历史遗留:
|
|
577
|
+
* A. 旧内置 display_file 尚未改名 → 改名(若 file_display 已存在则直接删除旧记录)。
|
|
578
|
+
* B. 库中已有重复的 file_display(改名迁移曾被中断/重复执行,或旧版与新代码
|
|
579
|
+
* 各播种过一次)→ 保留一条(displayOrder 最小者),删除其余内置重复项。
|
|
580
|
+
* 同时更新 npcs.enabledToolNames 与 messages.toolCallsJson 里的技能名引用,
|
|
581
|
+
* 保证老数据升级后新名字下的路由/展示/回看全部生效。
|
|
582
|
+
*/
|
|
583
|
+
async function migrateDisplayFileRename(): Promise<void> {
|
|
584
|
+
const OLD = 'display_file';
|
|
585
|
+
const NEW = 'file_display';
|
|
586
|
+
|
|
587
|
+
// A. display_file → file_display(幂等:绝不产生两条 file_display)
|
|
588
|
+
const oldTool = await db.tools.where('name').equals(OLD).first();
|
|
589
|
+
if (oldTool && oldTool.isBuiltIn) {
|
|
590
|
+
const newTool = await db.tools.where('name').equals(NEW).first();
|
|
591
|
+
if (newTool && newTool.isBuiltIn) {
|
|
592
|
+
await db.tools.delete(oldTool.id!);
|
|
593
|
+
} else {
|
|
594
|
+
let jsonContent = oldTool.jsonContent;
|
|
595
|
+
try {
|
|
596
|
+
const parsed = JSON.parse(jsonContent) as { function?: { name?: string } };
|
|
597
|
+
if (parsed?.function?.name === OLD) {
|
|
598
|
+
parsed.function.name = NEW;
|
|
599
|
+
jsonContent = JSON.stringify(parsed);
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
/* 保持原样,seedBuiltinTools 会重写 */
|
|
603
|
+
}
|
|
604
|
+
await db.tools.update(oldTool.id!, { name: NEW, jsonContent });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// B. 已污染的库:多条内置 file_display → 保留 displayOrder 最小的一条,删除其余
|
|
609
|
+
const newTools = await db.tools.where('name').equals(NEW).toArray();
|
|
610
|
+
if (newTools.length > 1) {
|
|
611
|
+
const builtins = newTools.filter((t) => t.isBuiltIn);
|
|
612
|
+
const keep = [...builtins].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0))[0];
|
|
613
|
+
for (const dup of builtins) {
|
|
614
|
+
if (dup.id !== keep.id) await db.tools.delete(dup.id!);
|
|
615
|
+
}
|
|
616
|
+
// 非内置的重复项(用户自定义同名)不删,避免误删用户数据
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 2. NPC 启用的技能名
|
|
620
|
+
const npcs = await db.npcs.toArray();
|
|
621
|
+
for (const n of npcs) {
|
|
622
|
+
if (Array.isArray(n.enabledToolNames) && n.enabledToolNames.includes(OLD)) {
|
|
623
|
+
await db.npcs.update(n.id!, {
|
|
624
|
+
enabledToolNames: n.enabledToolNames.map((s) => (s === OLD ? NEW : s)),
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 3. 历史消息中的工具调用名
|
|
630
|
+
const messages = await db.messages.toArray();
|
|
631
|
+
for (const m of messages) {
|
|
632
|
+
if (!m.toolCallsJson || !m.toolCallsJson.includes(`"${OLD}"`)) continue;
|
|
633
|
+
try {
|
|
634
|
+
const calls = JSON.parse(m.toolCallsJson) as Array<{ name?: string }>;
|
|
635
|
+
let changed = false;
|
|
636
|
+
for (const c of calls) {
|
|
637
|
+
if (c && c.name === OLD) {
|
|
638
|
+
c.name = NEW;
|
|
639
|
+
changed = true;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (changed) await db.messages.update(m.id!, { toolCallsJson: JSON.stringify(calls) });
|
|
643
|
+
} catch {
|
|
644
|
+
/* 坏 JSON 不处理 */
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import { initDatabase } from './db/database';
|
|
5
|
+
import { applyThemeFromCache } from './theme/theme';
|
|
6
|
+
import { enableHorizontalWheel } from './core/horizontalWheel';
|
|
7
|
+
import './theme/global.css';
|
|
8
|
+
|
|
9
|
+
async function bootstrap() {
|
|
10
|
+
// 首帧同步应用主题(读 localStorage 缓存),避免刷新闪动
|
|
11
|
+
applyThemeFromCache();
|
|
12
|
+
await initDatabase();
|
|
13
|
+
// 全局:支持 .side-nav 等横向滚动容器响应鼠标滚轮(垂直 → 水平)
|
|
14
|
+
enableHorizontalWheel();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
bootstrap().then(() => {
|
|
18
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
19
|
+
<React.StrictMode>
|
|
20
|
+
<App />
|
|
21
|
+
</React.StrictMode>
|
|
22
|
+
);
|
|
23
|
+
});
|