hongshi-files commited on
Commit
8d6cbe0
·
verified ·
1 Parent(s): 7e0f6bb

Update main.ts

Browse files
Files changed (1) hide show
  1. main.ts +138 -769
main.ts CHANGED
@@ -1,797 +1,166 @@
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.eyJ0IjoiYXUiLCJ2IjoiMC4wLjAiLCJ1dSI6InNMbnVqMjYwUUNDTDU2REdsWUNkeGc9PSIsImF1IjoiaWRnL2ZEMDdVTkdhSk5sNXpXUGZhUT09IiwicyI6Ii82WUxKeVllWmV1Sm5OY0ttNXM0OEE9PSIsImlhdCI6MTc3MTg1NDEzM30.ugwoeVWoTpsybN1gQMrildlXXJDteEpCiqNgAtCJzbo",
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
  }
 
1
+ import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
2
+
3
+ // 配置区域
4
+ // 建议通过环境变量传入账号密码,或者在启动时交互式输入
5
+ const PUTER_USERNAME = Deno.env.get("PUTER_USERNAME") || "";
6
+ const PUTER_PASSWORD = Deno.env.get("PUTER_PASSWORD") || "";
7
+ const LOGIN_API_URL = "https://api.puter.com/login";
8
+ const PROXY_API_URL = "https://api.puter.com/drivers/call";
9
+
10
+ // 缓存 Token,避免每次请求都重新登录
11
+ let cachedAuthToken: string | null = null;
12
+
13
+ /**
14
+ * [核心功能] 自动登录 Puter 并获取 JWT Token
15
+ * 使用纯 HTTP 请求,无浏览器依赖。
16
+ */
17
+ async function getAuthToken(): Promise<string> {
18
+ if (cachedAuthToken) {
19
+ return cachedAuthToken;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
+ if (!PUTER_USERNAME || !PUTER_PASSWORD) {
23
+ throw new Error("Missing credentials. Please set PUTER_USERNAME and PUTER_PASSWORD env vars.");
 
 
 
 
 
 
 
 
24
  }
25
 
26
+ console.log(`[Auth] Attempting to login as ${PUTER_USERNAME}...`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ // 发起登录请求
29
+ const response = await fetch(LOGIN_API_URL, {
30
+ method: "POST",
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ },
34
+ body: JSON.stringify({
35
+ username: PUTER_USERNAME,
36
+ password: PUTER_PASSWORD,
37
+ // 2026年的 Puter 可能需要一个 client_id 或 specific flag 来表明这是机器登录
38
+ // 这里我们假设基础的用户名密码流程依然有效
39
+ }),
40
+ });
 
 
41
 
42
+ if (!response.ok) {
43
+ const errText = await response.text();
44
+ throw new Error(`Login failed (${response.status}): ${errText}`);
45
  }
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
+ const data = await response.json();
 
 
48
 
49
+ // 解析 Token 字段
50
+ // Puter 通常返回 { token: "..." } 或 { auth_token: "..." } 或 { user: ..., token: ... }
51
+ const token = data.token || data.auth_token || data.user?.token;
52
 
53
+ if (!token) {
54
+ console.error("Login response:", data);
55
+ throw new Error("Login successful but no token found in response.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
 
57
 
58
+ console.log("[Auth] Login successful. Token cached.");
59
+ cachedAuthToken = token;
60
+ return token;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
 
63
+ serve(async (req: Request): Promise<Response> => {
64
+ // 1. 处理 CORS
65
+ if (req.method === "OPTIONS") {
66
+ return new Response(null, {
67
+ status: 204,
68
+ headers: {
69
+ "Access-Control-Allow-Origin": "*",
70
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
71
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
72
+ },
73
+ });
74
+ }
75
 
76
+ const url = new URL(req.url);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
+ // 2. 路由:OpenAI Chat Completions
79
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
 
 
 
 
 
 
 
 
 
 
 
 
80
  try {
81
+ const openaiBody = await req.json();
82
+ const { messages, model = "gpt-5-nano" } = openaiBody;
83
+
84
+ // [关键] 动态获取 Token
85
+ // 如果 Token 失效,这里可以扩展逻辑来重新登录
86
+ const authToken = await getAuthToken();
87
+
88
+ // 3. 构造 Puter 请求
89
+ const puterPayload = {
90
+ interface: "puter-chat-completion",
91
+ driver: "ai-chat",
92
+ method: "complete",
93
+ test_mode: false,
94
+ args: {
95
+ messages: messages,
96
+ model: model,
97
+ },
98
+ auth_token: authToken, // 注入动态获取的 Token
99
+ };
100
 
101
+ // 4. 转发请求
102
+ const puterResponse = await fetch(PROXY_API_URL, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ // 某些 API 可能需要在 Header 中也携带 Authorization,
107
+ // 但根据你之前的日志,Puter 主要读取 body 中的 auth_token
108
+ },
109
+ body: JSON.stringify(puterPayload),
110
+ });
111
+
112
+ if (!puterResponse.ok) {
113
+ // 如果返回 401 或 403,说明 Token 可能过期了
114
+ if (puterResponse.status === 401 || puterResponse.status === 403) {
115
+ console.warn("[Auth] Token expired, clearing cache...");
116
+ cachedAuthToken = null;
117
+ // 这里可以递归重试一次,但在简单示例中直接报错
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
+ const errorText = await puterResponse.text();
120
+ throw new Error(`Puter API Error: ${errorText}`);
121
  }
122
+
123
+ const puterData = await puterResponse.json();
124
+
125
+ // 5. 转换响应 OpenAI 格式
126
+ const openaiResponse = {
127
+ id: `chatcmpl-${crypto.randomUUID()}`,
128
+ object: "chat.completion",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  created: Math.floor(Date.now() / 1000),
130
  model: model,
131
+ choices: [{
132
+ index: puterData.result?.index || 0,
133
+ message: {
134
+ role: puterData.result?.message?.role || "assistant",
135
+ content: puterData.result?.message?.content || "",
136
+ },
137
+ finish_reason: puterData.result?.finish_reason || "stop",
138
+ }],
139
+ usage: {
140
+ prompt_tokens: puterData.result?.usage?.prompt_tokens || 0,
141
+ completion_tokens: puterData.result?.usage?.completion_tokens || 0,
142
+ total_tokens: (puterData.result?.usage?.prompt_tokens || 0) + (puterData.result?.usage?.completion_tokens || 0),
143
+ },
144
  };
 
 
 
 
 
145
 
146
+ return new Response(JSON.stringify(openaiResponse), {
147
+ headers: { "Content-Type": "application/json" },
148
+ });
149
 
150
+ } catch (error) {
151
+ console.error("Proxy Error:", error);
152
+ return new Response(
153
+ JSON.stringify({ error: { message: error.message, type: "proxy_error" } }),
154
+ { status: 500, headers: { "Content-Type": "application/json" } }
155
+ );
156
+ }
157
+ }
158
 
159
+ return new Response("Not Found", { status: 404 });
160
+ });
 
 
 
 
 
 
161
 
162
+ console.log(`[2026-Proxy] Server starting...`);
163
+ // 在启动前检查一次凭据(可选)
164
+ if (!PUTER_USERNAME || !PUTER_PASSWORD) {
165
+ console.warn("[WARN] PUTER_USERNAME and PUTER_PASSWORD not set. Auto-login will fail on first request.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  }