@simonyea/holysheep-cli 1.7.53 → 1.7.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/webui.js +49 -0
- package/src/index.js +12 -0
- package/src/tools/claude-process-proxy.js +37 -42
- package/src/webui/index.html +873 -0
- package/src/webui/server.js +506 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HolySheep WebUI — HTTP 服务器 + REST API
|
|
3
|
+
* 仅绑定 127.0.0.1,零新依赖
|
|
4
|
+
*/
|
|
5
|
+
'use strict'
|
|
6
|
+
|
|
7
|
+
const http = require('http')
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const { execSync, spawn } = require('child_process')
|
|
11
|
+
const { loadConfig, saveConfig, getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY, SHOP_URL, CONFIG_FILE } = require('../utils/config')
|
|
12
|
+
const { removeEnvFromShell } = require('../utils/shell')
|
|
13
|
+
const { commandExists } = require('../utils/which')
|
|
14
|
+
const TOOLS = require('../tools')
|
|
15
|
+
const pkg = require('../../package.json')
|
|
16
|
+
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function maskKey(key) {
|
|
20
|
+
if (!key || key.length < 8) return '****'
|
|
21
|
+
return key.slice(0, 6) + '...' + key.slice(-4)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function validateApiKey(apiKey) {
|
|
25
|
+
const fetch = require('node-fetch')
|
|
26
|
+
const res = await fetch(`${BASE_URL_OPENAI}/models`, {
|
|
27
|
+
method: 'GET',
|
|
28
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
29
|
+
timeout: 10000,
|
|
30
|
+
})
|
|
31
|
+
return res.status === 200
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getVersion(tool) {
|
|
35
|
+
if (typeof tool.getVersion === 'function') return tool.getVersion()
|
|
36
|
+
const cmds = {
|
|
37
|
+
'claude-code': 'claude --version',
|
|
38
|
+
'codex': 'codex --version',
|
|
39
|
+
'droid': 'droid --version',
|
|
40
|
+
'gemini-cli': 'gemini --version',
|
|
41
|
+
'opencode': 'opencode --version',
|
|
42
|
+
'openclaw': 'openclaw --version',
|
|
43
|
+
'aider': 'aider --version',
|
|
44
|
+
}
|
|
45
|
+
const cmd = cmds[tool.id]
|
|
46
|
+
if (!cmd) return null
|
|
47
|
+
try {
|
|
48
|
+
return execSync(cmd, { stdio: 'pipe', timeout: 10000 }).toString().trim().split('\n')[0].slice(0, 30)
|
|
49
|
+
} catch { return null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseBody(req) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let body = ''
|
|
55
|
+
req.on('data', chunk => { body += chunk })
|
|
56
|
+
req.on('end', () => {
|
|
57
|
+
try { resolve(body ? JSON.parse(body) : {}) }
|
|
58
|
+
catch { reject(new Error('Invalid JSON')) }
|
|
59
|
+
})
|
|
60
|
+
req.on('error', reject)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function json(res, data, status = 200) {
|
|
65
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
66
|
+
res.end(JSON.stringify(data))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sseStart(res) {
|
|
70
|
+
res.writeHead(200, {
|
|
71
|
+
'Content-Type': 'text/event-stream',
|
|
72
|
+
'Cache-Control': 'no-cache',
|
|
73
|
+
'Connection': 'keep-alive',
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sseEmit(res, data) {
|
|
78
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── AUTO_INSTALL map (from setup.js) ─────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const AUTO_INSTALL = {
|
|
84
|
+
'claude-code': {
|
|
85
|
+
cmd: process.platform === 'win32'
|
|
86
|
+
? 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
|
|
87
|
+
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
88
|
+
},
|
|
89
|
+
'codex': { cmd: 'npm install -g @openai/codex' },
|
|
90
|
+
'gemini-cli': { cmd: 'npm install -g @google/gemini-cli' },
|
|
91
|
+
'opencode': { cmd: 'npm install -g opencode-ai' },
|
|
92
|
+
'openclaw': { cmd: 'npm install -g openclaw@latest --ignore-scripts' },
|
|
93
|
+
'aider': { cmd: 'pip install aider-chat' },
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── UPGRADABLE_TOOLS (from upgrade.js) ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const UPGRADABLE_TOOLS = [
|
|
99
|
+
{
|
|
100
|
+
name: 'Claude Code', id: 'claude-code', command: 'claude',
|
|
101
|
+
versionCmd: 'claude --version', npmPkg: null,
|
|
102
|
+
installCmd: process.platform === 'win32'
|
|
103
|
+
? 'powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
|
|
104
|
+
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
105
|
+
},
|
|
106
|
+
{ name: 'Codex CLI', id: 'codex', command: 'codex', versionCmd: 'codex --version', npmPkg: '@openai/codex', installCmd: 'npm install -g @openai/codex@latest' },
|
|
107
|
+
{ name: 'Gemini CLI', id: 'gemini-cli', command: 'gemini', versionCmd: 'gemini --version', npmPkg: '@google/gemini-cli', installCmd: 'npm install -g @google/gemini-cli@latest' },
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
// ── API Handlers ─────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async function handleStatus(_req, res) {
|
|
113
|
+
const apiKey = getApiKey()
|
|
114
|
+
const config = loadConfig()
|
|
115
|
+
json(res, {
|
|
116
|
+
loggedIn: !!apiKey,
|
|
117
|
+
apiKey: apiKey ? maskKey(apiKey) : null,
|
|
118
|
+
savedAt: config.savedAt || null,
|
|
119
|
+
version: pkg.version,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleLogin(req, res) {
|
|
124
|
+
const body = await parseBody(req)
|
|
125
|
+
const apiKey = (body.apiKey || '').trim()
|
|
126
|
+
if (!apiKey || !apiKey.startsWith('cr_')) {
|
|
127
|
+
return json(res, { success: false, message: 'API Key 必须以 cr_ 开头' }, 400)
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const valid = await validateApiKey(apiKey)
|
|
131
|
+
if (!valid) return json(res, { success: false, message: 'API Key 无效' }, 401)
|
|
132
|
+
saveConfig({ apiKey, savedAt: new Date().toISOString() })
|
|
133
|
+
json(res, { success: true, apiKey: maskKey(apiKey) })
|
|
134
|
+
} catch (e) {
|
|
135
|
+
json(res, { success: false, message: `验证失败: ${e.message}` }, 500)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleLogout(_req, res) {
|
|
140
|
+
const config = loadConfig()
|
|
141
|
+
delete config.apiKey
|
|
142
|
+
delete config.savedAt
|
|
143
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
144
|
+
json(res, { success: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function handleWhoami(_req, res) {
|
|
148
|
+
const apiKey = getApiKey()
|
|
149
|
+
const config = loadConfig()
|
|
150
|
+
if (!apiKey) return json(res, { loggedIn: false })
|
|
151
|
+
let valid = null
|
|
152
|
+
try { valid = await validateApiKey(apiKey) } catch {}
|
|
153
|
+
json(res, { loggedIn: true, apiKey: maskKey(apiKey), savedAt: config.savedAt || null, valid })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function handleBalance(_req, res) {
|
|
157
|
+
const apiKey = getApiKey()
|
|
158
|
+
if (!apiKey) return json(res, { error: '未登录' }, 401)
|
|
159
|
+
try {
|
|
160
|
+
const fetch = require('node-fetch')
|
|
161
|
+
const r = await fetch(`${SHOP_URL}/api/stats/overview`, {
|
|
162
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
163
|
+
timeout: 8000,
|
|
164
|
+
})
|
|
165
|
+
if (r.status === 401) return json(res, { error: 'API Key 无效或已过期' }, 401)
|
|
166
|
+
if (!r.ok) return json(res, { error: `HTTP ${r.status}` }, r.status)
|
|
167
|
+
const data = await r.json()
|
|
168
|
+
json(res, {
|
|
169
|
+
balance: Number(data.balance || 0),
|
|
170
|
+
todayCost: Number(data.todayCost || 0),
|
|
171
|
+
monthCost: Number(data.monthCost || 0),
|
|
172
|
+
totalCalls: Number(data.totalCalls || 0),
|
|
173
|
+
})
|
|
174
|
+
} catch (e) {
|
|
175
|
+
json(res, { error: e.message }, 500)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function handleDoctor(_req, res) {
|
|
180
|
+
const apiKey = getApiKey()
|
|
181
|
+
const nodeMajor = parseInt(process.version.slice(1), 10)
|
|
182
|
+
|
|
183
|
+
// Tools
|
|
184
|
+
const tools = TOOLS.map(t => {
|
|
185
|
+
const installed = t.checkInstalled()
|
|
186
|
+
return {
|
|
187
|
+
id: t.id,
|
|
188
|
+
name: t.name,
|
|
189
|
+
installed,
|
|
190
|
+
configured: installed ? (t.isConfigured?.() || false) : false,
|
|
191
|
+
version: installed ? getVersion(t) : null,
|
|
192
|
+
installCmd: t.installCmd,
|
|
193
|
+
hint: t.hint || null,
|
|
194
|
+
launchCmd: t.launchCmd || null,
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// Connectivity
|
|
199
|
+
let connectivity = { ok: false, modelCount: 0 }
|
|
200
|
+
if (apiKey) {
|
|
201
|
+
try {
|
|
202
|
+
const fetch = require('node-fetch')
|
|
203
|
+
const r = await fetch(`${BASE_URL_ANTHROPIC}/v1/models`, {
|
|
204
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
205
|
+
timeout: 8000,
|
|
206
|
+
})
|
|
207
|
+
if (r.ok) {
|
|
208
|
+
const data = await r.json()
|
|
209
|
+
connectivity = { ok: true, modelCount: data.data?.length || 0 }
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
json(res, {
|
|
215
|
+
node: { version: process.version, ok: nodeMajor >= 16 },
|
|
216
|
+
apiKey: { set: !!apiKey, masked: apiKey ? maskKey(apiKey) : null },
|
|
217
|
+
envVars: {
|
|
218
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
|
|
219
|
+
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || null,
|
|
220
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || null,
|
|
221
|
+
OPENAI_API_KEY: process.env.OPENAI_API_KEY || null,
|
|
222
|
+
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || null,
|
|
223
|
+
},
|
|
224
|
+
tools,
|
|
225
|
+
connectivity,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function handleTools(_req, res) {
|
|
230
|
+
const tools = TOOLS.map(t => {
|
|
231
|
+
const installed = t.checkInstalled()
|
|
232
|
+
return {
|
|
233
|
+
id: t.id,
|
|
234
|
+
name: t.name,
|
|
235
|
+
installed,
|
|
236
|
+
configured: installed ? (t.isConfigured?.() || false) : false,
|
|
237
|
+
version: installed ? getVersion(t) : null,
|
|
238
|
+
installCmd: t.installCmd,
|
|
239
|
+
hint: t.hint || null,
|
|
240
|
+
launchCmd: t.launchCmd || null,
|
|
241
|
+
canAutoInstall: !!AUTO_INSTALL[t.id],
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
json(res, tools)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function handleSetup(req, res) {
|
|
248
|
+
const body = await parseBody(req)
|
|
249
|
+
const { apiKey, models, toolIds, autoInstall } = body
|
|
250
|
+
|
|
251
|
+
sseStart(res)
|
|
252
|
+
|
|
253
|
+
// Step 1: Validate API Key — support using saved key
|
|
254
|
+
let effectiveKey = apiKey
|
|
255
|
+
if (apiKey === '__use_saved__') {
|
|
256
|
+
effectiveKey = getApiKey()
|
|
257
|
+
if (!effectiveKey) {
|
|
258
|
+
sseEmit(res, { type: 'error', message: '未找到已保存的 API Key' })
|
|
259
|
+
return res.end()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!effectiveKey || !effectiveKey.startsWith('cr_')) {
|
|
263
|
+
sseEmit(res, { type: 'error', message: 'API Key 必须以 cr_ 开头' })
|
|
264
|
+
return res.end()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
sseEmit(res, { type: 'progress', step: 'validate', message: '验证 API Key...' })
|
|
268
|
+
try {
|
|
269
|
+
const valid = await validateApiKey(effectiveKey)
|
|
270
|
+
if (!valid) {
|
|
271
|
+
sseEmit(res, { type: 'error', message: 'API Key 无效' })
|
|
272
|
+
return res.end()
|
|
273
|
+
}
|
|
274
|
+
sseEmit(res, { type: 'progress', step: 'validate', status: 'ok', message: 'API Key 验证成功' })
|
|
275
|
+
} catch (e) {
|
|
276
|
+
sseEmit(res, { type: 'error', message: `验证失败: ${e.message}` })
|
|
277
|
+
return res.end()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Step 2: Save API Key
|
|
281
|
+
saveConfig({ apiKey: effectiveKey, savedAt: new Date().toISOString() })
|
|
282
|
+
sseEmit(res, { type: 'progress', step: 'save-key', status: 'ok', message: 'API Key 已保存' })
|
|
283
|
+
|
|
284
|
+
// Step 3: Determine models
|
|
285
|
+
const selectedModels = models && models.length > 0 ? models : ['claude-sonnet-4-6']
|
|
286
|
+
const primaryModel = selectedModels.find(m => m.startsWith('claude-')) || selectedModels[0] || 'claude-sonnet-4-6'
|
|
287
|
+
|
|
288
|
+
// Step 4: Process each tool
|
|
289
|
+
const selectedToolIds = toolIds || []
|
|
290
|
+
const selectedTools = TOOLS.filter(t => selectedToolIds.includes(t.id))
|
|
291
|
+
const results = []
|
|
292
|
+
|
|
293
|
+
for (const tool of selectedTools) {
|
|
294
|
+
const installed = tool.checkInstalled()
|
|
295
|
+
|
|
296
|
+
// Auto-install if needed
|
|
297
|
+
if (!installed && autoInstall && AUTO_INSTALL[tool.id]) {
|
|
298
|
+
sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, message: `正在安装 ${tool.name}...` })
|
|
299
|
+
const installOk = await new Promise(resolve => {
|
|
300
|
+
const child = spawn(AUTO_INSTALL[tool.id].cmd, [], { shell: true })
|
|
301
|
+
let output = ''
|
|
302
|
+
child.stdout?.on('data', chunk => {
|
|
303
|
+
output += chunk.toString()
|
|
304
|
+
sseEmit(res, { type: 'output', tool: tool.name, text: chunk.toString() })
|
|
305
|
+
})
|
|
306
|
+
child.stderr?.on('data', chunk => {
|
|
307
|
+
output += chunk.toString()
|
|
308
|
+
sseEmit(res, { type: 'output', tool: tool.name, text: chunk.toString() })
|
|
309
|
+
})
|
|
310
|
+
child.on('close', code => resolve(code === 0))
|
|
311
|
+
child.on('error', () => resolve(false))
|
|
312
|
+
})
|
|
313
|
+
if (!installOk) {
|
|
314
|
+
sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, status: 'error', message: `${tool.name} 安装失败` })
|
|
315
|
+
results.push({ tool: tool.name, status: 'error', error: '安装失败' })
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, status: 'ok', message: `${tool.name} 安装成功` })
|
|
319
|
+
} else if (!installed) {
|
|
320
|
+
sseEmit(res, { type: 'progress', step: 'skip', tool: tool.name, status: 'skip', message: `${tool.name} 未安装,跳过` })
|
|
321
|
+
results.push({ tool: tool.name, status: 'skip' })
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Configure
|
|
326
|
+
sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, message: `配置 ${tool.name}...` })
|
|
327
|
+
try {
|
|
328
|
+
const result = tool.configure(effectiveKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, primaryModel, selectedModels)
|
|
329
|
+
if (result.manual) {
|
|
330
|
+
sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'manual', message: `${tool.name} 需要手动配置`, steps: result.steps })
|
|
331
|
+
results.push({ tool: tool.name, status: 'manual', steps: result.steps })
|
|
332
|
+
} else if (result.warning) {
|
|
333
|
+
sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'warning', message: result.warning, file: result.file })
|
|
334
|
+
results.push({ tool: tool.name, status: 'warning', warning: result.warning, file: result.file })
|
|
335
|
+
} else {
|
|
336
|
+
sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'ok', message: `${tool.name} 配置成功`, file: result.file, hot: result.hot })
|
|
337
|
+
results.push({ tool: tool.name, status: 'ok', file: result.file, hot: result.hot })
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'error', message: e.message })
|
|
341
|
+
results.push({ tool: tool.name, status: 'error', error: e.message })
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Step 5: Clean env vars
|
|
346
|
+
try {
|
|
347
|
+
const cleaned = removeEnvFromShell(['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL'])
|
|
348
|
+
if (cleaned.length) {
|
|
349
|
+
sseEmit(res, { type: 'progress', step: 'clean-env', status: 'ok', message: `已清理环境变量: ${cleaned.join(', ')}` })
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
|
|
353
|
+
// Done
|
|
354
|
+
const ok = results.filter(r => r.status === 'ok').length
|
|
355
|
+
const errors = results.filter(r => r.status === 'error').length
|
|
356
|
+
sseEmit(res, { type: 'done', summary: { ok, errors, total: results.length, results } })
|
|
357
|
+
res.end()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function handleReset(req, res) {
|
|
361
|
+
const body = await parseBody(req)
|
|
362
|
+
if (!body.confirm) return json(res, { error: '需要确认' }, 400)
|
|
363
|
+
|
|
364
|
+
const results = []
|
|
365
|
+
for (const tool of TOOLS) {
|
|
366
|
+
if (!tool.checkInstalled() && !tool.isConfigured?.()) continue
|
|
367
|
+
try {
|
|
368
|
+
tool.reset()
|
|
369
|
+
results.push({ tool: tool.name, status: 'ok' })
|
|
370
|
+
} catch (e) {
|
|
371
|
+
results.push({ tool: tool.name, status: 'error', error: e.message })
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try { removeEnvFromShell() } catch {}
|
|
376
|
+
saveConfig({ apiKey: '' })
|
|
377
|
+
|
|
378
|
+
json(res, { success: true, results })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function handleUpgrade(_req, res) {
|
|
382
|
+
sseStart(res)
|
|
383
|
+
|
|
384
|
+
for (const tool of UPGRADABLE_TOOLS) {
|
|
385
|
+
const installed = commandExists(tool.command)
|
|
386
|
+
if (!installed) {
|
|
387
|
+
sseEmit(res, { type: 'tool', name: tool.name, status: 'not-installed' })
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let localVer = null
|
|
392
|
+
try {
|
|
393
|
+
const out = execSync(tool.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
|
|
394
|
+
const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
|
|
395
|
+
localVer = m ? m[1] : out.split('\n')[0].slice(0, 30)
|
|
396
|
+
} catch {}
|
|
397
|
+
|
|
398
|
+
sseEmit(res, { type: 'tool', name: tool.name, status: 'upgrading', localVer })
|
|
399
|
+
|
|
400
|
+
const ok = await new Promise(resolve => {
|
|
401
|
+
const child = spawn(tool.installCmd, [], { shell: true })
|
|
402
|
+
child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', name: tool.name, text: chunk.toString() }))
|
|
403
|
+
child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', name: tool.name, text: chunk.toString() }))
|
|
404
|
+
child.on('close', code => resolve(code === 0))
|
|
405
|
+
child.on('error', () => resolve(false))
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
let newVer = null
|
|
409
|
+
try {
|
|
410
|
+
const out = execSync(tool.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
|
|
411
|
+
const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
|
|
412
|
+
newVer = m ? m[1] : null
|
|
413
|
+
} catch {}
|
|
414
|
+
|
|
415
|
+
sseEmit(res, { type: 'tool', name: tool.name, status: ok ? 'ok' : 'error', localVer, newVer })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
sseEmit(res, { type: 'done' })
|
|
419
|
+
res.end()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleToolInstall(req, res) {
|
|
423
|
+
const body = await parseBody(req)
|
|
424
|
+
const { toolId } = body
|
|
425
|
+
if (!toolId || !AUTO_INSTALL[toolId]) {
|
|
426
|
+
return json(res, { error: '不支持自动安装此工具' }, 400)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
sseStart(res)
|
|
430
|
+
sseEmit(res, { type: 'progress', message: `正在安装 ${toolId}...` })
|
|
431
|
+
|
|
432
|
+
const ok = await new Promise(resolve => {
|
|
433
|
+
const child = spawn(AUTO_INSTALL[toolId].cmd, [], { shell: true })
|
|
434
|
+
child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
|
|
435
|
+
child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
|
|
436
|
+
child.on('close', code => resolve(code === 0))
|
|
437
|
+
child.on('error', () => resolve(false))
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
sseEmit(res, { type: 'done', success: ok })
|
|
441
|
+
res.end()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Models list ──────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
function handleModels(_req, res) {
|
|
447
|
+
json(res, [
|
|
448
|
+
{ id: 'gpt-5.4', label: 'GPT 5.4', desc: '通用编码' },
|
|
449
|
+
{ id: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', desc: '编码' },
|
|
450
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: '均衡推荐' },
|
|
451
|
+
{ id: 'claude-opus-4-6', label: 'Opus 4.6', desc: '强力旗舰' },
|
|
452
|
+
{ id: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7', desc: '高速经济版' },
|
|
453
|
+
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: '轻快便宜' },
|
|
454
|
+
])
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Router ───────────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
async function handleRequest(req, res) {
|
|
460
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
461
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
462
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
463
|
+
|
|
464
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end() }
|
|
465
|
+
|
|
466
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
467
|
+
const route = url.pathname
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
// Static
|
|
471
|
+
if (route === '/' || route === '/index.html') {
|
|
472
|
+
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8')
|
|
473
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
474
|
+
return res.end(html)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// API
|
|
478
|
+
if (route === '/api/status' && req.method === 'GET') return await handleStatus(req, res)
|
|
479
|
+
if (route === '/api/login' && req.method === 'POST') return await handleLogin(req, res)
|
|
480
|
+
if (route === '/api/logout' && req.method === 'POST') return await handleLogout(req, res)
|
|
481
|
+
if (route === '/api/whoami' && req.method === 'GET') return await handleWhoami(req, res)
|
|
482
|
+
if (route === '/api/balance' && req.method === 'GET') return await handleBalance(req, res)
|
|
483
|
+
if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
|
|
484
|
+
if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
|
|
485
|
+
if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
|
|
486
|
+
if (route === '/api/setup' && req.method === 'POST') return await handleSetup(req, res)
|
|
487
|
+
if (route === '/api/reset' && req.method === 'POST') return await handleReset(req, res)
|
|
488
|
+
if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
|
|
489
|
+
if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
|
|
490
|
+
|
|
491
|
+
res.writeHead(404)
|
|
492
|
+
res.end('Not Found')
|
|
493
|
+
} catch (e) {
|
|
494
|
+
if (!res.headersSent) {
|
|
495
|
+
json(res, { error: e.message }, 500)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function startServer(port) {
|
|
501
|
+
const server = http.createServer(handleRequest)
|
|
502
|
+
server.listen(port, '127.0.0.1')
|
|
503
|
+
return server
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
module.exports = { startServer }
|