hongshi-files commited on
Commit
171f058
·
verified ·
1 Parent(s): 1566b23

Upload 2 files

Browse files
Files changed (2) hide show
  1. Dockerfile +29 -0
  2. main.ts +797 -0
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ FROM denoland/deno:alpine
3
+
4
+ # 设置环境变量
5
+ ENV DENO_DIR=/deno-dir
6
+ ENV IMAGE_DIR=/app/public/images
7
+
8
+ WORKDIR /app
9
+
10
+ # 创建必要的目录并设置权限
11
+ RUN mkdir -p /app/public/images && \
12
+ mkdir -p $DENO_DIR && \
13
+ chown -R deno:deno /app && \
14
+ chown -R deno:deno $DENO_DIR && \
15
+ chmod -R 755 /app/public
16
+
17
+ # 复制文件
18
+ COPY . .
19
+
20
+ # 缓存依赖
21
+ RUN deno cache main.ts
22
+
23
+ # 切换到非root用户
24
+ USER deno
25
+
26
+ EXPOSE 7860
27
+
28
+ # 启动命令,包含所有必要权限
29
+ CMD ["run", "--allow-net", "--allow-env", "--allow-read", "--allow-write", "main.ts"]
main.ts ADDED
@@ -0,0 +1,797 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CONFIG = {
2
+ PROJECT_NAME: "puter-2api",
3
+ PROJECT_VERSION: "1.0.3-deno-pro",
4
+ API_MASTER_KEY: "1", // 建议在运行时通过环境变量覆盖: Deno.env.get("API_MASTER_KEY")
5
+ UPSTREAM_URL: "https://api.puter.com/drivers/call",
6
+ PUTER_AUTH_TOKENS: [
7
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0IjoiYXUiLCJ2IjoiMC4wLjAiLCJ1dSI6Ino4U1N4Z3k2VEJtbDZMTGVOUFVaZVE9PSIsImF1IjoiaWRnL2ZEMDdVTkdhSk5sNXpXUGZhUT09IiwicyI6Inc0UTJ3djM1ZHhwdkkyTlg3L3lWMlE9PSIsImlhdCI6MTc2MzQ5NDg5NX0.rSOf1PJ9ZL6Aup2Tn4mkAnVUHJCNN37tCUSlQZtBBM0",
8
+ ],
9
+ CHAT_MODELS: [
10
+ "gpt-4o-mini", "gpt-4o", "gemini-1.5-flash", "gpt-5.1", "gpt-5.1-chat-latest", "gpt-5-2025-08-07", "gpt-5", "gpt-5-mini-2025-08-07", "gpt-5-mini", "gpt-5-nano-2025-08-07", "gpt-5-nano", "gpt-5-chat-latest", "o1", "o3", "o3-mini", "o4-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "claude-haiku-4-5-20251001", "claude-sonnet-4-5-20250929", "claude-opus-4-1-20250805", "claude-opus-4-1", "claude-opus-4-20250514", "claude-sonnet-4-20250514", "claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest", "claude-3-haiku-20240307", "grok-beta", "grok-vision-beta", "grok-3", "grok-3-fast", "grok-3-mini", "grok-3-mini-fast", "grok-2-vision", "grok-2", "gemini-2.0-flash"
11
+ ],
12
+ IMAGE_MODELS: ["gpt-image-1"],
13
+ VIDEO_MODELS: ["sora-2", "sora-2-pro"],
14
+ DEFAULT_CHAT_MODEL: "gpt-4o-mini",
15
+ DEFAULT_IMAGE_MODEL: "gpt-image-1",
16
+ DEFAULT_VIDEO_MODEL: "sora-2",
17
+ };
18
+
19
+ let tokenIndex = 0;
20
+
21
+ // --- [Deno 入口与路由] ---
22
+ Deno.serve(async (request) => {
23
+ const url = new URL(request.url);
24
+
25
+ // 简单的路由逻辑
26
+ if (url.pathname === '/') {
27
+ return handleUI(request);
28
+ } else if (url.pathname.startsWith('/v1/')) {
29
+ return handleApi(request);
30
+ } else {
31
+ return createErrorResponse(`路径未找到: ${url.pathname}`, 404, 'not_found');
32
+ }
33
+ });
34
+
35
+ // --- [第三部分: API 代理逻辑] ---
36
+ async function handleApi(request) {
37
+ if (request.method === 'OPTIONS') {
38
+ return handleCorsPreflight();
39
+ }
40
+
41
+ const authHeader = request.headers.get('Authorization');
42
+ // 简单的认证检查
43
+ if (CONFIG.API_MASTER_KEY && CONFIG.API_MASTER_KEY !== "1") {
44
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
45
+ return createErrorResponse('需要 Bearer Token 认证。', 401, 'unauthorized');
46
+ }
47
+ const token = authHeader.substring(7);
48
+ if (token !== CONFIG.API_MASTER_KEY) {
49
+ return createErrorResponse('无效的 API Key。', 403, 'invalid_api_key');
50
+ }
51
+ }
52
+
53
+ const url = new URL(request.url);
54
+ const requestId = `puter-${crypto.randomUUID()}`;
55
+
56
+ switch (url.pathname) {
57
+ case '/v1/models':
58
+ return handleModelsRequest(request);
59
+ case '/v1/chat/completions':
60
+ return handleChatCompletions(request, requestId);
61
+ case '/v1/images/generations':
62
+ return handleImageGenerations(request, requestId);
63
+ case '/v1/videos/generations':
64
+ return handleVideoGenerations(request, requestId);
65
+ default:
66
+ return createErrorResponse(`API 路径不支持: ${url.pathname}`, 404, 'not_found');
67
+ }
68
+ }
69
+
70
+ async function handleModelsRequest(request) {
71
+ const allModels = [...CONFIG.CHAT_MODELS, ...CONFIG.IMAGE_MODELS, ...CONFIG.VIDEO_MODELS];
72
+ const modelsData = {
73
+ object: 'list',
74
+ data: allModels.map(modelId => ({
75
+ id: modelId,
76
+ object: 'model',
77
+ created: Math.floor(Date.now() / 1000),
78
+ owned_by: 'puter-2api',
79
+ })),
80
+ };
81
+ return new Response(JSON.stringify(modelsData), {
82
+ headers: corsHeaders({ 'Content-Type': 'application/json; charset=utf-8' })
83
+ });
84
+ }
85
+
86
+ async function handleChatCompletions(request, requestId) {
87
+ if (request.method !== 'POST') {
88
+ return createErrorResponse(`此端点仅接受 POST 请求,但收到了 ${request.method}。`, 405, 'method_not_allowed');
89
+ }
90
+ try {
91
+ const requestData = await request.json();
92
+ const upstreamPayload = createUpstreamPayload('chat', requestData);
93
+ const upstreamResponse = await fetch(CONFIG.UPSTREAM_URL, {
94
+ method: 'POST',
95
+ headers: createUpstreamHeaders(requestId),
96
+ body: JSON.stringify(upstreamPayload),
97
+ });
98
+
99
+ if (!upstreamResponse.ok) {
100
+ return await handleErrorResponse(upstreamResponse);
101
+ }
102
+
103
+ // [v1.1.4] 添加上游响应长度日志
104
+ const upstreamText = await upstreamResponse.clone().text();
105
+ console.error(`[DEBUG ${requestId}] 上游响应长度: ${upstreamText.length}, 样本: ${upstreamText.substring(0, 200)}...`);
106
+
107
+ const transformStream = createUpstreamToOpenAIStream(requestId, requestData.model || CONFIG.DEFAULT_CHAT_MODEL);
108
+
109
+ if (upstreamResponse.body) {
110
+ return new Response(upstreamResponse.body.pipeThrough(transformStream), {
111
+ headers: corsHeaders({
112
+ 'Content-Type': 'text/event-stream; charset=utf-8',
113
+ 'Cache-Control': 'no-cache',
114
+ 'Connection': 'keep-alive',
115
+ 'X-Worker-Trace-ID': requestId,
116
+ }),
117
+ });
118
+ } else {
119
+ return createErrorResponse('上游未返回有效响应体。', 502, 'bad_gateway');
120
+ }
121
+ } catch (e) {
122
+ if (e instanceof SyntaxError) {
123
+ return createErrorResponse('无法解析请求体。请确保它是有效的 JSON 格式。', 400, 'invalid_json');
124
+ }
125
+ console.error('处理聊天请求时发生异常:', e);
126
+ return createErrorResponse(`处理请求时发生内部错误: ${e.message}`, 500, 'internal_server_error');
127
+ }
128
+ }
129
+
130
+ async function handleImageGenerations(request, requestId) {
131
+ if (request.method !== 'POST') {
132
+ return createErrorResponse(`此端点仅接受 POST 请求,但收到了 ${request.method}。`, 405, 'method_not_allowed');
133
+ }
134
+ try {
135
+ const requestData = await request.json();
136
+ const upstreamPayload = createUpstreamPayload('image', requestData);
137
+ const upstreamResponse = await fetch(CONFIG.UPSTREAM_URL, {
138
+ method: 'POST',
139
+ headers: createUpstreamHeaders(requestId),
140
+ body: JSON.stringify(upstreamPayload),
141
+ });
142
+ if (!upstreamResponse.ok) {
143
+ return await handleErrorResponse(upstreamResponse);
144
+ }
145
+ const imageBytes = await upstreamResponse.arrayBuffer();
146
+ const bytes = new Uint8Array(imageBytes);
147
+ let binary = '';
148
+ for (let i = 0; i < bytes.length; i++) {
149
+ binary += String.fromCharCode(bytes[i]);
150
+ }
151
+ const b64_json = btoa(binary);
152
+ const responseData = {
153
+ created: Math.floor(Date.now() / 1000),
154
+ data: [{ b64_json: b64_json }]
155
+ };
156
+ return new Response(JSON.stringify(responseData), {
157
+ headers: corsHeaders({
158
+ 'Content-Type': 'application/json; charset=utf-8',
159
+ 'X-Worker-Trace-ID': requestId,
160
+ }),
161
+ });
162
+ } catch (e) {
163
+ console.error('处理图像生成请求时发生异常:', e);
164
+ return createErrorResponse(`处理请求时发生内部错误: ${e.message}`, 500, 'internal_server_error');
165
+ }
166
+ }
167
+
168
+ async function handleVideoGenerations(request, requestId) {
169
+ return createErrorResponse(
170
+ '此部署版本不支持视频生成功能。该功能可能需要 Puter.com 的高级账户才能使用。',
171
+ 403,
172
+ 'access_denied'
173
+ );
174
+ }
175
+
176
+ // --- 辅助函数 ---
177
+ function _get_auth_token() {
178
+ const token = CONFIG.PUTER_AUTH_TOKENS[tokenIndex];
179
+ tokenIndex = (tokenIndex + 1) % CONFIG.PUTER_AUTH_TOKENS.length;
180
+ return token;
181
+ }
182
+
183
+ function getDriverFromModel(model) {
184
+ if (model.startsWith("gpt") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4")) return "openai-completion";
185
+ if (model.startsWith("claude")) return "claude";
186
+ if (model.startsWith("gemini")) return "gemini";
187
+ if (model.startsWith("grok")) return "xai";
188
+ return "openai-completion";
189
+ }
190
+
191
+ function createUpstreamPayload(type, requestData) {
192
+ const authToken = _get_auth_token();
193
+ switch (type) {
194
+ case 'chat':
195
+ const model = requestData.model || CONFIG.DEFAULT_CHAT_MODEL;
196
+ return {
197
+ interface: "puter-chat-completion",
198
+ driver: getDriverFromModel(model),
199
+ test_mode: false,
200
+ method: "complete",
201
+ args: { messages: requestData.messages, model: model, stream: true },
202
+ auth_token: authToken
203
+ };
204
+ case 'image':
205
+ return {
206
+ interface: "puter-image-generation",
207
+ driver: "openai-image-generation",
208
+ test_mode: false,
209
+ method: "generate",
210
+ args: { model: requestData.model || CONFIG.DEFAULT_IMAGE_MODEL, quality: requestData.quality || "high", prompt: requestData.prompt },
211
+ auth_token: authToken
212
+ };
213
+ case 'video':
214
+ return {
215
+ interface: "puter-video-generation",
216
+ driver: "openai-video-generation",
217
+ test_mode: false,
218
+ method: "generate",
219
+ args: { model: requestData.model || CONFIG.DEFAULT_VIDEO_MODEL, seconds: requestData.seconds || 8, size: requestData.size || "1280x720", prompt: requestData.prompt },
220
+ auth_token: authToken
221
+ };
222
+ }
223
+ }
224
+
225
+ function createUpstreamHeaders(requestId) {
226
+ return {
227
+ 'Content-Type': 'application/json',
228
+ 'Accept': '*/*',
229
+ 'Origin': 'https://docs.puter.com',
230
+ 'Referer': 'https://docs.puter.com/',
231
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
232
+ 'X-Request-ID': requestId,
233
+ };
234
+ }
235
+
236
+ async function handleErrorResponse(response) {
237
+ const errorBody = await response.text();
238
+ console.error(`上游服务错误: ${response.status}`, errorBody);
239
+ try {
240
+ const errorJson = JSON.parse(errorBody);
241
+ if (errorJson.error && errorJson.error.message) {
242
+ return createErrorResponse(`上游服务错误: ${errorJson.error.message}`, response.status, errorJson.error.code || 'upstream_error');
243
+ }
244
+ } catch(e) {}
245
+ return createErrorResponse(`上游服务返回错误 ${response.status}: ${errorBody}`, response.status, 'upstream_error');
246
+ }
247
+
248
+ // [v1.1.4] 增强TransformStream:添加内容跟踪与fallback
249
+ function createUpstreamToOpenAIStream(requestId, model) {
250
+ const encoder = new TextEncoder();
251
+ const decoder = new TextDecoder();
252
+ let buffer = '';
253
+ let contentSent = false;
254
+ return new TransformStream({
255
+ transform(chunk, controller) {
256
+ buffer += decoder.decode(chunk, { stream: true });
257
+ const lines = buffer.split('\n');
258
+ buffer = lines.pop();
259
+ for (const line of lines) {
260
+ if (line.trim()) {
261
+ try {
262
+ const data = JSON.parse(line);
263
+ let content = null;
264
+ if (data.type === 'text' && typeof data.text === 'string') {
265
+ content = data.text;
266
+ } else if (typeof data.text === 'string') {
267
+ content = data.text;
268
+ }
269
+ if (content !== null && content.length > 0) {
270
+ contentSent = true;
271
+ const openAIChunk = {
272
+ id: requestId,
273
+ object: 'chat.completion.chunk',
274
+ created: Math.floor(Date.now() / 1000),
275
+ model: model,
276
+ choices: [{ index: 0, delta: { content: content }, finish_reason: null }],
277
+ };
278
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(openAIChunk)}\n\n`));
279
+ }
280
+ } catch (e) {
281
+ console.error('无法解析上游 NDJSON 数据块:', line, e);
282
+ }
283
+ }
284
+ }
285
+ },
286
+ flush(controller) {
287
+ if (!contentSent) {
288
+ const fallbackContent = "上游未返回有效内容。请检查提示词长度、模型可用性或认证token,并重试。";
289
+ const fallbackChunk = {
290
+ id: requestId,
291
+ object: 'chat.completion.chunk',
292
+ created: Math.floor(Date.now() / 1000),
293
+ model: model,
294
+ choices: [{ index: 0, delta: { content: fallbackContent }, finish_reason: null }],
295
+ };
296
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallbackChunk)}\n\n`));
297
+ console.error(`[DEBUG ${requestId}] 发送fallback响应(无上游内容)`);
298
+ }
299
+ if (buffer && buffer.trim()) {
300
+ try {
301
+ const data = JSON.parse(buffer);
302
+ if (data.text && !contentSent) {
303
+ contentSent = true;
304
+ const openAIChunk = {
305
+ id: requestId,
306
+ object: 'chat.completion.chunk',
307
+ created: Math.floor(Date.now() / 1000),
308
+ model: model,
309
+ choices: [{ index: 0, delta: { content: data.text }, finish_reason: null }],
310
+ };
311
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(openAIChunk)}\n\n`));
312
+ }
313
+ } catch(e) {}
314
+ }
315
+ const finalChunk = {
316
+ id: requestId,
317
+ object: 'chat.completion.chunk',
318
+ created: Math.floor(Date.now() / 1000),
319
+ model: model,
320
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
321
+ };
322
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
323
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
324
+ },
325
+ });
326
+ }
327
+
328
+ function handleCorsPreflight() {
329
+ return new Response(null, { status: 204, headers: corsHeaders() });
330
+ }
331
+
332
+ function createErrorResponse(message, status, code) {
333
+ return new Response(JSON.stringify({ error: { message, type: 'api_error', code } }), {
334
+ status,
335
+ headers: corsHeaders({ 'Content-Type': 'application/json; charset=utf-8' })
336
+ });
337
+ }
338
+
339
+ function corsHeaders(extra = {}) {
340
+ return {
341
+ 'Access-Control-Allow-Origin': '*',
342
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
343
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
344
+ ...extra
345
+ };
346
+ }
347
+
348
+ // --- [第四部分: 开发者驾驶舱 UI] ---
349
+ function handleUI(request) {
350
+ let origin;
351
+ const hostname = new URL(request.url).hostname;
352
+ const protocol = new URL(request.url).protocol;
353
+ const port = new URL(request.url).port;
354
+
355
+ // Deno 默认运行端口可能不同,这里构建完整的 origin
356
+ origin = `${protocol}//${hostname}${port ? ':' + port : ''}`;
357
+
358
+ const allModels = [...CONFIG.CHAT_MODELS, ...CONFIG.IMAGE_MODELS, ...CONFIG.VIDEO_MODELS];
359
+ const html = `<!DOCTYPE html>
360
+ <html lang="zh-CN">
361
+ <head>
362
+ <meta charset="UTF-8">
363
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
364
+ <title>${CONFIG.PROJECT_NAME} - 开发者驾驶舱</title>
365
+ <style>
366
+ :root { --bg-color: #121212; --sidebar-bg: #1E1E1E; --main-bg: #121212; --border-color: #333333; --text-color: #E0E0E0; --text-secondary: #888888; --primary-color: #FFBF00; --primary-hover: #FFD700; --input-bg: #2A2A2A; --error-color: #CF6679; --success-color: #66BB6A; --font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; --font-mono: 'Fira Code', 'Consolas', 'Monaco', monospace; }
367
+ * { box-sizing: border-box; }
368
+ body { font-family: var(--font-family); margin: 0; background-color: var(--bg-color); color: var(--text-color); font-size: 14px; display: flex; height: 100vh; overflow: hidden; }
369
+ .skeleton { background-color: #2a2a2a; background-image: linear-gradient(90deg, #2a2a2a, #3a3a3a, #2a2a2a); background-size: 200% 100%; animation: skeleton-loading 1.5s infinite; border-radius: 4px; }
370
+ @keyframes skeleton-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
371
+ select, textarea, input { background-color: var(--input-bg); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-color); padding: 10px; font-family: var(--font-family); font-size: 14px; width: 100%; }
372
+ select:focus, textarea:focus, input:focus { outline: none; border-color: var(--primary-color); }
373
+ </style>
374
+ </head>
375
+ <body>
376
+ <main-layout></main-layout>
377
+ <template id="main-layout-template">
378
+ <style>
379
+ .layout { display: flex; width: 100%; height: 100vh; }
380
+ .sidebar { width: 380px; flex-shrink: 0; background-color: var(--sidebar-bg); border-right: 1px solid var(--border-color); padding: 20px; display: flex; flex-direction: column; overflow-y: auto; }
381
+ .main-content { flex-grow: 1; display: flex; flex-direction: column; padding: 20px; overflow: hidden; }
382
+ .header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 15px; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); }
383
+ .header h1 { margin: 0; font-size: 20px; }
384
+ .header .version { font-size: 12px; color: var(--text-secondary); margin-left: 8px; }
385
+ .collapsible-section { margin-top: 20px; }
386
+ .collapsible-section summary { cursor: pointer; font-weight: bold; margin-bottom: 10px; list-style: none; }
387
+ .collapsible-section summary::-webkit-details-marker { display: none; }
388
+ .collapsible-section summary::before { content: '▶'; margin-right: 8px; display: inline-block; transition: transform 0.2s; }
389
+ .collapsible-section[open] > summary::before { transform: rotate(90deg); }
390
+ @media (max-width: 768px) { .layout { flex-direction: column; } .sidebar { width: 100%; height: auto; border-right: none; border-bottom: 1px solid var(--border-color); } }
391
+ </style>
392
+ <div class="layout">
393
+ <aside class="sidebar">
394
+ <header class="header">
395
+ <h1>${CONFIG.PROJECT_NAME}<span class="version">v${CONFIG.PROJECT_VERSION}</span></h1>
396
+ <status-indicator></status-indicator>
397
+ </header>
398
+ <info-panel></info-panel>
399
+ <details class="collapsible-section" open><summary>⚙️ 主流客户端集成</summary><client-guides></client-guides></details>
400
+ <details class="collapsible-section"><summary>📚 模型总览</summary><model-list-panel></model-list-panel></details>
401
+ </aside>
402
+ <main class="main-content"><live-terminal></live-terminal></main>
403
+ </div>
404
+ </template>
405
+ <template id="status-indicator-template">
406
+ <style>
407
+ .indicator { display: flex; align-items: center; gap: 8px; font-size: 12px; }
408
+ .dot { width: 10px; height: 10px; border-radius: 50%; transition: background-color 0.3s; }
409
+ .dot.grey { background-color: #555; } .dot.yellow { background-color: #FFBF00; animation: pulse 2s infinite; } .dot.green { background-color: var(--success-color); } .dot.red { background-color: var(--error-color); }
410
+ @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255, 191, 0, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(255, 191, 0, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 191, 0, 0); } }
411
+ </style>
412
+ <div class="indicator"><div id="status-dot" class="dot grey"></div><span id="status-text">正在初始化...</span></div>
413
+ </template>
414
+ <template id="info-panel-template">
415
+ <style>
416
+ .panel { display: flex; flex-direction: column; gap: 12px; } .info-item { display: flex; flex-direction: column; } .info-item label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
417
+ .info-value { background-color: var(--input-bg); padding: 8px 12px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--primary-color); display: flex; align-items: center; justify-content: space-between; word-break: break-all; }
418
+ .info-value.password { -webkit-text-security: disc; } .info-value.visible { -webkit-text-security: none; } .actions { display: flex; gap: 8px; }
419
+ .icon-btn { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 2px; display: flex; align-items: center; } .icon-btn:hover { color: var(--text-color); } .icon-btn svg { width: 16px; height: 16px; } .skeleton { height: 34px; }
420
+ </style>
421
+ <div class="panel">
422
+ <div class="info-item"><label>API 端点</label><div id="api-url" class="info-value skeleton"></div></div>
423
+ <div class="info-item"><label>API 密钥 (Master Key)</label><div id="api-key" class="info-value password skeleton"></div></div>
424
+ </div>
425
+ </template>
426
+ <template id="client-guides-template">
427
+ <style>
428
+ .tabs { display: flex; border-bottom: 1px solid var(--border-color); } .tab { padding: 8px 12px; cursor: pointer; border: none; background: none; color: var(--text-secondary); } .tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); }
429
+ .content { padding: 15px 0; } pre { background-color: var(--input-bg); padding: 12px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; word-break: break-all; position: relative; }
430
+ .copy-code-btn { position: absolute; top: 8px; right: 8px; background: #444; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; padding: 2px 6px; font-size: 12px; } .copy-code-btn:hover { background: #555; } .copy-code-btn.copied { background-color: var(--success-color); color: #121212; }
431
+ </style>
432
+ <div><div class="tabs"></div><div class="content"></div></div>
433
+ </template>
434
+ <template id="model-list-panel-template">
435
+ <style>
436
+ .model-list-container { padding-top: 10px; }
437
+ .model-category h3 { font-size: 14px; color: var(--primary-color); margin: 15px 0 8px 0; border-bottom: 1px solid var(--border-color); padding-bottom: 5px; }
438
+ .model-list { list-style: none; padding: 0; margin: 0; }
439
+ .model-list li { background-color: var(--input-bg); padding: 6px 10px; border-radius: 4px; margin-bottom: 5px; font-family: var(--font-mono); font-size: 12px; }
440
+ </style>
441
+ <div class="model-list-container"></div>
442
+ </template>
443
+ <template id="live-terminal-template">
444
+ <style>
445
+ .terminal { display: flex; flex-direction: column; height: 100%; background-color: var(--sidebar-bg); border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; }
446
+ .mode-tabs { display: flex; border-bottom: 1px solid var(--border-color); flex-shrink: 0; }
447
+ .mode-tab { padding: 10px 15px; cursor: pointer; background: none; border: none; color: var(--text-secondary); font-size: 14px; }
448
+ .mode-tab.active { color: var(--primary-color); border-bottom: 2px solid var(--primary-color); }
449
+ .pro-tag { font-size: 10px; color: var(--primary-color); margin-left: 5px; vertical-align: super; opacity: 0.8; }
450
+ .output-window { flex-grow: 1; padding: 15px; overflow-y: auto; line-height: 1.6; }
451
+ .output-window p, .output-window div { margin: 0 0 1em 0; }
452
+ .output-window .message.user { color: var(--primary-color); font-weight: bold; }
453
+ .output-window .message.assistant { color: var(--text-color); white-space: pre-wrap; }
454
+ .output-window .message.error { color: var(--error-color); }
455
+ .output-window img, .output-window video { max-width: 100%; border-radius: 4px; }
456
+ .input-area { border-top: 1px solid var(--border-color); padding: 15px; display: flex; flex-direction: column; gap: 10px; }
457
+ .tab-content { display: none; } .tab-content.active { display: flex; flex-direction: column; gap: 10px; }
458
+ .param-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
459
+ textarea { flex-grow: 1; resize: none; min-height: 80px; }
460
+ .submit-btn { background-color: var(--primary-color); color: #121212; border: none; border-radius: 4px; padding: 10px 15px; height: 42px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; }
461
+ .submit-btn:hover { background-color: var(--primary-hover); } .submit-btn:disabled { background-color: #555; cursor: not-allowed; }
462
+ .submit-btn.cancel svg { width: 24px; height: 24px; } .submit-btn svg { width: 20px; height: 20px; }
463
+ .placeholder { color: var(--text-secondary); }
464
+ </style>
465
+ <div class="terminal">
466
+ <div class="mode-tabs">
467
+ <button class="mode-tab active" data-mode="chat">文生文</button>
468
+ <button class="mode-tab" data-mode="image">文生图<span class="pro-tag">未修复目前不可用</span></button>
469
+ <button class="mode-tab" data-mode="video">文生视频<span class="pro-tag">需高级账户</span></button>
470
+ </div>
471
+ <div class="output-window"><p class="placeholder">多模态测试终端已就绪。请选择模式并输入指令... (提示:使用较长提示词避免空响应)</p></div>
472
+ <div class="input-area">
473
+ <div id="chat-panel" class="tab-content active">
474
+ <select id="chat-model-select"></select>
475
+ <textarea id="chat-prompt-input" rows="3" placeholder="输入您的对话内容..."></textarea>
476
+ </div>
477
+ <div id="image-panel" class="tab-content">
478
+ <select id="image-model-select"></select>
479
+ <textarea id="image-prompt-input" rows="3" placeholder="输入您的图片描述..."></textarea>
480
+ </div>
481
+ <div id="video-panel" class="tab-content">
482
+ <select id="video-model-select"></select>
483
+ <textarea id="video-prompt-input" rows="3" placeholder="输入您的视频描述... (此功能当前不可用)"></textarea>
484
+ <div class="param-grid">
485
+ <input type="text" id="video-size-input" value="1280x720" placeholder="分辨率 (e.g., 1280x720)">
486
+ <input type="number" id="video-seconds-input" value="8" placeholder="视频时长 (秒)">
487
+ </div>
488
+ </div>
489
+ <button id="submit-btn" class="submit-btn" title="发送/生成">
490
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M3.105 2.289a.75.75 0 00-.826.95l1.414 4.949a.75.75 0 00.95.544l3.239-1.281a.75.75 0 000-1.39L4.23 6.28a.75.75 0 00-.95-.545L1.865 3.45a.75.75 0 00.95-.826l.002-.007.002-.006zm.002 14.422a.75.75 0 00.95.826l1.415-2.28a.75.75 0 00-.545-.95l-3.239-1.28a.75.75 0 00-1.39 0l-1.28 3.239a.75.75 0 00.544.95l4.95 1.414zM12.75 8.5a.75.75 0 000 1.5h5.5a.75.75 0 000-1.5h-5.5z"/></svg>
491
+ </button>
492
+ </div>
493
+ </div>
494
+ </template>
495
+ <script>
496
+ const CLIENT_CONFIG = {
497
+ WORKER_ORIGIN: '${origin}',
498
+ API_MASTER_KEY: '${CONFIG.API_MASTER_KEY}',
499
+ CHAT_MODELS: ${JSON.stringify(CONFIG.CHAT_MODELS)},
500
+ IMAGE_MODELS: ${JSON.stringify(CONFIG.IMAGE_MODELS)},
501
+ VIDEO_MODELS: ${JSON.stringify(CONFIG.VIDEO_MODELS)},
502
+ DEFAULT_CHAT_MODEL: '${CONFIG.DEFAULT_CHAT_MODEL}',
503
+ CUSTOM_MODELS_STRING: '${allModels.map(m => `+${m}`).join(',')}'
504
+ };
505
+ const AppState = { INITIALIZING: 'INITIALIZING', HEALTH_CHECKING: 'HEALTH_CHECKING', READY: 'READY', REQUESTING: 'REQUESTING', STREAMING: 'STREAMING', ERROR: 'ERROR' };
506
+ let currentState = AppState.INITIALIZING;
507
+ let abortController = null;
508
+ class BaseComponent extends HTMLElement {
509
+ constructor(id) {
510
+ super();
511
+ this.attachShadow({ mode: 'open' });
512
+ const template = document.getElementById(id);
513
+ if (template) this.shadowRoot.appendChild(template.content.cloneNode(true));
514
+ }
515
+ }
516
+ class MainLayout extends BaseComponent { constructor() { super('main-layout-template'); } }
517
+ customElements.define('main-layout', MainLayout);
518
+ class StatusIndicator extends BaseComponent {
519
+ constructor() { super('status-indicator-template'); this.dot = this.shadowRoot.getElementById('status-dot'); this.text = this.shadowRoot.getElementById('status-text'); }
520
+ setState(state, message) {
521
+ this.dot.className = 'dot';
522
+ switch (state) {
523
+ case 'checking': this.dot.classList.add('yellow'); break;
524
+ case 'ok': this.dot.classList.add('green'); break;
525
+ case 'error': this.dot.classList.add('red'); break;
526
+ default: this.dot.classList.add('grey'); break;
527
+ }
528
+ this.text.textContent = message;
529
+ }
530
+ }
531
+ customElements.define('status-indicator', StatusIndicator);
532
+ class InfoPanel extends BaseComponent {
533
+ constructor() { super('info-panel-template'); this.apiUrlEl = this.shadowRoot.getElementById('api-url'); this.apiKeyEl = this.shadowRoot.getElementById('api-key'); }
534
+ connectedCallback() { this.render(); }
535
+ render() {
536
+ this.populateField(this.apiUrlEl, CLIENT_CONFIG.WORKER_ORIGIN + '/v1');
537
+ this.populateField(this.apiKeyEl, CLIENT_CONFIG.API_MASTER_KEY, true);
538
+ }
539
+ populateField(el, value, isPassword = false) {
540
+ el.classList.remove('skeleton');
541
+ el.innerHTML = \`<span>\${value}</span><div class="actions">\${isPassword ? '<button class="icon-btn" data-action="toggle-visibility" title="切换可见性"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z"/><path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 010-1.18l.88-1.473a1.65 1.65 0 012.899 0l.88 1.473a1.65 1.65 0 010 1.18l-.88 1.473a1.65 1.65 0 01-2.899 0l-.88-1.473zM18.45 10.59a1.651 1.651 0 010-1.18l.88-1.473a1.65 1.65 0 012.899 0l.88 1.473a1.65 1.65 0 010 1.18l-.88 1.473a1.65 1.65 0 01-2.899 0l-.88-1.473zM10 17a1.651 1.651 0 01-1.18 0l-1.473-.88a1.65 1.65 0 010-2.899l1.473-.88a1.651 1.651 0 011.18 0l1.473.88a1.65 1.65 0 010 2.899l-1.473.88a1.651 1.651 0 01-1.18 0z" clip-rule="evenodd"/></svg></button>' : ''}<button class="icon-btn" data-action="copy" title="复制"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.121A1.5 1.5 0 0117 6.621V16.5a1.5 1.5 0 01-1.5 1.5h-7A1.5 1.5 0 017 16.5v-13z"/><path d="M5 6.5A1.5 1.5 0 016.5 5h3.879a1.5 1.5 0 011.06.44l3.122 3.121A1.5 1.5 0 0115 9.621V14.5a1.5 1.5 0 01-1.5 1.5h-7A1.5 1.5 0 015 14.5v-8z"/></svg></button></div>\`;
542
+ el.querySelector('[data-action="copy"]').addEventListener('click', () => navigator.clipboard.writeText(value));
543
+ if (isPassword) el.querySelector('[data-action="toggle-visibility"]').addEventListener('click', () => el.classList.toggle('visible'));
544
+ }
545
+ }
546
+ customElements.define('info-panel', InfoPanel);
547
+ class ClientGuides extends BaseComponent {
548
+ constructor() { super('client-guides-template'); this.tabs = this.shadowRoot.querySelector('.tabs'); this.content = this.shadowRoot.querySelector('.content'); this.guides = { 'cURL': this.getCurlGuide(), 'Python': this.getPythonGuide(), 'LobeChat': this.getLobeChatGuide(), 'Next-Web': this.getNextWebGuide() }; }
549
+ connectedCallback() {
550
+ Object.keys(this.guides).forEach((name, index) => { const tab = document.createElement('button'); tab.className = 'tab'; tab.textContent = name; if (index === 0) tab.classList.add('active'); tab.addEventListener('click', () => this.switchTab(name)); this.tabs.appendChild(tab); });
551
+ this.switchTab(Object.keys(this.guides)[0]);
552
+ this.content.addEventListener('click', (e) => { const button = e.target.closest('.copy-code-btn'); if (button) { const code = button.closest('pre').querySelector('code').innerText; navigator.clipboard.writeText(code).then(() => { button.textContent = '已复制!'; button.classList.add('copied'); setTimeout(() => { button.textContent = '复制'; button.classList.remove('copied'); }, 2000); }); } });
553
+ }
554
+ switchTab(name) { this.tabs.querySelector('.active')?.classList.remove('active'); const newActiveTab = Array.from(this.tabs.children).find(tab => tab.textContent === name); newActiveTab?.classList.add('active'); this.content.innerHTML = this.guides[name]; }
555
+ getCurlGuide() { return \`<pre><button class="copy-code-btn">复制</button><code>curl --location '\\\${CLIENT_CONFIG.WORKER_ORIGIN}/v1/chat/completions' \\\\<br>--header 'Content-Type: application/json' \\\\<br>--header 'Authorization: Bearer \\\${CLIENT_CONFIG.API_MASTER_KEY}' \\\\<br>--data '{<br> "model": "\\\${CLIENT_CONFIG.DEFAULT_CHAT_MODEL}",<br> "messages": [{"role": "user", "content": "你好"}],<br> "stream": true<br>}'</code></pre>\`; }
556
+ getPythonGuide() { return \`<pre><button class="copy-code-btn">复制</button><code>import openai<br><br>client = openai.OpenAI(<br> api_key="\\\${CLIENT_CONFIG.API_MASTER_KEY}",<br> base_url="\\\${CLIENT_CONFIG.WORKER_ORIGIN}/v1"<br>)<br><br>stream = client.chat.completions.create(<br> model="\\\${CLIENT_CONFIG.DEFAULT_CHAT_MODEL}",<br> messages=[{"role": "user", "content": "你好"}],<br> stream=True,<br>)<br><br>for chunk in stream:<br> print(chunk.choices[0].delta.content or "", end="")</code></pre>\`; }
557
+ getLobeChatGuide() { return \`<p>在 LobeChat 设置中:</p><pre><button class="copy-code-btn">复制</button><code>API Key: \\\${CLIENT_CONFIG.API_MASTER_KEY}<br>API 地址: \\\${CLIENT_CONFIG.WORKER_ORIGIN}<br>模型列表: (请留空或手动填入)</code></pre>\`; }
558
+ getNextWebGuide() { return \`<p>在 ChatGPT-Next-Web 部署时:</p><pre><button class="copy-code-btn">复制</button><code>CODE=\\\${CLIENT_CONFIG.API_MASTER_KEY}<br>BASE_URL=\\\${CLIENT_CONFIG.WORKER_ORIGIN}<br>CUSTOM_MODELS=\\\${CLIENT_CONFIG.CUSTOM_MODELS_STRING}</code></pre>\`; }
559
+ }
560
+ customElements.define('client-guides', ClientGuides);
561
+ class ModelListPanel extends BaseComponent {
562
+ constructor() { super('model-list-panel-template'); this.container = this.shadowRoot.querySelector('.model-list-container'); }
563
+ connectedCallback() { this.render(); }
564
+ render() {
565
+ const categories = { '文生文': CLIENT_CONFIG.CHAT_MODELS, '文生图': CLIENT_CONFIG.IMAGE_MODELS, '文生视频': CLIENT_CONFIG.VIDEO_MODELS };
566
+ for (const [title, models] of Object.entries(categories)) {
567
+ if (models.length > 0) {
568
+ const categoryDiv = document.createElement('div');
569
+ categoryDiv.className = 'model-category';
570
+ categoryDiv.innerHTML = \`<h3>\${title}</h3><ul class="model-list">\${models.map(m => \`<li>\${m}</li>\`).join('')}</ul>\`;
571
+ this.container.appendChild(categoryDiv);
572
+ }
573
+ }
574
+ }
575
+ }
576
+ customElements.define('model-list-panel', ModelListPanel);
577
+ class LiveTerminal extends BaseComponent {
578
+ constructor() {
579
+ super('live-terminal-template');
580
+ this.activeMode = 'chat';
581
+ this.output = this.shadowRoot.querySelector('.output-window');
582
+ this.btn = this.shadowRoot.getElementById('submit-btn');
583
+ this.tabs = this.shadowRoot.querySelectorAll('.mode-tab');
584
+ this.panels = this.shadowRoot.querySelectorAll('.tab-content');
585
+
586
+ this.inputs = {
587
+ chat: { model: this.shadowRoot.getElementById('chat-model-select'), prompt: this.shadowRoot.getElementById('chat-prompt-input') },
588
+ image: { model: this.shadowRoot.getElementById('image-model-select'), prompt: this.shadowRoot.getElementById('image-prompt-input') },
589
+ video: { model: this.shadowRoot.getElementById('video-model-select'), prompt: this.shadowRoot.getElementById('video-prompt-input'), size: this.shadowRoot.getElementById('video-size-input'), seconds: this.shadowRoot.getElementById('video-seconds-input') }
590
+ };
591
+ this.sendIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M3.105 2.289a.75.75 0 00-.826.95l1.414 4.949a.75.75 0 00.95.544l3.239-1.281a.75.75 0 000-1.39L4.23 6.28a.75.75 0 00-.95-.545L1.865 3.45a.75.75 0 00.95-.826l.002-.007.002-.006zm.002 14.422a.75.75 0 00.95.826l1.415-2.28a.75.75 0 00-.545-.95l-3.239-1.28a.75.75 0 00-1.39 0l-1.28 3.239a.75.75 0 00.544.95l4.95 1.414zM12.75 8.5a.75.75 0 000 1.5h5.5a.75.75 0 000-1.5h-5.5z"/></svg>';
592
+ this.cancelIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"/></svg>';
593
+ }
594
+ connectedCallback() {
595
+ this.btn.addEventListener('click', () => this.handleSubmit());
596
+ this.tabs.forEach(tab => tab.addEventListener('click', () => this.switchMode(tab.dataset.mode)));
597
+ this.populateModels();
598
+ }
599
+ populateModels() {
600
+ this.populateSelect(this.inputs.chat.model, CLIENT_CONFIG.CHAT_MODELS);
601
+ this.populateSelect(this.inputs.image.model, CLIENT_CONFIG.IMAGE_MODELS);
602
+ this.populateSelect(this.inputs.video.model, CLIENT_CONFIG.VIDEO_MODELS);
603
+ }
604
+ populateSelect(selectEl, models) {
605
+ if (!selectEl || !models || models.length === 0) return;
606
+ selectEl.innerHTML = models.map(m => \`<option value="\${m}">\${m}</option>\`).join('');
607
+ }
608
+ switchMode(mode) {
609
+ this.activeMode = mode;
610
+ this.tabs.forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
611
+ this.panels.forEach(p => p.classList.toggle('active', p.id === \`\${mode}-panel\`));
612
+ }
613
+ handleSubmit() {
614
+ if (currentState === AppState.REQUESTING || currentState === AppState.STREAMING) {
615
+ this.cancelRequest();
616
+ } else {
617
+ this.startRequest();
618
+ }
619
+ }
620
+ addMessage(role, content, isHtml = false) {
621
+ const el = document.createElement('div');
622
+ el.className = 'message ' + role;
623
+ if (isHtml) {
624
+ el.innerHTML = content;
625
+ } else {
626
+ el.textContent = content;
627
+ }
628
+ this.output.appendChild(el);
629
+ this.output.scrollTop = this.output.scrollHeight;
630
+ return el;
631
+ }
632
+ async startRequest() {
633
+ const currentInputs = this.inputs[this.activeMode];
634
+ const prompt = currentInputs.prompt.value.trim();
635
+ if (!prompt) return;
636
+ setState(AppState.REQUESTING);
637
+ this.output.innerHTML = '';
638
+ this.addMessage('user', prompt);
639
+ abortController = new AbortController();
640
+ try {
641
+ switch (this.activeMode) {
642
+ case 'chat': await this.handleChatRequest(prompt); break;
643
+ case 'image': await this.handleImageRequest(prompt); break;
644
+ case 'video': await this.handleVideoRequest(prompt); break;
645
+ }
646
+ } catch (e) {
647
+ if (e.name !== 'AbortError') {
648
+ this.addMessage('error', '请求失败: ' + e.message);
649
+ setState(AppState.ERROR);
650
+ }
651
+ } finally {
652
+ if (currentState !== AppState.ERROR && currentState !== AppState.INITIALIZING) {
653
+ setState(AppState.READY);
654
+ }
655
+ }
656
+ }
657
+ async handleChatRequest(prompt) {
658
+ const model = this.inputs.chat.model.value;
659
+ const assistantEl = this.addMessage('assistant', '▍');
660
+
661
+ const response = await fetch(CLIENT_CONFIG.WORKER_ORIGIN + '/v1/chat/completions', {
662
+ method: 'POST',
663
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + CLIENT_CONFIG.API_MASTER_KEY },
664
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: true }),
665
+ signal: abortController.signal,
666
+ });
667
+ if (!response.ok) throw new Error((await response.json()).error.message);
668
+ setState(AppState.STREAMING);
669
+ const reader = response.body.getReader();
670
+ const decoder = new TextDecoder();
671
+ let fullResponse = '';
672
+ while (true) {
673
+ const { done, value } = await reader.read();
674
+ if (done) break;
675
+ const chunk = decoder.decode(value);
676
+ const lines = chunk.split('\\n').filter(line => line.startsWith('data:'));
677
+ for (const line of lines) {
678
+ const data = line.substring(5).trim();
679
+ if (data === '[DONE]') {
680
+ assistantEl.textContent = fullResponse || '(无响应内容)';
681
+ if (!fullResponse) {
682
+ this.addMessage('error', '检测到空响应,请检查日志或尝试更详细的提示词。');
683
+ }
684
+ return;
685
+ }
686
+ try {
687
+ const json = JSON.parse(data);
688
+ const delta = json.choices[0].delta.content;
689
+ if (delta) { fullResponse += delta; assistantEl.textContent = fullResponse + '▍'; this.output.scrollTop = this.output.scrollHeight; }
690
+ } catch (e) {}
691
+ }
692
+ }
693
+ assistantEl.textContent = fullResponse || '(无响应内容)';
694
+ if (!fullResponse) {
695
+ this.addMessage('error', '检测到空响应,请检查日志或尝试更详细的提示词。');
696
+ }
697
+ }
698
+ async handleImageRequest(prompt) {
699
+ const model = this.inputs.image.model.value;
700
+ this.addMessage('assistant', '正在生成图片...');
701
+ const response = await fetch(CLIENT_CONFIG.WORKER_ORIGIN + '/v1/images/generations', {
702
+ method: 'POST',
703
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + CLIENT_CONFIG.API_MASTER_KEY },
704
+ body: JSON.stringify({ model, prompt }),
705
+ signal: abortController.signal,
706
+ });
707
+ if (!response.ok) throw new Error((await response.json()).error.message);
708
+ const result = await response.json();
709
+ const b64 = result.data[0].b64_json;
710
+ this.output.innerHTML = '';
711
+ this.addMessage('user', prompt);
712
+ this.addMessage('assistant', \`<img src="data:image/png;base64,\${b64}" alt="Generated Image"> \`, true);
713
+ }
714
+ async handleVideoRequest(prompt) {
715
+ const model = this.inputs.video.model.value;
716
+ const size = this.inputs.video.size.value;
717
+ const seconds = parseInt(this.inputs.video.seconds.value, 10);
718
+ this.addMessage('assistant', '正在请求视频生成...');
719
+ const response = await fetch(CLIENT_CONFIG.WORKER_ORIGIN + '/v1/videos/generations', {
720
+ method: 'POST',
721
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + CLIENT_CONFIG.API_MASTER_KEY },
722
+ body: JSON.stringify({ model, prompt, size, seconds }),
723
+ signal: abortController.signal,
724
+ });
725
+ if (!response.ok) throw new Error((await response.json()).error.message);
726
+ const result = await response.json();
727
+ const url = result.data[0].url;
728
+ this.output.innerHTML = '';
729
+ this.addMessage('user', prompt);
730
+ this.addMessage('assistant', \`<video src="\${url}" controls autoplay muted loop playsinline></video>\`, true);
731
+ }
732
+ cancelRequest() {
733
+ if (abortController) {
734
+ abortController.abort();
735
+ abortController = null;
736
+ }
737
+ setState(AppState.READY);
738
+ }
739
+ updateButton(state) {
740
+ if (state === AppState.REQUESTING || state === AppState.STREAMING) {
741
+ this.btn.innerHTML = this.cancelIcon;
742
+ this.btn.title = "取消";
743
+ this.btn.classList.add('cancel');
744
+ this.btn.disabled = false;
745
+ } else {
746
+ this.btn.innerHTML = this.sendIcon;
747
+ this.btn.title = "发送/生成";
748
+ this.btn.classList.remove('cancel');
749
+ this.btn.disabled = state !== AppState.READY;
750
+ }
751
+ }
752
+ }
753
+ customElements.define('live-terminal', LiveTerminal);
754
+ function setState(newState) {
755
+ currentState = newState;
756
+ const terminal = document.querySelector('live-terminal');
757
+ if (terminal) terminal.updateButton(newState);
758
+ }
759
+ async function healthCheck() {
760
+ const statusIndicator = document.querySelector('main-layout')?.shadowRoot.querySelector('status-indicator');
761
+ if (!statusIndicator) return;
762
+ statusIndicator.setState('checking', '检查服务...');
763
+ try {
764
+ const response = await fetch(CLIENT_CONFIG.WORKER_ORIGIN + '/v1/models', { headers: { 'Authorization': 'Bearer ' + CLIENT_CONFIG.API_MASTER_KEY } });
765
+ if (response.ok) {
766
+ statusIndicator.setState('ok', '服务正常');
767
+ setState(AppState.READY);
768
+ } else {
769
+ const errorData = await response.json();
770
+ throw new Error(errorData.error.message || '未知错误');
771
+ }
772
+ } catch (e) {
773
+ statusIndicator.setState('error', '检查失败');
774
+ setState(AppState.ERROR);
775
+ const terminal = document.querySelector('live-terminal');
776
+ if(terminal) {
777
+ terminal.output.innerHTML = '';
778
+ terminal.addMessage('error', '健康检查失败: ' + e.message);
779
+ }
780
+ }
781
+ }
782
+ document.addEventListener('DOMContentLoaded', () => {
783
+ setState(AppState.INITIALIZING);
784
+ customElements.whenDefined('main-layout').then(() => {
785
+ healthCheck();
786
+ });
787
+ });
788
+ </script>
789
+ </body>
790
+ </html>`;
791
+ return new Response(html, {
792
+ headers: {
793
+ 'Content-Type': 'text/html; charset=utf-8',
794
+ 'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()'
795
+ },
796
+ });
797
+ }