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,718 @@
|
|
|
1
|
+
import { builtinTavernHandbookName, db, isBuiltinTavernHandbook } from '../../db/database';
|
|
2
|
+
import { createSession } from '../../store/store';
|
|
3
|
+
import type {
|
|
4
|
+
ChatCompletionTool,
|
|
5
|
+
ChatSession,
|
|
6
|
+
GeneratedSkillExecution,
|
|
7
|
+
McpTool,
|
|
8
|
+
ToolConfirmationRequest,
|
|
9
|
+
} from '../../types/models';
|
|
10
|
+
import { BUILTIN_TOOL_NAMES } from '../toolDefinitions';
|
|
11
|
+
import { translate } from '../i18n';
|
|
12
|
+
import { rollDice } from './builtinTools';
|
|
13
|
+
import {
|
|
14
|
+
createSessionWorkspaceDir,
|
|
15
|
+
executeGeneratedSkill,
|
|
16
|
+
normalizeWorkspaceDir,
|
|
17
|
+
readWorkspaceFileText,
|
|
18
|
+
truncateToolOutput,
|
|
19
|
+
writeWorkspaceFileTextFor,
|
|
20
|
+
} from './generatedSkillExecutor';
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// 工具路由与确认门控 — 对应 ToolExecutionCoordinator.kt
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
type ToolRoute = 'NATIVE' | 'STANDARD' | 'BLOCKED';
|
|
27
|
+
|
|
28
|
+
function routeFor(tool: McpTool | null): ToolRoute {
|
|
29
|
+
// 生成式技能(非内置)永远不可进入原生路由,即使重用内置名字
|
|
30
|
+
if (tool && !tool.isBuiltIn) return 'STANDARD';
|
|
31
|
+
if (tool && (BUILTIN_TOOL_NAMES as readonly string[]).includes(tool.name)) return 'NATIVE';
|
|
32
|
+
return 'BLOCKED';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ToolExecutionContext {
|
|
36
|
+
sessionId: number;
|
|
37
|
+
/** 发起工具调用的 NPC。 */
|
|
38
|
+
npcId: number | null;
|
|
39
|
+
/** 用户确认回调:返回 Promise<boolean> */
|
|
40
|
+
requestConfirmation: (req: ToolConfirmationRequest) => Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 执行一个工具调用(原生或生成式)。
|
|
45
|
+
* 修改/删除类工具的统一确认入口在外层白名单(store.ts 回合循环);
|
|
46
|
+
* shell 类工具的确认在执行过程中按需触发(沙箱服务判定)。
|
|
47
|
+
*/
|
|
48
|
+
export async function executeToolCall(
|
|
49
|
+
toolName: string,
|
|
50
|
+
argsJson: string,
|
|
51
|
+
ctx: ToolExecutionContext
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const tool = await db.tools.where('name').equals(toolName).first();
|
|
54
|
+
const mcpTool = tool ?? null;
|
|
55
|
+
const route = routeFor(mcpTool);
|
|
56
|
+
|
|
57
|
+
let args: Record<string, unknown> = {};
|
|
58
|
+
try {
|
|
59
|
+
args = JSON.parse(argsJson || '{}');
|
|
60
|
+
} catch {
|
|
61
|
+
return 'ERROR: Tool arguments are not valid JSON';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 系统消息层:自动执行幻觉工具名(未知工具)
|
|
65
|
+
if (route === 'BLOCKED') {
|
|
66
|
+
return `ERROR: Tool '${toolName}' does not exist or is unavailable. Available tools: ${(await listToolNames()).join(', ')}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 原生路由
|
|
70
|
+
if (route === 'NATIVE') {
|
|
71
|
+
return runNativeTool(toolName, args, ctx);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 生成式技能(STANDARD 路由):不在当前会话启用检查由上层决定
|
|
75
|
+
if (mcpTool && mcpTool.executionJson) {
|
|
76
|
+
try {
|
|
77
|
+
const execution = JSON.parse(mcpTool.executionJson) as GeneratedSkillExecution;
|
|
78
|
+
const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
|
|
79
|
+
return await executeGeneratedSkill(execution, args, ctx.requestConfirmation, workspaceDir);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return `ERROR: Invalid skill implementation: ${(e as Error).message}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return `ERROR: Skill '${toolName}' has no implementation`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 把工具调用工作目录切到当前会话的沙箱目录。
|
|
89
|
+
* 内置酒馆老板的 NPC 单人会话固定使用 public;其他会话使用专属目录。
|
|
90
|
+
* 会话记录上的 workspaceDir 由创建会话时生成;
|
|
91
|
+
* 旧会话没有该字段或数据库读取失败时,也回退到当前酒馆实例专属目录,避免意外扩大权限。
|
|
92
|
+
*/
|
|
93
|
+
export async function applySessionWorkspace(sessionId: number, npcId?: number | null): Promise<string> {
|
|
94
|
+
let dir = createSessionWorkspaceDir(sessionId);
|
|
95
|
+
try {
|
|
96
|
+
const session = await db.sessions.get(sessionId);
|
|
97
|
+
if (session?.mode === 'NPC' && session.associatedId != null) {
|
|
98
|
+
const effectiveNpcId = npcId === undefined ? session.associatedId : npcId;
|
|
99
|
+
if (effectiveNpcId === session.associatedId && (await db.npcs.get(effectiveNpcId))?.isBuiltIn) {
|
|
100
|
+
return 'public';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (session && typeof (session as ChatSession).workspaceDir === 'string') {
|
|
104
|
+
dir = (session as ChatSession).workspaceDir!;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
dir = createSessionWorkspaceDir(sessionId);
|
|
108
|
+
}
|
|
109
|
+
return normalizeWorkspaceDir(dir) ?? createSessionWorkspaceDir(sessionId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runNativeTool(
|
|
113
|
+
toolName: string,
|
|
114
|
+
args: Record<string, unknown>,
|
|
115
|
+
ctx: ToolExecutionContext
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
switch (toolName) {
|
|
118
|
+
case 'roll_dice': {
|
|
119
|
+
const expr = String(args.expression ?? '');
|
|
120
|
+
if (!expr) return 'ERROR: Missing expression';
|
|
121
|
+
return rollDice(expr);
|
|
122
|
+
}
|
|
123
|
+
case 'file_read':
|
|
124
|
+
return await handleFileRead(args, ctx);
|
|
125
|
+
case 'file_write':
|
|
126
|
+
return await handleFileWrite(args, ctx);
|
|
127
|
+
case 'file_edit':
|
|
128
|
+
return await handleFileEdit(args, ctx);
|
|
129
|
+
case 'run_shell_script':
|
|
130
|
+
return await handleRunShellScript(args, ctx);
|
|
131
|
+
|
|
132
|
+
case 'get_tavern_info':
|
|
133
|
+
return await handleGetTavernInfo(args);
|
|
134
|
+
case 'get_character_info':
|
|
135
|
+
return await handleGetCharacterInfo(args);
|
|
136
|
+
case 'get_lorebook_info':
|
|
137
|
+
return await handleGetLorebookInfo(args);
|
|
138
|
+
|
|
139
|
+
case 'file_display':
|
|
140
|
+
return await handleFileDisplay(args, ctx);
|
|
141
|
+
|
|
142
|
+
case 'create_skill':
|
|
143
|
+
return await handleCreateSkill(args);
|
|
144
|
+
// 修改/删除组的确认统一在外层白名单(store.ts 回合循环)收口,此处直接执行
|
|
145
|
+
case 'update_skill':
|
|
146
|
+
return await handleUpdateSkill(args);
|
|
147
|
+
case 'delete_skill':
|
|
148
|
+
return await handleDeleteSkill(args);
|
|
149
|
+
|
|
150
|
+
case 'create_character':
|
|
151
|
+
return await handleCreateCharacter(args);
|
|
152
|
+
case 'update_character':
|
|
153
|
+
return await handleUpdateCharacter(args);
|
|
154
|
+
case 'delete_character':
|
|
155
|
+
return await handleDeleteCharacter(args);
|
|
156
|
+
|
|
157
|
+
case 'create_conversation':
|
|
158
|
+
return await handleCreateConversation(args);
|
|
159
|
+
case 'create_lorebook':
|
|
160
|
+
return await handleCreateLorebook(args);
|
|
161
|
+
case 'update_lorebook':
|
|
162
|
+
return await handleUpdateLorebook(args);
|
|
163
|
+
case 'delete_lorebook':
|
|
164
|
+
return await handleDeleteLorebook(args);
|
|
165
|
+
|
|
166
|
+
default:
|
|
167
|
+
return `ERROR: Unknown tool ${toolName}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================
|
|
172
|
+
// 原生工具实现
|
|
173
|
+
// ============================================================
|
|
174
|
+
|
|
175
|
+
async function handleGetTavernInfo(args: Record<string, unknown>): Promise<string> {
|
|
176
|
+
const fields = Array.isArray(args.fields) ? (args.fields as string[]) : [];
|
|
177
|
+
if (fields.length === 0) return 'ERROR: At least one field is required';
|
|
178
|
+
const out: Record<string, unknown> = {};
|
|
179
|
+
|
|
180
|
+
if (fields.includes('characters')) {
|
|
181
|
+
const npcs = await db.npcs.toArray();
|
|
182
|
+
out.characters = npcs.map((n) => ({
|
|
183
|
+
name: n.name,
|
|
184
|
+
is_default: n.isBuiltIn,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
if (fields.includes('lorebooks')) {
|
|
188
|
+
const books = await db.worldBooks.toArray();
|
|
189
|
+
out.lorebooks = books.map((b) => ({ name: b.name }));
|
|
190
|
+
}
|
|
191
|
+
if (fields.includes('skills')) {
|
|
192
|
+
const tools = await db.tools.toArray();
|
|
193
|
+
out.skills = tools.map((t) => {
|
|
194
|
+
const parsed = safeJsonParse<ChatCompletionTool>(t.jsonContent);
|
|
195
|
+
const exec = safeJsonParse<{ type?: string }>(t.executionJson);
|
|
196
|
+
return {
|
|
197
|
+
name: t.name,
|
|
198
|
+
description: parsed?.function?.description ?? '',
|
|
199
|
+
is_default: t.isBuiltIn,
|
|
200
|
+
implementation_type: t.isBuiltIn ? 'native' : (exec?.type ?? 'unknown'),
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (fields.includes('career_stats')) {
|
|
205
|
+
const stats = (await db.careerStats.get(1)) ?? { inputTokens: 0, outputTokens: 0, totalRounds: 0 };
|
|
206
|
+
const sessionCount = await db.sessions.count();
|
|
207
|
+
const npcStats = await db.careerNpcStats.toArray();
|
|
208
|
+
const mostActive = npcStats.length
|
|
209
|
+
? npcStats.sort((a, b) => b.rounds - a.rounds)[0]
|
|
210
|
+
: null;
|
|
211
|
+
out.career_stats = {
|
|
212
|
+
input_tokens: stats.inputTokens,
|
|
213
|
+
output_tokens: stats.outputTokens,
|
|
214
|
+
total_tokens: stats.inputTokens + stats.outputTokens,
|
|
215
|
+
total_rounds: stats.totalRounds,
|
|
216
|
+
session_count: sessionCount,
|
|
217
|
+
average_rounds_per_session: sessionCount ? Math.round(stats.totalRounds / sessionCount * 10) / 10 : 0,
|
|
218
|
+
most_active_npc: mostActive?.npcName ?? null,
|
|
219
|
+
most_active_npc_rounds: mostActive?.rounds ?? 0,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return JSON.stringify(out, null, 2);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function handleGetCharacterInfo(args: Record<string, unknown>): Promise<string> {
|
|
226
|
+
const name = String(args.name ?? '').trim();
|
|
227
|
+
if (!name) return 'ERROR: Missing name';
|
|
228
|
+
const npc = await db.npcs.where('name').equals(name).first();
|
|
229
|
+
if (!npc) return `ERROR: Character ${name} does not exist`;
|
|
230
|
+
return JSON.stringify({
|
|
231
|
+
name: npc.name,
|
|
232
|
+
prompt: npc.prompt,
|
|
233
|
+
greeting: npc.greeting,
|
|
234
|
+
alternate_greetings: npc.alternateGreetings ?? [],
|
|
235
|
+
is_default: npc.isBuiltIn,
|
|
236
|
+
enabled_skills: npc.enabledToolNames,
|
|
237
|
+
}, null, 2);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function handleGetLorebookInfo(args: Record<string, unknown>): Promise<string> {
|
|
241
|
+
const name = String(args.name ?? '').trim();
|
|
242
|
+
if (!name) return 'ERROR: Missing name';
|
|
243
|
+
const book = await db.worldBooks.where('name').equals(name).first();
|
|
244
|
+
if (!book) return `ERROR: Lorebook ${name} does not exist`;
|
|
245
|
+
return JSON.stringify({ name: book.name, content: book.content }, null, 2);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------- file_display(弹窗展示工作区文件,只读) ----------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 结果中携带的展示引用标记前缀。store 在落库工具结果时会解析它,
|
|
252
|
+
* 把 JSON 部分写入消息的 displayRef 字段,并自动打开展示弹窗。
|
|
253
|
+
* 对模型返回的仍是可读文本(不暴露内部标记)。
|
|
254
|
+
*/
|
|
255
|
+
const DISPLAY_REF_PREFIX = 'DISPLAY_REF: ';
|
|
256
|
+
|
|
257
|
+
interface DisplayPayload {
|
|
258
|
+
path: string;
|
|
259
|
+
kind: 'text' | 'image' | 'html';
|
|
260
|
+
title?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 解析工具结果中的展示引用(无标记返回 null) */
|
|
264
|
+
export function parseDisplayRef(result: string): DisplayPayload | null {
|
|
265
|
+
if (!result.startsWith(DISPLAY_REF_PREFIX)) return null;
|
|
266
|
+
// DISPLAY_REF 是结果的第一行,JSON 后可能还有换行和摘要文本
|
|
267
|
+
const firstLine = result.slice(DISPLAY_REF_PREFIX.length).split('\n')[0]!;
|
|
268
|
+
try {
|
|
269
|
+
const obj = JSON.parse(firstLine) as DisplayPayload;
|
|
270
|
+
if (!obj || typeof obj.path !== 'string' || !['text', 'image', 'html'].includes(obj.kind)) return null;
|
|
271
|
+
return { path: obj.path, kind: obj.kind, title: typeof obj.title === 'string' ? obj.title : undefined };
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 读取本地沙箱工作区文件并生成展示结果 */
|
|
278
|
+
async function handleFileDisplay(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
|
|
279
|
+
try {
|
|
280
|
+
const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
|
|
281
|
+
// 与 file_read 对齐:仅允许会话工作区内相对路径;public 软链只读
|
|
282
|
+
const rawPath = String(args.path ?? '').replace(/\\/g, '/').trim();
|
|
283
|
+
if (!rawPath) return 'ERROR: Invalid path';
|
|
284
|
+
const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : undefined;
|
|
285
|
+
|
|
286
|
+
// 从文件后缀推断展示方式
|
|
287
|
+
const ext = /\.([A-Za-z0-9]+)$/.exec(rawPath);
|
|
288
|
+
const extLower = ext ? ext[1].toLowerCase() : '';
|
|
289
|
+
const kind: DisplayPayload['kind'] =
|
|
290
|
+
/^(html?)$/i.test(extLower) ? 'html' :
|
|
291
|
+
/^(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(extLower) ? 'image' :
|
|
292
|
+
'text';
|
|
293
|
+
|
|
294
|
+
const content = await readWorkspaceFileText(rawPath, workspaceDir);
|
|
295
|
+
if (content === null) {
|
|
296
|
+
return `ERROR: File not found: ${rawPath}`;
|
|
297
|
+
}
|
|
298
|
+
const payload: DisplayPayload = { path: rawPath, kind, title };
|
|
299
|
+
// 第一行携带展示引用(store 解析并写入 displayRef / 自动弹窗)。
|
|
300
|
+
// 不做内容摘要:完整内容在展示弹窗里,模型如需阅读应改用 file_read。
|
|
301
|
+
return `${DISPLAY_REF_PREFIX}${JSON.stringify(payload)}\n${translate('tool.fileDisplaySuccess', { path: rawPath, kind })}`;
|
|
302
|
+
} catch (e) {
|
|
303
|
+
return `ERROR: ${(e as Error).message}`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------- file_read / file_write / run_shell_script(内置技能,复用生成式执行引擎的本地能力) ----------
|
|
308
|
+
|
|
309
|
+
async function handleFileRead(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
|
|
310
|
+
try {
|
|
311
|
+
const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
|
|
312
|
+
const path = String(args.path ?? '').replace(/\\/g, '/').trim();
|
|
313
|
+
if (!path) return 'ERROR: Invalid path';
|
|
314
|
+
const content = await readWorkspaceFileText(path, workspaceDir);
|
|
315
|
+
if (content === null) {
|
|
316
|
+
return `ERROR: File not found: ${path}`;
|
|
317
|
+
}
|
|
318
|
+
return truncateToolOutput(content);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return `ERROR: ${(e as Error).message}`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function handleFileWrite(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
|
|
325
|
+
const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
|
|
326
|
+
return executeGeneratedSkill(
|
|
327
|
+
{ type: 'file_write', path: String(args.path ?? ''), content: String(args.content ?? ''), append: args.append === true },
|
|
328
|
+
args,
|
|
329
|
+
ctx.requestConfirmation,
|
|
330
|
+
workspaceDir,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function handleFileEdit(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
|
|
335
|
+
const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
|
|
336
|
+
try {
|
|
337
|
+
const path = String(args.path ?? '').replace(/\\/g, '/').trim();
|
|
338
|
+
const oldText = String(args.old_text ?? '');
|
|
339
|
+
const newText = String(args.new_text ?? '');
|
|
340
|
+
const expected = args.expected_replacements == null ? 1 : Number(args.expected_replacements);
|
|
341
|
+
if (!path) return 'ERROR: Invalid path';
|
|
342
|
+
if (!oldText) return 'ERROR: old_text must not be empty';
|
|
343
|
+
if (!Number.isInteger(expected) || expected < 1 || expected > 100) {
|
|
344
|
+
return 'ERROR: expected_replacements must be an integer from 1 to 100';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const content = await readWorkspaceFileText(path, workspaceDir);
|
|
348
|
+
if (content === null) return `ERROR: File not found: ${path}`;
|
|
349
|
+
const matches = content.split(oldText).length - 1;
|
|
350
|
+
if (matches !== expected) {
|
|
351
|
+
return `ERROR: ${path} was not modified: old_text matched ${matches} times; expected ${expected}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 与 file_write 对齐:仅工作区内写入;public 软链写入直接拒绝
|
|
355
|
+
await writeWorkspaceFileTextFor(
|
|
356
|
+
path,
|
|
357
|
+
content.split(oldText).join(newText),
|
|
358
|
+
workspaceDir,
|
|
359
|
+
);
|
|
360
|
+
return translate('tool.fileEditSuccess', { path, count: matches });
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return `ERROR: ${(e as Error).message}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function handleRunShellScript(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
|
|
367
|
+
const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
|
|
368
|
+
return executeGeneratedSkill(
|
|
369
|
+
{ type: 'shell', script: String(args.script ?? '') },
|
|
370
|
+
args,
|
|
371
|
+
ctx.requestConfirmation,
|
|
372
|
+
workspaceDir,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------- 技能 CRUD ----------
|
|
377
|
+
|
|
378
|
+
function validateSkillArgs(args: Record<string, unknown>): string | null {
|
|
379
|
+
const name = String(args.name ?? '');
|
|
380
|
+
if (!/^[a-z][a-z0-9_]{2,39}$/.test(name)) return 'Skill name must contain 3-40 lowercase letters, digits, or underscores and start with a letter';
|
|
381
|
+
const desc = String(args.description ?? '');
|
|
382
|
+
if (desc.length > 500) return 'Description exceeds 500 characters';
|
|
383
|
+
const params = args.parameters as Record<string, unknown> | undefined;
|
|
384
|
+
if (params && params.type !== 'object') return 'Parameters must use an object schema';
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function createSkillTool(name: string, description: string, parameters: Record<string, unknown>, execution: unknown): Promise<McpTool> {
|
|
389
|
+
const tool: ChatCompletionTool = {
|
|
390
|
+
type: 'function',
|
|
391
|
+
function: { name, description, parameters: parameters ?? { type: 'object', properties: {} } },
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
name,
|
|
395
|
+
jsonContent: JSON.stringify(tool),
|
|
396
|
+
executionJson: execution ? JSON.stringify(execution) : null,
|
|
397
|
+
isBuiltIn: false,
|
|
398
|
+
origin: 'custom',
|
|
399
|
+
createdAt: Date.now(),
|
|
400
|
+
displayOrder: (await db.tools.count()) + 1,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function handleCreateSkill(args: Record<string, unknown>): Promise<string> {
|
|
405
|
+
const err = validateSkillArgs(args);
|
|
406
|
+
if (err) return `ERROR: ${err}`;
|
|
407
|
+
const name = String(args.name);
|
|
408
|
+
const exists = await db.tools.where('name').equals(name).first();
|
|
409
|
+
if (exists) return `ERROR: Skill ${name} already exists`;
|
|
410
|
+
const tool = await createSkillTool(name, String(args.description ?? ''), (args.parameters as Record<string, unknown>) ?? {}, args.execution);
|
|
411
|
+
await db.tools.add(tool);
|
|
412
|
+
return translate('tool.skillCreated', { name });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function handleUpdateSkill(args: Record<string, unknown>): Promise<string> {
|
|
416
|
+
const name = String(args.name ?? '');
|
|
417
|
+
const tool = await db.tools.where('name').equals(name).first();
|
|
418
|
+
if (!tool) return `ERROR: Skill ${name} does not exist`;
|
|
419
|
+
if (tool.isBuiltIn) return `ERROR: Built-in skill ${name} is protected and cannot be modified`;
|
|
420
|
+
const newName = args.new_name ? String(args.new_name) : tool.name;
|
|
421
|
+
if (args.new_name && !/^[a-z][a-z0-9_]{2,39}$/.test(newName)) return 'ERROR: Invalid new skill name';
|
|
422
|
+
if (args.new_name && (await db.tools.where('name').equals(newName).first())) return `ERROR: Skill ${newName} already exists`;
|
|
423
|
+
|
|
424
|
+
const parsed = safeJsonParse<ChatCompletionTool>(tool.jsonContent);
|
|
425
|
+
const updatedTool = {
|
|
426
|
+
...tool,
|
|
427
|
+
name: newName,
|
|
428
|
+
jsonContent: JSON.stringify({
|
|
429
|
+
type: 'function',
|
|
430
|
+
function: {
|
|
431
|
+
name: newName,
|
|
432
|
+
description: args.description != null ? String(args.description) : parsed?.function?.description ?? '',
|
|
433
|
+
parameters: args.parameters ?? parsed?.function?.parameters ?? { type: 'object', properties: {} },
|
|
434
|
+
},
|
|
435
|
+
}),
|
|
436
|
+
executionJson: args.execution ? JSON.stringify(args.execution) : tool.executionJson,
|
|
437
|
+
};
|
|
438
|
+
await db.tools.put(updatedTool);
|
|
439
|
+
// 同步角色启用的技能名
|
|
440
|
+
if (newName !== name) {
|
|
441
|
+
const npcs = await db.npcs.toArray();
|
|
442
|
+
for (const n of npcs) {
|
|
443
|
+
if (n.enabledToolNames.includes(name)) {
|
|
444
|
+
await db.npcs.update(n.id!, {
|
|
445
|
+
enabledToolNames: n.enabledToolNames.map((t) => (t === name ? newName : t)),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return translate('tool.skillUpdated', { name });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function handleDeleteSkill(args: Record<string, unknown>): Promise<string> {
|
|
454
|
+
const name = String(args.name ?? '');
|
|
455
|
+
const tool = await db.tools.where('name').equals(name).first();
|
|
456
|
+
if (!tool) return `ERROR: Skill ${name} does not exist`;
|
|
457
|
+
if (tool.isBuiltIn) return `ERROR: Built-in skill ${name} is protected and cannot be deleted`;
|
|
458
|
+
await db.tools.delete(tool.id!);
|
|
459
|
+
// 清理角色引用
|
|
460
|
+
const npcs = await db.npcs.toArray();
|
|
461
|
+
for (const n of npcs) {
|
|
462
|
+
if (n.enabledToolNames.includes(name)) {
|
|
463
|
+
await db.npcs.update(n.id!, { enabledToolNames: n.enabledToolNames.filter((t) => t !== name) });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return translate('tool.skillDeleted', { name });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------- 角色 CRUD ----------
|
|
470
|
+
|
|
471
|
+
async function handleCreateCharacter(args: Record<string, unknown>): Promise<string> {
|
|
472
|
+
const name = String(args.name ?? '').trim();
|
|
473
|
+
const greeting = String(args.greeting ?? '');
|
|
474
|
+
const prompt = String(args.prompt ?? '');
|
|
475
|
+
if (!name || !greeting || !prompt) return 'ERROR: name, greeting, and prompt are required';
|
|
476
|
+
const exists = await db.npcs.where('name').equals(name).first();
|
|
477
|
+
if (exists) return `ERROR: Character ${name} already exists`;
|
|
478
|
+
const alternates = Array.isArray(args.alternate_greetings)
|
|
479
|
+
? (args.alternate_greetings as unknown[])
|
|
480
|
+
.map((g) => String(g ?? '').trim())
|
|
481
|
+
.filter((g, i, arr) => g && g !== greeting && arr.indexOf(g) === i)
|
|
482
|
+
.slice(0, 20)
|
|
483
|
+
.map((g) => g.slice(0, 1000))
|
|
484
|
+
: [];
|
|
485
|
+
await db.npcs.add({
|
|
486
|
+
name,
|
|
487
|
+
prompt,
|
|
488
|
+
greeting,
|
|
489
|
+
alternateGreetings: alternates,
|
|
490
|
+
avatarColorOrdinal: Math.floor(Math.random() * 6),
|
|
491
|
+
avatarDataUrl: null,
|
|
492
|
+
enabledToolNames: [],
|
|
493
|
+
isBuiltIn: false,
|
|
494
|
+
createdAt: Date.now(),
|
|
495
|
+
});
|
|
496
|
+
return translate('tool.characterCreated', { name });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function handleUpdateCharacter(args: Record<string, unknown>): Promise<string> {
|
|
500
|
+
const name = String(args.name ?? '');
|
|
501
|
+
const npc = await db.npcs.where('name').equals(name).first();
|
|
502
|
+
if (!npc) return `ERROR: Character ${name} does not exist`;
|
|
503
|
+
const updates: Partial<import('../../types/models').NpcCharacter> = {};
|
|
504
|
+
if (args.new_name) {
|
|
505
|
+
if (npc.isBuiltIn) return `ERROR: Built-in character ${name} cannot be renamed`;
|
|
506
|
+
const n = String(args.new_name);
|
|
507
|
+
if (await db.npcs.where('name').equals(n).first()) return `ERROR: Character ${n} already exists`;
|
|
508
|
+
updates.name = n;
|
|
509
|
+
}
|
|
510
|
+
if (args.greeting != null) updates.greeting = String(args.greeting).slice(0, 1000);
|
|
511
|
+
if (Array.isArray(args.alternate_greetings)) {
|
|
512
|
+
updates.alternateGreetings = (args.alternate_greetings as unknown[])
|
|
513
|
+
.map((g) => String(g ?? '').trim())
|
|
514
|
+
.filter((g, i, arr) => g && g !== String(args.greeting ?? npc.greeting) && arr.indexOf(g) === i)
|
|
515
|
+
.slice(0, 20)
|
|
516
|
+
.map((g) => g.slice(0, 1000));
|
|
517
|
+
}
|
|
518
|
+
if (args.prompt != null) updates.prompt = String(args.prompt).slice(0, 4000);
|
|
519
|
+
if (Array.isArray(args.enable_skills) || Array.isArray(args.disable_skills)) {
|
|
520
|
+
const allTools = new Set((await db.tools.toArray()).map((t) => t.name));
|
|
521
|
+
const enabled = new Set(npc.enabledToolNames);
|
|
522
|
+
if (Array.isArray(args.enable_skills)) {
|
|
523
|
+
for (const s of args.enable_skills) {
|
|
524
|
+
if (!allTools.has(s)) return `ERROR: Skill ${s} does not exist`;
|
|
525
|
+
enabled.add(s);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (Array.isArray(args.disable_skills)) {
|
|
529
|
+
for (const s of args.disable_skills) {
|
|
530
|
+
enabled.delete(s);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
updates.enabledToolNames = [...enabled];
|
|
534
|
+
}
|
|
535
|
+
if (Object.keys(updates).length > 0) await db.npcs.update(npc.id!, updates);
|
|
536
|
+
return translate('tool.characterUpdated', { name });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function handleDeleteCharacter(args: Record<string, unknown>): Promise<string> {
|
|
540
|
+
const name = String(args.name ?? '');
|
|
541
|
+
const npc = await db.npcs.where('name').equals(name).first();
|
|
542
|
+
if (!npc) return `ERROR: Character ${name} does not exist`;
|
|
543
|
+
if (npc.isBuiltIn) return `ERROR: Built-in character ${name} is protected and cannot be deleted`;
|
|
544
|
+
await db.npcs.delete(npc.id!);
|
|
545
|
+
// 清理会话关联(删除关联会话?App 中保留用户会话,仅解绑)
|
|
546
|
+
const sessions = await db.sessions.where('associatedId').equals(npc.id!).toArray();
|
|
547
|
+
for (const s of sessions) {
|
|
548
|
+
await db.sessions.update(s.id!, { associatedId: null });
|
|
549
|
+
await db.participants.where('[sessionId+participantId]').equals([s.id!, npc.id!]).delete();
|
|
550
|
+
}
|
|
551
|
+
return translate('tool.characterDeleted', { name });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function handleCreateConversation(args: Record<string, unknown>): Promise<string> {
|
|
555
|
+
const rawParticipants = Array.isArray(args.participants)
|
|
556
|
+
? (args.participants as unknown[]).map((name) => String(name ?? '').trim()).filter(Boolean)
|
|
557
|
+
: [];
|
|
558
|
+
if (rawParticipants.length === 0) return 'ERROR: At least one participating character is required';
|
|
559
|
+
if (new Set(rawParticipants).size !== rawParticipants.length) return 'ERROR: participants contains duplicate characters';
|
|
560
|
+
|
|
561
|
+
const npcIds: number[] = [];
|
|
562
|
+
const npcNameToId = new Map<string, number>();
|
|
563
|
+
for (const name of rawParticipants) {
|
|
564
|
+
const npc = await db.npcs.where('name').equals(name).first();
|
|
565
|
+
if (!npc?.id) return `ERROR: Character ${name} does not exist`;
|
|
566
|
+
npcIds.push(npc.id);
|
|
567
|
+
npcNameToId.set(npc.name, npc.id);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let worldBookId: number | null = null;
|
|
571
|
+
if (args.world_book != null) {
|
|
572
|
+
const worldBookName = String(args.world_book).trim();
|
|
573
|
+
if (worldBookName) {
|
|
574
|
+
const worldBook = await db.worldBooks.where('name').equals(worldBookName).first();
|
|
575
|
+
if (!worldBook?.id) return `ERROR: Lorebook ${worldBookName} does not exist`;
|
|
576
|
+
worldBookId = worldBook.id;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let userPersonaNpcId: number | null = null;
|
|
581
|
+
if (args.user_persona != null) {
|
|
582
|
+
const personaName = String(args.user_persona).trim();
|
|
583
|
+
if (personaName) {
|
|
584
|
+
const persona = await db.npcs.where('name').equals(personaName).first();
|
|
585
|
+
if (!persona?.id) return `ERROR: User persona ${personaName} does not exist`;
|
|
586
|
+
userPersonaNpcId = persona.id;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const orderTokens = Array.isArray(args.speaking_order)
|
|
591
|
+
? (args.speaking_order as unknown[]).map((token) => String(token ?? '').trim()).filter(Boolean)
|
|
592
|
+
: [];
|
|
593
|
+
const playerAliases = new Set(['user', 'player', 'me', 'self', 'you', '用户', '用戶', '玩家']);
|
|
594
|
+
const participantOrder: number[] = [];
|
|
595
|
+
for (const token of orderTokens) {
|
|
596
|
+
const normalized = token.toLowerCase();
|
|
597
|
+
if (playerAliases.has(normalized)) {
|
|
598
|
+
if (participantOrder.includes(-1)) return 'ERROR: Player appears more than once in the speaking order';
|
|
599
|
+
participantOrder.push(-1);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const npcId = npcNameToId.get(token);
|
|
603
|
+
if (npcId == null) return `ERROR: Participant ${token} in the speaking order does not exist`;
|
|
604
|
+
if (participantOrder.includes(npcId)) return `ERROR: Participant ${token} appears more than once in the speaking order`;
|
|
605
|
+
participantOrder.push(npcId);
|
|
606
|
+
}
|
|
607
|
+
for (const participantId of [-1, ...npcIds]) {
|
|
608
|
+
if (!participantOrder.includes(participantId)) participantOrder.push(participantId);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const mode = npcIds.length === 1 ? 'NPC' : 'GROUP';
|
|
612
|
+
const sessionId = await createSession(mode, {
|
|
613
|
+
associatedId: npcIds[0],
|
|
614
|
+
npcIds,
|
|
615
|
+
title: String(args.title ?? '').trim().slice(0, 60) || undefined,
|
|
616
|
+
worldBookId,
|
|
617
|
+
userPersonaNpcId,
|
|
618
|
+
turnOrderMode: args.random_order === true ? 'RANDOM' : 'PRESET',
|
|
619
|
+
mentionOnlyMode: args.mention_only_mode === true,
|
|
620
|
+
participantOrder,
|
|
621
|
+
enableGreeting: args.enable_greeting !== false,
|
|
622
|
+
});
|
|
623
|
+
return JSON.stringify({
|
|
624
|
+
session_id: sessionId,
|
|
625
|
+
mode,
|
|
626
|
+
participant_count: npcIds.length + 1,
|
|
627
|
+
random_order: args.random_order === true,
|
|
628
|
+
mention_only_mode: args.mention_only_mode === true && mode === 'GROUP',
|
|
629
|
+
greeting_enabled: args.enable_greeting !== false,
|
|
630
|
+
}, null, 2);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------- Lorebook CRUD ----------
|
|
634
|
+
|
|
635
|
+
async function handleCreateLorebook(args: Record<string, unknown>): Promise<string> {
|
|
636
|
+
const name = String(args.name ?? '').trim();
|
|
637
|
+
const content = String(args.content ?? '');
|
|
638
|
+
if (!name || !content) return 'ERROR: name and content are required';
|
|
639
|
+
if (await db.worldBooks.where('name').equals(name).first()) return `ERROR: Lorebook ${name} already exists`;
|
|
640
|
+
await db.worldBooks.add({ name, content: content.slice(0, 10_000), imageUri: null, createdAt: Date.now() });
|
|
641
|
+
return translate('tool.lorebookCreated', { name });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function handleUpdateLorebook(args: Record<string, unknown>): Promise<string> {
|
|
645
|
+
const name = String(args.name ?? '');
|
|
646
|
+
const book = await db.worldBooks.where('name').equals(name).first();
|
|
647
|
+
if (!book) return `ERROR: Lorebook ${name} does not exist`;
|
|
648
|
+
const updates: Partial<import('../../types/models').WorldBook> = {};
|
|
649
|
+
if (args.new_name) {
|
|
650
|
+
if (isBuiltinTavernHandbook(book)) return `ERROR: 内置世界书「${builtinTavernHandbookName()}」名称不可修改`;
|
|
651
|
+
updates.name = String(args.new_name).slice(0, 60);
|
|
652
|
+
}
|
|
653
|
+
if (args.content != null) updates.content = String(args.content).slice(0, 10_000);
|
|
654
|
+
await db.worldBooks.update(book.id!, updates);
|
|
655
|
+
return translate('tool.lorebookUpdated', { name });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function handleDeleteLorebook(args: Record<string, unknown>): Promise<string> {
|
|
659
|
+
const name = String(args.name ?? '');
|
|
660
|
+
const book = await db.worldBooks.where('name').equals(name).first();
|
|
661
|
+
if (!book) return `ERROR: Lorebook ${name} does not exist`;
|
|
662
|
+
if (isBuiltinTavernHandbook(book)) return `ERROR: 内置世界书「${builtinTavernHandbookName()}」不可删除`;
|
|
663
|
+
await db.worldBooks.delete(book.id!);
|
|
664
|
+
const sessions = await db.sessions.toArray();
|
|
665
|
+
for (const s of sessions) {
|
|
666
|
+
if (s.worldBookId === book.id) await db.sessions.update(s.id!, { worldBookId: null });
|
|
667
|
+
}
|
|
668
|
+
return translate('tool.lorebookDeleted', { name });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ---------- 工具列举 ----------
|
|
672
|
+
|
|
673
|
+
async function listToolNames(): Promise<string[]> {
|
|
674
|
+
const tools = await db.tools.toArray();
|
|
675
|
+
return tools.map((t) => t.name).sort();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 根据会话与当前发言人获取已启用的工具列表(NPC 会话取 associatedId,群聊取当前发言人 NPC) */
|
|
679
|
+
export async function getEnabledToolsForSession(
|
|
680
|
+
sessionId: number,
|
|
681
|
+
activeSpeakerParticipantId: number | null
|
|
682
|
+
): Promise<ChatCompletionTool[]> {
|
|
683
|
+
const session = await db.sessions.get(sessionId);
|
|
684
|
+
if (!session) return [];
|
|
685
|
+
|
|
686
|
+
let npcId: number | null = session.associatedId;
|
|
687
|
+
if (session.mode === 'GROUP' && activeSpeakerParticipantId != null) {
|
|
688
|
+
const p = await db.participants
|
|
689
|
+
.where('[sessionId+participantId]')
|
|
690
|
+
.equals([sessionId, activeSpeakerParticipantId])
|
|
691
|
+
.first();
|
|
692
|
+
npcId = p?.npcId ?? null;
|
|
693
|
+
}
|
|
694
|
+
if (npcId == null) return [];
|
|
695
|
+
const npc = await db.npcs.get(npcId);
|
|
696
|
+
if (!npc) return [];
|
|
697
|
+
|
|
698
|
+
const enabled = new Set(Array.isArray(npc.enabledToolNames) ? npc.enabledToolNames : []);
|
|
699
|
+
if (enabled.size === 0) return [];
|
|
700
|
+
|
|
701
|
+
const allTools = await db.tools.toArray();
|
|
702
|
+
const result: ChatCompletionTool[] = [];
|
|
703
|
+
for (const t of allTools) {
|
|
704
|
+
if (!enabled.has(t.name)) continue;
|
|
705
|
+
const parsed = safeJsonParse(t.jsonContent) as ChatCompletionTool | null;
|
|
706
|
+
if (parsed) result.push(parsed);
|
|
707
|
+
}
|
|
708
|
+
return result;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function safeJsonParse<T>(s: string | null | undefined): T | null {
|
|
712
|
+
if (!s) return null;
|
|
713
|
+
try {
|
|
714
|
+
return JSON.parse(s) as T;
|
|
715
|
+
} catch {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|