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,364 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 领域模型 — 与 MyAgent-Android 的 Room 实体一一对应
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
export type SessionMode = 'STANDARD' | 'NPC' | 'GROUP';
|
|
6
|
+
export type ParticipantKind = 'PLAYER' | 'NPC';
|
|
7
|
+
export type TurnOrderMode = 'PRESET' | 'RANDOM';
|
|
8
|
+
export type Role = 'system' | 'user' | 'assistant' | 'tool';
|
|
9
|
+
export type ThemeModeId = 'system' | 'light' | 'dark';
|
|
10
|
+
export type ReasoningEffort = 'auto' | 'off' | 'low' | 'medium' | 'xhigh';
|
|
11
|
+
export type ContextCompressionThreshold = 'off' | number;
|
|
12
|
+
/** 界面语言:null = 跟随浏览器语言 */
|
|
13
|
+
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 工具结果展示引用(file_display 等内置展示工具的回看锚点)。
|
|
17
|
+
* 持久化在 ChatMessage.displayRef,供对话流里的「查看」按钮随时重开弹窗。
|
|
18
|
+
*/
|
|
19
|
+
export interface DisplayFileRef {
|
|
20
|
+
/** 工作区相对路径(与 file_read / file_write 同一沙箱工作区) */
|
|
21
|
+
path: string;
|
|
22
|
+
/** 展示方式:text(按扩展名智能渲染)/ image / html */
|
|
23
|
+
kind: 'text' | 'image' | 'html';
|
|
24
|
+
/** 展示标题(默认取文件名) */
|
|
25
|
+
title?: string;
|
|
26
|
+
/** 产生该展示的会话 id(回看时按其专属工作目录解析;null = 共享工作区) */
|
|
27
|
+
sessionId?: number | null;
|
|
28
|
+
/** 提示附带的展示结果文本(工具结果的持久化,回看时无需重读文件) */
|
|
29
|
+
cachedContent?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AppSettings {
|
|
33
|
+
id: number;
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
apiKey: string;
|
|
36
|
+
defaultModel: string;
|
|
37
|
+
defaultProviderId: number | null;
|
|
38
|
+
themeMode: ThemeModeId;
|
|
39
|
+
/** 界面语言;null = 跟随浏览器语言 */
|
|
40
|
+
language: AppLanguage;
|
|
41
|
+
temperature: number;
|
|
42
|
+
topP: number;
|
|
43
|
+
maxTokens: number;
|
|
44
|
+
topK: number;
|
|
45
|
+
frequencyPenalty: number;
|
|
46
|
+
presencePenalty: number;
|
|
47
|
+
repetitionPenalty: number;
|
|
48
|
+
reasoningEffort: ReasoningEffort;
|
|
49
|
+
/** 'off' 或完整对话单元数(1–100);每 N 个单元预压缩,再积累 N 个单元后生效。群聊按玩家发言批次计数。 */
|
|
50
|
+
contextCompressionThreshold: ContextCompressionThreshold;
|
|
51
|
+
seed: number;
|
|
52
|
+
stop: string;
|
|
53
|
+
isStreaming: boolean;
|
|
54
|
+
isThinkingModeEnabled: boolean;
|
|
55
|
+
isToolCallsEnabled: boolean;
|
|
56
|
+
statsResetTime: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ApiProvider {
|
|
60
|
+
id?: number;
|
|
61
|
+
name: string;
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
apiKey: string;
|
|
64
|
+
isEnabled: boolean;
|
|
65
|
+
cachedModelsCsv: string;
|
|
66
|
+
/** JSON object mapping API model IDs to user-facing aliases. */
|
|
67
|
+
modelAliasesJson?: string;
|
|
68
|
+
/** JSON array of model IDs enabled for use. Missing values keep legacy models enabled. */
|
|
69
|
+
enabledModelIdsJson?: string;
|
|
70
|
+
createdAt: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 可在对话模型选择器中选择的模型,模型 id 在不同 Provider 中可重复。 */
|
|
74
|
+
export interface ProviderModelOption {
|
|
75
|
+
providerId: number;
|
|
76
|
+
modelId: string;
|
|
77
|
+
label: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function enabledModelIds(provider: ApiProvider): Set<string> {
|
|
81
|
+
const availableModels = provider.cachedModelsCsv.split(',').map((model) => model.trim()).filter(Boolean);
|
|
82
|
+
if (!provider.enabledModelIdsJson) return new Set(availableModels);
|
|
83
|
+
try {
|
|
84
|
+
const modelIds: unknown = JSON.parse(provider.enabledModelIdsJson);
|
|
85
|
+
if (!Array.isArray(modelIds)) return new Set(availableModels);
|
|
86
|
+
return new Set(modelIds.filter((model): model is string => typeof model === 'string' && availableModels.includes(model)));
|
|
87
|
+
} catch {
|
|
88
|
+
return new Set(availableModels);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function modelAliases(provider: ApiProvider): Record<string, string> {
|
|
93
|
+
try {
|
|
94
|
+
const aliases: unknown = JSON.parse(provider.modelAliasesJson || '{}');
|
|
95
|
+
if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) return {};
|
|
96
|
+
return Object.fromEntries(
|
|
97
|
+
Object.entries(aliases).filter(([model, alias]) => model.trim() && typeof alias === 'string' && alias.trim()),
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function displayModelName(providers: ApiProvider[], model: string, providerId?: number | null): string {
|
|
105
|
+
const provider = providerId == null
|
|
106
|
+
? providers.find((candidate) => candidate.isEnabled && enabledModelIds(candidate).has(model))
|
|
107
|
+
: providers.find((candidate) => candidate.id === providerId && candidate.isEnabled && enabledModelIds(candidate).has(model));
|
|
108
|
+
return provider ? modelAliases(provider)[model]?.trim() || model : model;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface NpcCharacter {
|
|
112
|
+
id?: number;
|
|
113
|
+
name: string;
|
|
114
|
+
prompt: string;
|
|
115
|
+
greeting: string;
|
|
116
|
+
/** 额外开场白(创建对话时随机选用一个,含 greeting) */
|
|
117
|
+
alternateGreetings?: string[];
|
|
118
|
+
avatarColorOrdinal: number;
|
|
119
|
+
avatarDataUrl?: string | null;
|
|
120
|
+
enabledToolNames: string[]; // CSV in DB; array in memory
|
|
121
|
+
isBuiltIn: boolean;
|
|
122
|
+
/** 标识默认内置角色「酒馆老板」,与其他内置角色区分。 */
|
|
123
|
+
isTavernKeeper?: boolean;
|
|
124
|
+
createdAt: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ChatSession {
|
|
128
|
+
id?: number;
|
|
129
|
+
title: string;
|
|
130
|
+
mode: SessionMode;
|
|
131
|
+
associatedId: number | null; // NPC id(NPC 模式)
|
|
132
|
+
worldBookId: number | null;
|
|
133
|
+
userPersonaNpcId: number | null;
|
|
134
|
+
enableGreeting?: boolean;
|
|
135
|
+
/** 群聊仅 @ 模式:玩家必须点名角色,角色未点名时自动把话题交还给玩家。 */
|
|
136
|
+
mentionOnlyMode?: boolean;
|
|
137
|
+
turnOrderMode: TurnOrderMode;
|
|
138
|
+
/**
|
|
139
|
+
* 会话专属沙箱工作目录名(含当前酒馆实例标识和会话 id)。
|
|
140
|
+
* 该会话的所有工具调用(shell / file_read / file_write / 脚本执行等)
|
|
141
|
+
* 都在这个独立目录下进行,会话和酒馆实例之间相互隔离。
|
|
142
|
+
* 内置酒馆老板的 NPC 单人会话例外地固定使用 public;旧会话未设置时使用当前酒馆实例的专属目录。
|
|
143
|
+
*/
|
|
144
|
+
workspaceDir?: string | null;
|
|
145
|
+
turnQueueJson: string;
|
|
146
|
+
/** 各循环的完整初始顺序历史(JSON 二维数组),用于群聊发言队列面板展示完整历史 */
|
|
147
|
+
turnQueueHistoryJson: string;
|
|
148
|
+
loopIndex: number;
|
|
149
|
+
lastMessage: string;
|
|
150
|
+
/** 置顶标记:1 = 置顶(侧边栏会话列表置顶区),0 = 未置顶。默认 0(false) */
|
|
151
|
+
pinned: number;
|
|
152
|
+
updatedAt: number;
|
|
153
|
+
createdAt: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ChatParticipant {
|
|
157
|
+
sessionId: number;
|
|
158
|
+
participantId: number;
|
|
159
|
+
kind: ParticipantKind;
|
|
160
|
+
npcId: number | null;
|
|
161
|
+
displayName: string;
|
|
162
|
+
seatOrder: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ToolCallRecord {
|
|
166
|
+
id: string;
|
|
167
|
+
name: string;
|
|
168
|
+
argumentsJson: string;
|
|
169
|
+
contentOffset: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ChatAttachmentInfo {
|
|
173
|
+
// 保存元信息,dataUrl 单独存字段(避免所有消息都过大)
|
|
174
|
+
displayName: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ChatMessage {
|
|
178
|
+
id?: number;
|
|
179
|
+
sessionId: number;
|
|
180
|
+
role: Role;
|
|
181
|
+
speakerParticipantId: number | null;
|
|
182
|
+
speakerName: string | null;
|
|
183
|
+
content: string;
|
|
184
|
+
/** 会话初始化时写入的角色开场白;不支持重新生成。 */
|
|
185
|
+
isOpeningGreeting?: boolean;
|
|
186
|
+
/** 仅供界面显示与群聊转交的自动 @ 提示;不会进入模型上下文。 */
|
|
187
|
+
displayHandoffMention?: string | null;
|
|
188
|
+
/** 持久化用 JSON 字符串数组 */
|
|
189
|
+
toolCallsJson: string;
|
|
190
|
+
toolCallId: string | null;
|
|
191
|
+
thinkingContent: string | null;
|
|
192
|
+
/** 群聊:该消息所属的循环序号(0 起)。用于对话与发言队列联动滚动 / 点击定位 */
|
|
193
|
+
loopIndex: number | null;
|
|
194
|
+
timestamp: number;
|
|
195
|
+
latencyMs: number | null;
|
|
196
|
+
promptTokens: number;
|
|
197
|
+
completionTokens: number;
|
|
198
|
+
totalTokens: number;
|
|
199
|
+
tokensPerSec: number | null;
|
|
200
|
+
modelUsed: string | null;
|
|
201
|
+
/** dataUrl 或 URL 的附件列表 */
|
|
202
|
+
attachments: string[];
|
|
203
|
+
attachmentInfos: ChatAttachmentInfo[];
|
|
204
|
+
/**
|
|
205
|
+
* 展示类工具(file_display)的结果锚点:JSON 字符串化的 DisplayFileRef。
|
|
206
|
+
* 工具结果消息携带它,UI 据此渲染「查看」按钮并提供弹窗回看。
|
|
207
|
+
*/
|
|
208
|
+
displayRef: string | null;
|
|
209
|
+
rawRequestBody: string | null;
|
|
210
|
+
rawResponseBody: string | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 保留原始消息的上下文压缩记录,仅用于构建后续 LLM 请求。 */
|
|
214
|
+
export interface ContextCompression {
|
|
215
|
+
id?: number;
|
|
216
|
+
sessionId: number;
|
|
217
|
+
summary: string;
|
|
218
|
+
endMessageId: number;
|
|
219
|
+
endTimestamp: number;
|
|
220
|
+
createdAt: number;
|
|
221
|
+
/** 生成该记录时使用的压缩轮数;旧记录缺失时回退到当前设置。 */
|
|
222
|
+
threshold?: number;
|
|
223
|
+
/** 请求上下文压缩时发送给模型的原始请求体。 */
|
|
224
|
+
rawRequestBody: string | null;
|
|
225
|
+
/** 上下文压缩模型返回的原始响应体。 */
|
|
226
|
+
rawResponseBody: string | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface CareerStatsTotal {
|
|
230
|
+
id: number;
|
|
231
|
+
inputTokens: number;
|
|
232
|
+
outputTokens: number;
|
|
233
|
+
totalRounds: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface CareerNpcStat {
|
|
237
|
+
npcId: number;
|
|
238
|
+
npcName: string;
|
|
239
|
+
rounds: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 成就解锁记录 */
|
|
243
|
+
export interface AchievementUnlock {
|
|
244
|
+
id?: number;
|
|
245
|
+
achievementId: string;
|
|
246
|
+
unlockedAt: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface McpTool {
|
|
250
|
+
id?: number;
|
|
251
|
+
name: string;
|
|
252
|
+
jsonContent: string; // 标准 OpenAI tool JSON
|
|
253
|
+
executionJson: string | null; // 生成式技能的声明式实现
|
|
254
|
+
isBuiltIn: boolean;
|
|
255
|
+
/** 来源:builtin 内置(读写受保护)/ custom 自定义(create_skill 创建)/ imported 导入(玩法导入) */
|
|
256
|
+
origin?: 'builtin' | 'custom' | 'imported';
|
|
257
|
+
createdAt: number;
|
|
258
|
+
displayOrder: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface WorldBook {
|
|
262
|
+
id?: number;
|
|
263
|
+
name: string;
|
|
264
|
+
content: string;
|
|
265
|
+
imageUri: string | null;
|
|
266
|
+
/** 内置世界书的名称受保护,但内容允许编辑和重置。 */
|
|
267
|
+
isBuiltIn?: boolean;
|
|
268
|
+
createdAt: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------- 网络层模型 ----------
|
|
272
|
+
|
|
273
|
+
export interface NetworkContentPart {
|
|
274
|
+
type: 'text' | 'image_url' | 'video_url';
|
|
275
|
+
text?: string;
|
|
276
|
+
image_url?: { url: string };
|
|
277
|
+
video_url?: { url: string };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface NetworkMessage {
|
|
281
|
+
role: Role;
|
|
282
|
+
content: string | NetworkContentPart[] | null;
|
|
283
|
+
tool_calls?: NetworkToolCall[];
|
|
284
|
+
tool_call_id?: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface NetworkToolFunction {
|
|
288
|
+
name: string;
|
|
289
|
+
arguments: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface NetworkToolCall {
|
|
293
|
+
id: string;
|
|
294
|
+
type: 'function';
|
|
295
|
+
function: NetworkToolFunction;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface ChatCompletionTool {
|
|
299
|
+
type: 'function';
|
|
300
|
+
function: {
|
|
301
|
+
name: string;
|
|
302
|
+
description?: string;
|
|
303
|
+
parameters: Record<string, unknown>;
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface ChatCompletionRequest {
|
|
308
|
+
model: string;
|
|
309
|
+
messages: NetworkMessage[];
|
|
310
|
+
tools?: ChatCompletionTool[];
|
|
311
|
+
tool_choice?: string;
|
|
312
|
+
temperature: number;
|
|
313
|
+
top_p?: number;
|
|
314
|
+
max_tokens?: number;
|
|
315
|
+
stream: boolean;
|
|
316
|
+
stream_options?: { include_usage: boolean };
|
|
317
|
+
enable_thinking?: boolean;
|
|
318
|
+
reasoning_effort?: string;
|
|
319
|
+
chat_template_kwargs?: { enable_thinking: boolean; preserve_thinking: boolean };
|
|
320
|
+
top_k?: number;
|
|
321
|
+
frequency_penalty?: number;
|
|
322
|
+
presence_penalty?: number;
|
|
323
|
+
repetition_penalty?: number;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------- 流式分块 ----------
|
|
327
|
+
|
|
328
|
+
export type ChatStreamChunk =
|
|
329
|
+
| { type: 'raw'; line: string }
|
|
330
|
+
| { type: 'content'; text: string }
|
|
331
|
+
| { type: 'thinking'; text: string }
|
|
332
|
+
| { type: 'tool_call'; id: string; name: string; argJson: string }
|
|
333
|
+
| { type: 'usage'; prompt: number; completion: number; total: number }
|
|
334
|
+
| { type: 'error'; message: string }
|
|
335
|
+
| { type: 'done' };
|
|
336
|
+
|
|
337
|
+
export interface ToolConfirmationRequest {
|
|
338
|
+
sessionId: number;
|
|
339
|
+
toolName: string;
|
|
340
|
+
title: string;
|
|
341
|
+
message: string;
|
|
342
|
+
inlineCode?: string;
|
|
343
|
+
argsJson: string;
|
|
344
|
+
kind?: 'tool' | 'limit';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface GeneratedSkillExecution {
|
|
348
|
+
type: 'template' | 'http_get' | 'javascript' | 'file_read' | 'file_write' | 'shell';
|
|
349
|
+
// template
|
|
350
|
+
template?: string;
|
|
351
|
+
// http_get
|
|
352
|
+
url?: string;
|
|
353
|
+
// javascript
|
|
354
|
+
code?: string;
|
|
355
|
+
// file_read / file_write
|
|
356
|
+
path?: string;
|
|
357
|
+
content?: string;
|
|
358
|
+
json_content?: unknown;
|
|
359
|
+
append?: boolean;
|
|
360
|
+
append_newline?: boolean;
|
|
361
|
+
// shell
|
|
362
|
+
script?: string;
|
|
363
|
+
[k: string]: unknown;
|
|
364
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"types": ["vite/client"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src"]
|
|
21
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { defineConfig, type Plugin } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CORS 转发中间件:为本地(无 CORS 头)的 OpenAI 兼容服务提供同源代理。
|
|
14
|
+
* Base URL 填 http://localhost:5173/api/192.168.50.175:8788/ 即可;
|
|
15
|
+
* /api/<host>/ 之后的路径(含 query)原样转发到 http://<host>/。
|
|
16
|
+
*/
|
|
17
|
+
function corsProxyPlugin(): Plugin {
|
|
18
|
+
return {
|
|
19
|
+
name: 'cors-proxy',
|
|
20
|
+
configureServer(server) {
|
|
21
|
+
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => {
|
|
22
|
+
const url = req.url || '';
|
|
23
|
+
const m = /^\/api\/([^/]+)(\/[^?]*)?(\?.*)?$/.exec(url);
|
|
24
|
+
if (!m) return next();
|
|
25
|
+
|
|
26
|
+
const rawHost = m[1]; // e.g. 192.168.50.175:8788
|
|
27
|
+
const path = (m[2] || '') + (m[3] || '');
|
|
28
|
+
const isHttps = rawHost.startsWith('https://');
|
|
29
|
+
const host = rawHost.replace(/^https?:\/\//, '');
|
|
30
|
+
const [hostname, portStr] = host.split(':');
|
|
31
|
+
const port = portStr ? parseInt(portStr, 10) : isHttps ? 443 : 80;
|
|
32
|
+
|
|
33
|
+
const mod = isHttps ? https : http;
|
|
34
|
+
const forwardedHeaders: Record<string, string | string[] | undefined> = {
|
|
35
|
+
...(req.headers as Record<string, string | string[] | undefined>),
|
|
36
|
+
host: host,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const proxyReq = mod.request(
|
|
40
|
+
{
|
|
41
|
+
protocol: isHttps ? 'https:' : 'http:',
|
|
42
|
+
hostname,
|
|
43
|
+
port,
|
|
44
|
+
path,
|
|
45
|
+
method: req.method,
|
|
46
|
+
headers: forwardedHeaders,
|
|
47
|
+
},
|
|
48
|
+
(proxyRes) => {
|
|
49
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
50
|
+
proxyRes.pipe(res);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
proxyReq.on('error', (e: Error) => {
|
|
54
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
55
|
+
res.end(`proxy error: ${e.message}`);
|
|
56
|
+
});
|
|
57
|
+
req.pipe(proxyReq);
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 生成式技能真实 Shell 执行端点。
|
|
65
|
+
* 规则:/api-v2/exec 转发到本地 sandbox-server(127.0.0.1:17891,端口从 .sandbox-port 锁文件读取)。
|
|
66
|
+
* 若 sandbox 未启动,会在收到请求时自动拉起 sandbox-server 子进程,无需手动执行 node sandbox-server.mjs。
|
|
67
|
+
*/
|
|
68
|
+
function sandboxProxyPlugin(): Plugin {
|
|
69
|
+
let sandboxChild: ChildProcess | null = null;
|
|
70
|
+
let starting: Promise<number | null> | null = null;
|
|
71
|
+
let childPort: number | null = null;
|
|
72
|
+
const serviceToken = randomBytes(32).toString('base64url');
|
|
73
|
+
const approvalToken = randomBytes(32).toString('base64url');
|
|
74
|
+
const lockPath = join(process.cwd(), '.sandbox-port');
|
|
75
|
+
const SANDBOX_SCRIPT = join(process.cwd(), 'sandbox-server.mjs');
|
|
76
|
+
const DEFAULT_PORT = 17891;
|
|
77
|
+
|
|
78
|
+
const loadPort = () => {
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(lockPath)) {
|
|
81
|
+
const p = parseInt(readFileSync(lockPath, 'utf8').trim(), 10);
|
|
82
|
+
if (!Number.isNaN(p) && p > 0) return p;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** 探测 127.0.0.1:port 是否已有服务监听 */
|
|
91
|
+
const portOpen = (port: number) =>
|
|
92
|
+
new Promise<boolean>((resolve) => {
|
|
93
|
+
const sock = net.connect({ host: '127.0.0.1', port, timeout: 400 });
|
|
94
|
+
sock.once('connect', () => {
|
|
95
|
+
sock.destroy();
|
|
96
|
+
resolve(true);
|
|
97
|
+
});
|
|
98
|
+
sock.once('timeout', () => {
|
|
99
|
+
sock.destroy();
|
|
100
|
+
resolve(false);
|
|
101
|
+
});
|
|
102
|
+
sock.once('error', () => resolve(false));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const serviceReady = (port: number) =>
|
|
106
|
+
new Promise<boolean>((resolve) => {
|
|
107
|
+
const body = JSON.stringify({ script: 'echo ready' });
|
|
108
|
+
const request = http.request({
|
|
109
|
+
hostname: '127.0.0.1',
|
|
110
|
+
port,
|
|
111
|
+
path: '/exec',
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'Content-Length': Buffer.byteLength(body),
|
|
116
|
+
'X-Command-Service-Token': serviceToken,
|
|
117
|
+
},
|
|
118
|
+
timeout: 400,
|
|
119
|
+
}, (response) => {
|
|
120
|
+
response.resume();
|
|
121
|
+
resolve(response.statusCode === 200);
|
|
122
|
+
});
|
|
123
|
+
request.once('timeout', () => {
|
|
124
|
+
request.destroy();
|
|
125
|
+
resolve(false);
|
|
126
|
+
});
|
|
127
|
+
request.once('error', () => resolve(false));
|
|
128
|
+
request.end(body);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const findAvailablePort = async () => {
|
|
132
|
+
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + 20; port++) {
|
|
133
|
+
if (!(await portOpen(port))) return port;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const stopSandbox = () => {
|
|
139
|
+
const child = sandboxChild;
|
|
140
|
+
sandboxChild = null;
|
|
141
|
+
childPort = null;
|
|
142
|
+
try {
|
|
143
|
+
child?.kill();
|
|
144
|
+
} catch {
|
|
145
|
+
/* ignore */
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** 确保 sandbox-server 在运行:已有则复用;否则自动拉起并等待就绪 */
|
|
150
|
+
const ensureSandbox = async (): Promise<number | null> => {
|
|
151
|
+
if (starting) return starting;
|
|
152
|
+
starting = (async () => {
|
|
153
|
+
// 1) 复用已在运行的沙箱(手动启动/上次 vite 拉起)
|
|
154
|
+
const known = loadPort() ?? DEFAULT_PORT;
|
|
155
|
+
if ((await portOpen(known)) && (await serviceReady(known))) return known;
|
|
156
|
+
if (known !== DEFAULT_PORT && (await portOpen(DEFAULT_PORT)) && (await serviceReady(DEFAULT_PORT))) return DEFAULT_PORT;
|
|
157
|
+
// 2) 自动拉起子进程
|
|
158
|
+
if (!sandboxChild && existsSync(SANDBOX_SCRIPT)) {
|
|
159
|
+
const availablePort = await findAvailablePort();
|
|
160
|
+
if (availablePort == null) return null;
|
|
161
|
+
childPort = availablePort;
|
|
162
|
+
const child = spawn(process.execPath, [SANDBOX_SCRIPT, String(availablePort)], {
|
|
163
|
+
cwd: process.cwd(),
|
|
164
|
+
stdio: 'ignore',
|
|
165
|
+
windowsHide: true,
|
|
166
|
+
env: {
|
|
167
|
+
...process.env,
|
|
168
|
+
COMMAND_SERVICE_TOKEN: serviceToken,
|
|
169
|
+
COMMAND_APPROVAL_TOKEN: approvalToken,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
sandboxChild = child;
|
|
173
|
+
child.on('exit', () => {
|
|
174
|
+
if (sandboxChild !== child) return;
|
|
175
|
+
sandboxChild = null;
|
|
176
|
+
childPort = null;
|
|
177
|
+
});
|
|
178
|
+
child.unref();
|
|
179
|
+
process.once('exit', () => {
|
|
180
|
+
try {
|
|
181
|
+
child.kill();
|
|
182
|
+
} catch {
|
|
183
|
+
/* ignore */
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (!sandboxChild) return null;
|
|
188
|
+
// 3) 等待就绪(最多 3s)
|
|
189
|
+
for (let i = 0; i < 30; i++) {
|
|
190
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
191
|
+
if (childPort != null && (await serviceReady(childPort))) return childPort;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
})().finally(() => {
|
|
195
|
+
starting = null;
|
|
196
|
+
});
|
|
197
|
+
return starting;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
name: 'sandbox-proxy',
|
|
202
|
+
closeBundle() {
|
|
203
|
+
stopSandbox();
|
|
204
|
+
},
|
|
205
|
+
configureServer(server) {
|
|
206
|
+
server.watcher.add(SANDBOX_SCRIPT);
|
|
207
|
+
server.watcher.on('change', (path) => {
|
|
208
|
+
if (path === SANDBOX_SCRIPT) stopSandbox();
|
|
209
|
+
});
|
|
210
|
+
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => {
|
|
211
|
+
// 转发与沙箱服务相关的端点:/exec 及真实工作区文件端点
|
|
212
|
+
const m = /^\/api-v2\/(exec|approve|file_read|file_write|file_delete|file_list|session_create|session_delete)$/.exec(req.url || '');
|
|
213
|
+
if (!m || req.method !== 'POST') return next();
|
|
214
|
+
const origin = req.headers.origin;
|
|
215
|
+
if (!origin || req.headers['sec-fetch-site'] !== 'same-origin') {
|
|
216
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
217
|
+
res.end(JSON.stringify({ ok: false, message: 'sandbox access requires a same-origin browser request' }));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let originHost = '';
|
|
221
|
+
try {
|
|
222
|
+
originHost = new URL(origin).host;
|
|
223
|
+
} catch {
|
|
224
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
225
|
+
res.end(JSON.stringify({ ok: false, message: 'invalid origin' }));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (originHost !== req.headers.host) {
|
|
229
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
230
|
+
res.end(JSON.stringify({ ok: false, message: 'cross-origin request denied' }));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
void (async () => {
|
|
234
|
+
const port = await ensureSandbox();
|
|
235
|
+
if (!port) {
|
|
236
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
237
|
+
res.end(JSON.stringify({ ok: false, message: 'sandbox server not running' }));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const forwardedHeaders = {
|
|
241
|
+
...req.headers,
|
|
242
|
+
'x-command-service-token': serviceToken,
|
|
243
|
+
...(m[1] === 'approve' ? { 'x-command-approval-token': approvalToken } : {}),
|
|
244
|
+
};
|
|
245
|
+
const proxyReq = http.request(
|
|
246
|
+
{ protocol: 'http:', hostname: '127.0.0.1', port, path: `/${m[1]}`, method: 'POST', headers: forwardedHeaders },
|
|
247
|
+
(proxyRes) => {
|
|
248
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
249
|
+
proxyRes.pipe(res);
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
proxyReq.on('error', (e: Error) => {
|
|
253
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
254
|
+
res.end(JSON.stringify({ ok: false, message: `sandbox proxy error: ${e.message}` }));
|
|
255
|
+
});
|
|
256
|
+
req.pipe(proxyReq);
|
|
257
|
+
})();
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default defineConfig({
|
|
264
|
+
plugins: [react(), corsProxyPlugin(), sandboxProxyPlugin()],
|
|
265
|
+
server: {
|
|
266
|
+
port: 5173,
|
|
267
|
+
open: false,
|
|
268
|
+
},
|
|
269
|
+
build: {
|
|
270
|
+
target: 'es2022',
|
|
271
|
+
chunkSizeWarningLimit: 1200,
|
|
272
|
+
},
|
|
273
|
+
});
|