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,67 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 文件下载(支持用户选择保存位置与文件名)
|
|
3
|
+
// 返回:'saved' 已保存到用户选择的位置;'downloaded' 已下载到默认目录;
|
|
4
|
+
// 'canceled' 用户取消(不产生任何文件)
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
export type SaveResult = 'saved' | 'downloaded' | 'canceled';
|
|
8
|
+
|
|
9
|
+
async function pickSaveFileHandle(suggestedName: string): Promise<FileSystemFileHandle | 'canceled' | null> {
|
|
10
|
+
// 仅在安全上下文(localhost / https)下可用
|
|
11
|
+
if (typeof (window as unknown as { showSaveFilePicker?: unknown }).showSaveFilePicker !== 'function') {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return await (window as unknown as {
|
|
16
|
+
showSaveFilePicker: (opts: {
|
|
17
|
+
suggestedName: string;
|
|
18
|
+
types: Array<{ description: string; accept: Record<string, string[]> }>;
|
|
19
|
+
}) => Promise<FileSystemFileHandle>;
|
|
20
|
+
}).showSaveFilePicker({
|
|
21
|
+
suggestedName,
|
|
22
|
+
types: [
|
|
23
|
+
{
|
|
24
|
+
description: 'Text Files',
|
|
25
|
+
accept: { 'text/plain': ['.txt', '.json', '.md', '.csv', '.log'] },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
} catch (e) {
|
|
30
|
+
// 用户主动取消(Esc / 点取消)→ 不产生任何文件
|
|
31
|
+
if ((e as Error).name === 'AbortError') return 'canceled';
|
|
32
|
+
// 其余异常(如浏览器不支持)→ 回退默认下载目录
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 保存文本内容:优先调用系统「另存为」对话框让用户选择位置与文件名;
|
|
38
|
+
* 不可用时回退为浏览器默认下载目录。用户取消时不产生任何文件。 */
|
|
39
|
+
export async function saveTextFile(content: string, mime: string, suggestedName: string): Promise<SaveResult> {
|
|
40
|
+
const handle = await pickSaveFileHandle(suggestedName);
|
|
41
|
+
if (handle === 'canceled') return 'canceled';
|
|
42
|
+
if (handle) {
|
|
43
|
+
try {
|
|
44
|
+
const writable = await handle.createWritable();
|
|
45
|
+
await writable.write(content);
|
|
46
|
+
await writable.close();
|
|
47
|
+
return 'saved';
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// 写入失败(如权限被拒)→ 回退默认下载
|
|
50
|
+
console.warn('saveTextFile: File System Access API 写入失败,回退下载', e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 回退:浏览器默认下载目录 + 建议文件名
|
|
54
|
+
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
|
55
|
+
const url = URL.createObjectURL(blob);
|
|
56
|
+
const a = document.createElement('a');
|
|
57
|
+
a.href = url;
|
|
58
|
+
a.download = suggestedName;
|
|
59
|
+
a.click();
|
|
60
|
+
URL.revokeObjectURL(url);
|
|
61
|
+
return 'downloaded';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 保存 JSON 对象为文件(便于各导出入口统一调用) */
|
|
65
|
+
export function saveJsonFile(payload: unknown, suggestedName: string): Promise<SaveResult> {
|
|
66
|
+
return saveTextFile(JSON.stringify(payload, null, 2), 'application/json', suggestedName);
|
|
67
|
+
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 游戏导出 / 导入(完全重建)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// 把一次「游戏局」完整导出为独立 JSON 文件:
|
|
5
|
+
// - 会话设置(模式 / 标题 / 世界书关联 / 用户人设 / 发言顺序 / 开场白开关 / 队列&循环历史)
|
|
6
|
+
// - 全部参与 NPC(角色卡:人设 / 开场白 / 头像 / 启用的技能)
|
|
7
|
+
// - 全部涉及的工具(生成式技能:OpenAI tool JSON + 声明式实现)
|
|
8
|
+
// - 对话历史(可选):消息 / 参与者 / 队列与循环索引完全复原
|
|
9
|
+
// 导入时反向重建:先建工具 → 再建角色与回调映射 → 再建世界书(会话引用其新 id)→
|
|
10
|
+
// 再建会话与参与者 → 最后批量写入消息(保留顺序与相对间隔)。
|
|
11
|
+
// 幂等:技能 / 角色 / 世界书 / 用户人设不与现有对象合并——名称冲突时创建「 (2)」副本,
|
|
12
|
+
// 绝不覆盖用户已有数据;会话始终新建。
|
|
13
|
+
// 格式版本号:1
|
|
14
|
+
// ============================================================
|
|
15
|
+
|
|
16
|
+
import { db } from '../db/database';
|
|
17
|
+
import type {
|
|
18
|
+
ChatMessage,
|
|
19
|
+
ChatParticipant,
|
|
20
|
+
ChatSession,
|
|
21
|
+
McpTool,
|
|
22
|
+
NpcCharacter,
|
|
23
|
+
WorldBook,
|
|
24
|
+
} from '../types/models';
|
|
25
|
+
import { saveJsonFile, type SaveResult } from './fileDownload';
|
|
26
|
+
import { createSessionWorkspaceDir, ensureSessionWorkspaceDir } from './tools/generatedSkillExecutor';
|
|
27
|
+
import { seedOpeningGreeting } from './openingGreeting';
|
|
28
|
+
|
|
29
|
+
const GAMEPLAY_EXPORT_VERSION = 1;
|
|
30
|
+
const GAMEPLAY_FORMAT = 'tavern-harness-gameplay';
|
|
31
|
+
|
|
32
|
+
/** 参与会话的角色:角色卡全量快照 + 会话内显示名 */
|
|
33
|
+
interface GameplayNpc {
|
|
34
|
+
npc: NpcCharacter;
|
|
35
|
+
/** 会话参与者表中的显示名(可能与会话创建后角色改名不同) */
|
|
36
|
+
sessionDisplayName: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 游戏导出文件结构 */
|
|
40
|
+
interface GameplayExportPayload {
|
|
41
|
+
format: typeof GAMEPLAY_FORMAT;
|
|
42
|
+
version: number;
|
|
43
|
+
exportedAt: number;
|
|
44
|
+
appName: 'Tavern Harness';
|
|
45
|
+
title: string;
|
|
46
|
+
includeHistory: boolean;
|
|
47
|
+
/** 会话设置(无 id 与 lastMessage;队列/循环历史按参与者旧 id 记录) */
|
|
48
|
+
session: Omit<ChatSession, 'id' | 'lastMessage'>;
|
|
49
|
+
/** 会话内参与记录(源 participantId → 席位/显示名,玩家恒定 -1) */
|
|
50
|
+
sessionParticipants: ChatParticipant[];
|
|
51
|
+
/** 参与会话的角色卡快照(含会话内显示名) */
|
|
52
|
+
npcs: GameplayNpc[];
|
|
53
|
+
/** 对话历史消息(仅重建所需字段,id 在导入时重建) */
|
|
54
|
+
messages: ReturnType<typeof sanitizeMessage>[];
|
|
55
|
+
/** 会话引用的世界书(worldBookId 关联) */
|
|
56
|
+
worldBook?: WorldBook;
|
|
57
|
+
/** 会话引用的用户人设(userPersonaNpcId 关联) */
|
|
58
|
+
userPersona?: NpcCharacter;
|
|
59
|
+
/** 会话涉及的生成式技能(NPC 启用列表 ∪ 历史消息工具记录;不含内置技能) */
|
|
60
|
+
tools: McpTool[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// 内部帮助函数
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async function loadMessages(sessionId: number): Promise<ChatMessage[]> {
|
|
68
|
+
return db.messages.where('sessionId').equals(sessionId).sortBy('timestamp');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 从会话历史消息中收集实际出现过的工具调用名 */
|
|
72
|
+
function collectToolNamesFromMessages(messages: ChatMessage[]): Set<string> {
|
|
73
|
+
const names = new Set<string>();
|
|
74
|
+
for (const m of messages) {
|
|
75
|
+
if (m.role !== 'assistant') continue;
|
|
76
|
+
try {
|
|
77
|
+
const calls = JSON.parse(m.toolCallsJson || '[]') as Array<{ name?: string }>;
|
|
78
|
+
for (const c of calls) if (c.name) names.add(c.name);
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 不影响重建对话的字段:性能 / 调试 / 运行时细节,导出时直接移除
|
|
87
|
+
* (toolCallsJson 必须保留:工具调用卡片与工具结果关联都依赖它) */
|
|
88
|
+
function sanitizeMessage(m: ChatMessage) {
|
|
89
|
+
const {
|
|
90
|
+
sessionId: _sessionId,
|
|
91
|
+
latencyMs: _latencyMs,
|
|
92
|
+
promptTokens: _promptTokens,
|
|
93
|
+
completionTokens: _completionTokens,
|
|
94
|
+
totalTokens: _totalTokens,
|
|
95
|
+
tokensPerSec: _tokensPerSec,
|
|
96
|
+
modelUsed: _modelUsed,
|
|
97
|
+
attachmentInfos: _attachmentInfos,
|
|
98
|
+
rawRequestBody: _rawRequestBody,
|
|
99
|
+
rawResponseBody: _rawResponseBody,
|
|
100
|
+
...rest
|
|
101
|
+
} = m;
|
|
102
|
+
void _sessionId;
|
|
103
|
+
void _latencyMs;
|
|
104
|
+
void _promptTokens;
|
|
105
|
+
void _completionTokens;
|
|
106
|
+
void _totalTokens;
|
|
107
|
+
void _tokensPerSec;
|
|
108
|
+
void _modelUsed;
|
|
109
|
+
void _attachmentInfos;
|
|
110
|
+
void _rawRequestBody;
|
|
111
|
+
void _rawResponseBody;
|
|
112
|
+
return rest;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sanitizeSession(session: ChatSession): Omit<ChatSession, 'id' | 'lastMessage'> {
|
|
116
|
+
const { id: _id, lastMessage: _last, ...rest } = session;
|
|
117
|
+
void _id;
|
|
118
|
+
void _last;
|
|
119
|
+
return rest;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function safeFileName(title: string): string {
|
|
123
|
+
return (title.replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 40) || 'gameplay') + '-gameplay.json';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 队列 JSON 里的参与者 id 是字符串('-1' = 玩家,其余为 NPC 的 participantId)。
|
|
127
|
+
* 导入后 NPC 的 participantId 变为新 npc id,因此逐 token 替换队列字符串。 */
|
|
128
|
+
function remapQueueJson(value: string, map: Map<number, number>): string {
|
|
129
|
+
if (!value || map.size === 0) return value;
|
|
130
|
+
return value.replace(/"(-?\d+)"/g, (match, raw: string) => {
|
|
131
|
+
const num = Number(raw);
|
|
132
|
+
if (Number.isInteger(num)) {
|
|
133
|
+
const mapped = map.get(num);
|
|
134
|
+
if (mapped != null) return `"${mapped}"`;
|
|
135
|
+
}
|
|
136
|
+
return match;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 生成一个不与现有条目重名的名称:重名时追加「 (2)」「 (3)」… */
|
|
141
|
+
async function uniqueName(
|
|
142
|
+
table: 'npcs' | 'worldBooks' | 'tools',
|
|
143
|
+
name: string
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const count = (n: string) =>
|
|
146
|
+
db[table].where('name').equals(n).count();
|
|
147
|
+
if ((await count(name)) === 0) return name;
|
|
148
|
+
let i = 2;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const candidate = `${name} (${i})`;
|
|
151
|
+
if ((await count(candidate)) === 0) return candidate;
|
|
152
|
+
i += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// 导出
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
interface ExportGameplayOptions {
|
|
161
|
+
sessionId: number;
|
|
162
|
+
includeHistory: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 导出会话游戏为 JSON 文件(弹出保存对话框)。
|
|
167
|
+
* 返回:'saved' 保存成功 / 'downloaded' 已下载到默认目录 / 'canceled' 用户取消
|
|
168
|
+
*/
|
|
169
|
+
export async function exportGameplay({ sessionId, includeHistory }: ExportGameplayOptions): Promise<SaveResult> {
|
|
170
|
+
const session = await db.sessions.get(sessionId);
|
|
171
|
+
if (!session) throw new Error('会话不存在');
|
|
172
|
+
|
|
173
|
+
const participants = await db.participants.where('sessionId').equals(sessionId).toArray();
|
|
174
|
+
const messages = includeHistory ? await loadMessages(sessionId) : [];
|
|
175
|
+
|
|
176
|
+
// 参与角色(按座位顺序)
|
|
177
|
+
const npcIds = participants
|
|
178
|
+
.filter((p) => p.kind === 'NPC' && p.npcId != null)
|
|
179
|
+
.sort((a, b) => a.seatOrder - b.seatOrder)
|
|
180
|
+
.map((p) => p.npcId!);
|
|
181
|
+
if (session.associatedId != null && !npcIds.includes(session.associatedId)) {
|
|
182
|
+
npcIds.unshift(session.associatedId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const npcs: GameplayNpc[] = [];
|
|
186
|
+
for (const npcId of npcIds) {
|
|
187
|
+
const npc = await db.npcs.get(npcId);
|
|
188
|
+
if (!npc) continue;
|
|
189
|
+
const participant = participants.find((p) => p.kind === 'NPC' && p.npcId === npcId);
|
|
190
|
+
npcs.push({ npc, sessionDisplayName: participant?.displayName ?? npc.name });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const worldBook = session.worldBookId != null
|
|
194
|
+
? (await db.worldBooks.get(session.worldBookId)) ?? undefined
|
|
195
|
+
: undefined;
|
|
196
|
+
const userPersona = session.userPersonaNpcId != null
|
|
197
|
+
? (await db.npcs.get(session.userPersonaNpcId)) ?? undefined
|
|
198
|
+
: undefined;
|
|
199
|
+
|
|
200
|
+
// 涉及技能:NPC 启用列表 ∪ 历史消息工具调用
|
|
201
|
+
const toolNames = new Set<string>();
|
|
202
|
+
for (const n of npcs) for (const name of n.npc.enabledToolNames) toolNames.add(name);
|
|
203
|
+
for (const name of collectToolNamesFromMessages(messages)) toolNames.add(name);
|
|
204
|
+
|
|
205
|
+
const allTools = await db.tools.toArray();
|
|
206
|
+
const tools = allTools
|
|
207
|
+
.filter((t) => !t.isBuiltIn && toolNames.has(t.name))
|
|
208
|
+
.map((t) => stripToolId(t));
|
|
209
|
+
|
|
210
|
+
const payload: GameplayExportPayload = {
|
|
211
|
+
format: GAMEPLAY_FORMAT,
|
|
212
|
+
version: GAMEPLAY_EXPORT_VERSION,
|
|
213
|
+
exportedAt: Date.now(),
|
|
214
|
+
appName: 'Tavern Harness',
|
|
215
|
+
title: session.title,
|
|
216
|
+
includeHistory,
|
|
217
|
+
session: sanitizeSession(session),
|
|
218
|
+
sessionParticipants: participants,
|
|
219
|
+
messages: messages.map(sanitizeMessage),
|
|
220
|
+
npcs,
|
|
221
|
+
worldBook,
|
|
222
|
+
userPersona,
|
|
223
|
+
tools,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return saveJsonFile(payload, safeFileName(session.title));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 去除工具表的自增 id(导入时重建) */
|
|
230
|
+
function stripToolId(tool: McpTool): Omit<McpTool, 'id'> {
|
|
231
|
+
const { id: _id, ...rest } = tool;
|
|
232
|
+
void _id;
|
|
233
|
+
return rest;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 把消息的 toolCallsJson 中的工具调用名重映射到副本名(工具重名被改为「 (2)」时) */
|
|
237
|
+
function remapToolCallsJson(value: string | undefined, map: Map<string, string>): string {
|
|
238
|
+
if (!value || map.size === 0) return value || '[]';
|
|
239
|
+
try {
|
|
240
|
+
const calls = JSON.parse(value) as Array<{ name?: string }>;
|
|
241
|
+
if (!Array.isArray(calls)) return value;
|
|
242
|
+
let changed = false;
|
|
243
|
+
for (const c of calls) {
|
|
244
|
+
if (c && typeof c.name === 'string') {
|
|
245
|
+
const mapped = map.get(c.name);
|
|
246
|
+
if (mapped && mapped !== c.name) {
|
|
247
|
+
c.name = mapped;
|
|
248
|
+
changed = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return changed ? JSON.stringify(calls) : value;
|
|
253
|
+
} catch {
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// 导入
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
interface ImportGameplayResult {
|
|
263
|
+
sessionId: number;
|
|
264
|
+
sessionTitle: string;
|
|
265
|
+
createdNpcs: number;
|
|
266
|
+
importedWorldBook: boolean;
|
|
267
|
+
importedPersona: boolean;
|
|
268
|
+
importedTools: number;
|
|
269
|
+
importedMessages: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 把游戏导出 JSON 完全重建为可游玩的会话。
|
|
274
|
+
* 返回统计信息;失败抛出带信息的 Error。
|
|
275
|
+
*/
|
|
276
|
+
export async function importGameplay(payload: unknown): Promise<ImportGameplayResult> {
|
|
277
|
+
const data = parseGameplayPayload(payload);
|
|
278
|
+
|
|
279
|
+
// ---- 1. 工具(生成式技能):重名不覆盖,创建独立副本 ----
|
|
280
|
+
// toolNameMap:源工具名 → 副本最终名,供 NPC enabledToolNames / 消息记录重映射
|
|
281
|
+
let importedTools = 0;
|
|
282
|
+
const toolNameMap = new Map<string, string>();
|
|
283
|
+
for (const tool of data.tools) {
|
|
284
|
+
if (!tool.name) continue;
|
|
285
|
+
const finalName = await uniqueName('tools', tool.name);
|
|
286
|
+
toolNameMap.set(tool.name, finalName);
|
|
287
|
+
await db.tools.add({
|
|
288
|
+
name: finalName,
|
|
289
|
+
jsonContent: tool.jsonContent,
|
|
290
|
+
executionJson: tool.executionJson ?? null,
|
|
291
|
+
isBuiltIn: false,
|
|
292
|
+
origin: 'imported',
|
|
293
|
+
createdAt: Date.now(),
|
|
294
|
+
displayOrder: (await db.tools.count()) + 1,
|
|
295
|
+
});
|
|
296
|
+
importedTools += 1;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- 2. 角色(NPC)与用户人设:重名不覆盖,创建副本;保留源 id → 新 id 映射 ----
|
|
300
|
+
const sourceNpcIdToNew = new Map<number, number>();
|
|
301
|
+
let createdNpcs = 0;
|
|
302
|
+
const createNpcCopy = async (source: NpcCharacter): Promise<number | null> => {
|
|
303
|
+
if (!source || !source.name) return null;
|
|
304
|
+
if (source.id != null && sourceNpcIdToNew.has(source.id)) return sourceNpcIdToNew.get(source.id)!;
|
|
305
|
+
const finalName = await uniqueName('npcs', source.name);
|
|
306
|
+
const id = await db.npcs.add({
|
|
307
|
+
name: finalName,
|
|
308
|
+
prompt: source.prompt ?? '',
|
|
309
|
+
greeting: source.greeting ?? '',
|
|
310
|
+
alternateGreetings: source.alternateGreetings ?? [],
|
|
311
|
+
avatarColorOrdinal: source.avatarColorOrdinal ?? 0,
|
|
312
|
+
avatarDataUrl: source.avatarDataUrl ?? null,
|
|
313
|
+
enabledToolNames: (source.enabledToolNames ?? []).map((n) => toolNameMap.get(n) ?? n),
|
|
314
|
+
isBuiltIn: false,
|
|
315
|
+
createdAt: Date.now(),
|
|
316
|
+
});
|
|
317
|
+
if (source.id != null) sourceNpcIdToNew.set(source.id, id);
|
|
318
|
+
createdNpcs += 1;
|
|
319
|
+
return id;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// 参与角色按导出顺序登记
|
|
323
|
+
for (const entry of data.npcs) {
|
|
324
|
+
await createNpcCopy(entry.npc);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 用户人设角色
|
|
328
|
+
let personaNpcId: number | null = null;
|
|
329
|
+
if (data.userPersona) {
|
|
330
|
+
personaNpcId = await createNpcCopy(data.userPersona);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- 3. 世界书:重名不覆盖,创建副本 ----
|
|
334
|
+
let importedWorldBook = false;
|
|
335
|
+
let worldBookId: number | null = null;
|
|
336
|
+
if (data.worldBook && data.worldBook.name) {
|
|
337
|
+
const finalName = await uniqueName('worldBooks', data.worldBook.name);
|
|
338
|
+
worldBookId = await db.worldBooks.add({
|
|
339
|
+
name: finalName,
|
|
340
|
+
content: data.worldBook.content ?? '',
|
|
341
|
+
imageUri: null,
|
|
342
|
+
createdAt: Date.now(),
|
|
343
|
+
});
|
|
344
|
+
importedWorldBook = true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- 4. 会话 ----
|
|
348
|
+
const src = data.session;
|
|
349
|
+
const title = data.title?.trim() || src.title || '导入的游戏';
|
|
350
|
+
const sessionId = await db.sessions.add({
|
|
351
|
+
title,
|
|
352
|
+
mode: src.mode === 'NPC' || src.mode === 'GROUP' ? src.mode : 'STANDARD',
|
|
353
|
+
associatedId: null,
|
|
354
|
+
worldBookId,
|
|
355
|
+
userPersonaNpcId: personaNpcId,
|
|
356
|
+
enableGreeting: src.enableGreeting !== false,
|
|
357
|
+
mentionOnlyMode: src.mode === 'GROUP' && src.mentionOnlyMode === true,
|
|
358
|
+
turnOrderMode: src.turnOrderMode === 'RANDOM' ? 'RANDOM' : 'PRESET',
|
|
359
|
+
workspaceDir: null, // 导入的会话分配全新的独立工作目录,不继承源导出文件
|
|
360
|
+
turnQueueJson: remapQueueJson(src.turnQueueJson || '[]', sourceNpcIdToNew),
|
|
361
|
+
turnQueueHistoryJson: remapQueueJson(src.turnQueueHistoryJson || '[]', sourceNpcIdToNew),
|
|
362
|
+
loopIndex: Number.isFinite(src.loopIndex) ? src.loopIndex : 0,
|
|
363
|
+
pinned: 0,
|
|
364
|
+
lastMessage: '',
|
|
365
|
+
updatedAt: Date.now(),
|
|
366
|
+
createdAt: Date.now(),
|
|
367
|
+
});
|
|
368
|
+
// 会话专属工作目录(当前酒馆实例 + 会话 id,单层目录、不嵌套)
|
|
369
|
+
const workspaceDir = createSessionWorkspaceDir(sessionId);
|
|
370
|
+
await db.sessions.update(sessionId, { workspaceDir });
|
|
371
|
+
// 创建对话的同时预建其专属工作目录
|
|
372
|
+
await ensureSessionWorkspaceDir(workspaceDir);
|
|
373
|
+
|
|
374
|
+
// ---- 5. 参与者(玩家恒定 -1;NPC participantId = 新 npc id,保持座位顺序) ----
|
|
375
|
+
const participants: ChatParticipant[] = [];
|
|
376
|
+
participants.push({
|
|
377
|
+
sessionId,
|
|
378
|
+
participantId: -1,
|
|
379
|
+
kind: 'PLAYER',
|
|
380
|
+
npcId: null,
|
|
381
|
+
displayName: '用户',
|
|
382
|
+
seatOrder: 0,
|
|
383
|
+
});
|
|
384
|
+
const sortedSrcParticipants = [...data.sessionParticipants].sort((a, b) => a.seatOrder - b.seatOrder);
|
|
385
|
+
for (const sp of sortedSrcParticipants) {
|
|
386
|
+
if (sp.kind !== 'NPC' || sp.npcId == null) continue;
|
|
387
|
+
const newNpcId = sourceNpcIdToNew.get(sp.npcId) ?? null;
|
|
388
|
+
if (newNpcId == null) continue;
|
|
389
|
+
participants.push({
|
|
390
|
+
sessionId,
|
|
391
|
+
participantId: newNpcId,
|
|
392
|
+
kind: 'NPC',
|
|
393
|
+
npcId: newNpcId,
|
|
394
|
+
displayName: sp.displayName || '',
|
|
395
|
+
seatOrder: participants.length,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
await db.participants.bulkAdd(participants);
|
|
399
|
+
|
|
400
|
+
// 会话关联角色(NPC 模式)
|
|
401
|
+
const npcParts = participants.filter((p) => p.kind === 'NPC');
|
|
402
|
+
const sessionMode = npcParts.length === 1 ? 'NPC' : 'GROUP';
|
|
403
|
+
await db.sessions.update(sessionId, {
|
|
404
|
+
associatedId: sessionMode === 'NPC' ? npcParts[0].npcId : null,
|
|
405
|
+
mode: sessionMode,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// 导出文件未包含对话时,按新建对话的规则写入角色开场白。
|
|
409
|
+
let importedMessages = 0;
|
|
410
|
+
if (data.messages.length === 0 && src.enableGreeting !== false) {
|
|
411
|
+
const remappedQueue = (() => {
|
|
412
|
+
try {
|
|
413
|
+
const queue = JSON.parse(remapQueueJson(src.turnQueueJson || '[]', sourceNpcIdToNew));
|
|
414
|
+
return Array.isArray(queue) ? queue.map(Number) : [];
|
|
415
|
+
} catch {
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
})();
|
|
419
|
+
const greetingParticipant = sessionMode === 'NPC'
|
|
420
|
+
? npcParts[0]
|
|
421
|
+
: npcParts.find((participant) => participant.participantId === remappedQueue[0]);
|
|
422
|
+
if (greetingParticipant?.npcId != null) {
|
|
423
|
+
const greetingNpc = await db.npcs.get(greetingParticipant.npcId);
|
|
424
|
+
if (greetingNpc) {
|
|
425
|
+
const preview = await seedOpeningGreeting(sessionId, greetingNpc, greetingParticipant.displayName || greetingNpc.name);
|
|
426
|
+
if (preview) {
|
|
427
|
+
await db.sessions.update(sessionId, { lastMessage: preview });
|
|
428
|
+
importedMessages = 1;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---- 6. 消息(保留顺序与相对间隔,id 重建) ----
|
|
435
|
+
// 注意:导出时已剔除性能/调试字段(tokens、latency、model、raw body、attachmentInfos),
|
|
436
|
+
// 此处为所有非导出字段补齐默认值,兼容旧版导出文件。
|
|
437
|
+
if (data.messages.length > 0) {
|
|
438
|
+
const sorted = [...data.messages].sort((a, b) => a.timestamp - b.timestamp);
|
|
439
|
+
const firstTs = sorted[0]?.timestamp ?? Date.now();
|
|
440
|
+
const baseTime = Date.now() - (sorted.length - 1) * 30_000;
|
|
441
|
+
for (const m of sorted) {
|
|
442
|
+
const speaker = m.speakerParticipantId == null
|
|
443
|
+
? null
|
|
444
|
+
: m.speakerParticipantId === -1
|
|
445
|
+
? -1
|
|
446
|
+
: sourceNpcIdToNew.get(m.speakerParticipantId) ?? null;
|
|
447
|
+
const row: ChatMessage = {
|
|
448
|
+
sessionId,
|
|
449
|
+
role: m.role,
|
|
450
|
+
speakerParticipantId: speaker,
|
|
451
|
+
speakerName: m.speakerName ?? null,
|
|
452
|
+
content: m.content ?? '',
|
|
453
|
+
toolCallsJson: remapToolCallsJson(m.toolCallsJson || '[]', toolNameMap),
|
|
454
|
+
toolCallId: m.toolCallId ?? null,
|
|
455
|
+
thinkingContent: m.thinkingContent ?? null,
|
|
456
|
+
loopIndex: m.loopIndex ?? null,
|
|
457
|
+
timestamp: baseTime + Math.max(0, (m.timestamp ?? firstTs) - firstTs),
|
|
458
|
+
latencyMs: null,
|
|
459
|
+
promptTokens: 0,
|
|
460
|
+
completionTokens: 0,
|
|
461
|
+
totalTokens: 0,
|
|
462
|
+
tokensPerSec: null,
|
|
463
|
+
modelUsed: null,
|
|
464
|
+
attachments: m.attachments ?? [],
|
|
465
|
+
attachmentInfos: [],
|
|
466
|
+
displayRef: m.displayRef ?? null,
|
|
467
|
+
rawRequestBody: null,
|
|
468
|
+
rawResponseBody: null,
|
|
469
|
+
};
|
|
470
|
+
await db.messages.add(row);
|
|
471
|
+
importedMessages += 1;
|
|
472
|
+
}
|
|
473
|
+
// 会话预览
|
|
474
|
+
const lastVisible = [...sorted].reverse().find((m) => m.role !== 'tool');
|
|
475
|
+
if (lastVisible && lastVisible.content) {
|
|
476
|
+
await db.sessions.update(sessionId, { lastMessage: lastVisible.content.slice(0, 60) });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
sessionId,
|
|
482
|
+
sessionTitle: title,
|
|
483
|
+
createdNpcs,
|
|
484
|
+
importedWorldBook,
|
|
485
|
+
importedPersona: Boolean(personaNpcId),
|
|
486
|
+
importedTools,
|
|
487
|
+
importedMessages,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 校验并规范化游戏导出结构;失败抛出带信息的 Error */
|
|
492
|
+
function parseGameplayPayload(payload: unknown): GameplayExportPayload {
|
|
493
|
+
if (!payload || typeof payload !== 'object') throw new Error('无效的游戏文件');
|
|
494
|
+
const p = payload as Partial<GameplayExportPayload> & { participants?: ChatParticipant[] };
|
|
495
|
+
if (p.format !== GAMEPLAY_FORMAT) {
|
|
496
|
+
throw new Error('不是 Tavern Harness 游戏导出文件');
|
|
497
|
+
}
|
|
498
|
+
const session = p.session as Partial<ChatSession> | undefined;
|
|
499
|
+
if (!session || typeof session !== 'object' || !session.mode) {
|
|
500
|
+
throw new Error('游戏文件缺少会话信息');
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
format: GAMEPLAY_FORMAT,
|
|
504
|
+
version: typeof p.version === 'number' ? p.version : 0,
|
|
505
|
+
exportedAt: typeof p.exportedAt === 'number' ? p.exportedAt : 0,
|
|
506
|
+
appName: 'Tavern Harness',
|
|
507
|
+
title: typeof p.title === 'string' ? p.title : (session.title ?? ''),
|
|
508
|
+
includeHistory: p.includeHistory !== false,
|
|
509
|
+
session: session as Omit<ChatSession, 'id' | 'lastMessage'>,
|
|
510
|
+
sessionParticipants: Array.isArray(p.sessionParticipants)
|
|
511
|
+
? p.sessionParticipants
|
|
512
|
+
: Array.isArray(p.participants)
|
|
513
|
+
? p.participants
|
|
514
|
+
: [],
|
|
515
|
+
messages: Array.isArray(p.messages) ? p.messages : [],
|
|
516
|
+
npcs: Array.isArray(p.npcs) ? p.npcs : [],
|
|
517
|
+
worldBook: p.worldBook,
|
|
518
|
+
userPersona: p.userPersona,
|
|
519
|
+
tools: Array.isArray(p.tools) ? p.tools : [],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 横向滚动容器的「垂直滚轮 → 水平滚动」翻译
|
|
3
|
+
//
|
|
4
|
+
// 问题背景:类似 .side-nav(侧边栏上部导航 / 角色工坊 tab 栏)的
|
|
5
|
+
// 容器是 overflow-x: auto。桌面鼠标滚轮默认产生垂直 deltaY,
|
|
6
|
+
// 悬停其上不会触发横向滚动,导致窄布局下按钮被裁掉却滚不动。
|
|
7
|
+
//
|
|
8
|
+
// 方案:在 document 上做事件委托(capture + passive:false),
|
|
9
|
+
// 当滚轮事件落在带 [data-hscroll] 的容器内、且容器存在横向溢出时,
|
|
10
|
+
// 把 deltaY(必要时叠加 deltaX)写入 scrollLeft;滚动到两端边界时
|
|
11
|
+
// 还原默认行为(放行给外层容器/页面),避免“粘住”不可达的滚动条。
|
|
12
|
+
//
|
|
13
|
+
// 用法:给希望滚轮横滚的容器加 data-hscroll 属性即可,无需改组件。
|
|
14
|
+
// 本模块在应用启动时调用一次 enableHorizontalWheel()。
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
function findScrollParent(target: EventTarget | null): HTMLElement | null {
|
|
18
|
+
let el = target instanceof Element ? (target as HTMLElement) : null;
|
|
19
|
+
while (el && el !== document.body) {
|
|
20
|
+
if (el.matches('[data-hscroll]')) return el;
|
|
21
|
+
el = el.parentElement;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 维护横向溢出提示状态(class 由 CSS 的 mask 渐变消费):
|
|
27
|
+
// has-overflow — 存在横向溢出(内容确实被遮挡)
|
|
28
|
+
// hidden-left — 左侧有内容被遮挡 → 左边缘淡出
|
|
29
|
+
// hidden-right — 右侧有内容被遮挡 → 右边缘淡出
|
|
30
|
+
function updateOverflowHint(el: HTMLElement): void {
|
|
31
|
+
const overflowX = el.scrollWidth > el.clientWidth + 1;
|
|
32
|
+
const atStart = el.scrollLeft <= 1;
|
|
33
|
+
const atEnd = el.scrollLeft >= el.scrollWidth - el.clientWidth - 1;
|
|
34
|
+
el.classList.toggle('has-overflow', overflowX);
|
|
35
|
+
el.classList.toggle('hidden-left', overflowX && !atStart);
|
|
36
|
+
el.classList.toggle('hidden-right', overflowX && !atEnd);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function enableHorizontalWheel(): void {
|
|
40
|
+
// 滚动时刷新溢出提示
|
|
41
|
+
document.addEventListener(
|
|
42
|
+
'scroll',
|
|
43
|
+
(e: Event) => {
|
|
44
|
+
const target = e.target;
|
|
45
|
+
if (!(target instanceof Element)) return;
|
|
46
|
+
const h = target.closest('[data-hscroll]') as HTMLElement | null;
|
|
47
|
+
if (!h) return;
|
|
48
|
+
updateOverflowHint(h);
|
|
49
|
+
},
|
|
50
|
+
{ capture: true, passive: true }
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// 布局变化(初次渲染 / 窗口尺寸变化 / 字体加载)后重新计算
|
|
54
|
+
const sweep = () => {
|
|
55
|
+
document.querySelectorAll<HTMLElement>('[data-hscroll]').forEach(updateOverflowHint);
|
|
56
|
+
};
|
|
57
|
+
// 初次渲染可能晚于 enableHorizontalWheel 调用,延后到 React 挂载完成后执行
|
|
58
|
+
requestAnimationFrame(() => setTimeout(sweep, 0));
|
|
59
|
+
window.addEventListener('resize', sweep);
|
|
60
|
+
window.addEventListener('load', sweep);
|
|
61
|
+
document.fonts?.ready?.then(sweep).catch(() => {});
|
|
62
|
+
// 动态挂载的 [data-hscroll] 容器(如工坊 tab 切换)出现后立即计算
|
|
63
|
+
const mo = new MutationObserver(sweep);
|
|
64
|
+
mo.observe(document.body, { subtree: true, childList: true });
|
|
65
|
+
|
|
66
|
+
document.addEventListener(
|
|
67
|
+
'wheel',
|
|
68
|
+
(e: WheelEvent) => {
|
|
69
|
+
const el = findScrollParent(e.target);
|
|
70
|
+
if (!el) return;
|
|
71
|
+
|
|
72
|
+
const canScrollX = el.scrollWidth > el.clientWidth + 1;
|
|
73
|
+
if (!canScrollX) return;
|
|
74
|
+
|
|
75
|
+
// 只有横向溢出才接管;无横向溢出时保持默认(不影响页面内其它滚动)
|
|
76
|
+
const delta = e.deltaY ?? 0;
|
|
77
|
+
if (delta === 0 && (e.deltaX ?? 0) === 0) return;
|
|
78
|
+
|
|
79
|
+
// 滚轮偏移换算:正常一次“格”约 100px,与常规滚动体感一致
|
|
80
|
+
let target = el.scrollLeft + delta + (e.deltaX ?? 0);
|
|
81
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
82
|
+
const prev = el.scrollLeft;
|
|
83
|
+
target = Math.max(0, Math.min(target, max));
|
|
84
|
+
if (target !== prev) {
|
|
85
|
+
el.scrollLeft = target;
|
|
86
|
+
// 到此为止:真正发生了横向滚动,阻止默认(避免外层/页面也跟着滚)
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
}
|
|
89
|
+
// 若目标在两端(无剩余空间),不 preventDefault,让事件自然冒泡
|
|
90
|
+
},
|
|
91
|
+
{ capture: true, passive: false }
|
|
92
|
+
);
|
|
93
|
+
}
|