bhg-helper 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/api/app.ts +53 -0
- package/api/index.ts +9 -0
- package/api/lib/logger.ts +65 -0
- package/api/lib/paths.ts +27 -0
- package/api/lib/providers.ts +66 -0
- package/api/lib/repository.ts +153 -0
- package/api/lib/types.ts +43 -0
- package/api/relay/config.ts +76 -0
- package/api/relay/protocol.ts +393 -0
- package/api/relay/server.ts +283 -0
- package/api/routes/backups.ts +73 -0
- package/api/routes/config.ts +197 -0
- package/api/routes/install.ts +158 -0
- package/api/routes/logs.ts +20 -0
- package/api/routes/providers.ts +13 -0
- package/api/routes/relay.ts +106 -0
- package/api/server.ts +40 -0
- package/cli/cli.js +454 -0
- package/dist/assets/index-BjvGHrGe.js +156 -0
- package/dist/assets/index-CQrGCyBr.css +1 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/nodemon.json +10 -0
- package/package.json +82 -0
- package/postcss.config.js +10 -0
- package/scripts/install.bat +32 -0
- package/scripts/start.bat +46 -0
- package/scripts/start.ps1 +45 -0
- package/src/App.tsx +73 -0
- package/src/assets/react.svg +1 -0
- package/src/components/ConsolePanel.tsx +44 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/ErrorBoundary.tsx +54 -0
- package/src/components/Layout.tsx +17 -0
- package/src/components/Page.tsx +130 -0
- package/src/components/Sidebar.tsx +56 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/index.css +1350 -0
- package/src/lib/api.ts +120 -0
- package/src/lib/store.ts +166 -0
- package/src/lib/types.ts +117 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/pages/ConsolePage.tsx +48 -0
- package/src/pages/Dashboard.tsx +101 -0
- package/src/pages/Install.tsx +195 -0
- package/src/pages/Relay.tsx +409 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +13 -0
- package/tsconfig.json +40 -0
- package/vite.config.ts +28 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/backups 列出所有快照
|
|
3
|
+
* POST /api/backups 手动创建备份 { scope }
|
|
4
|
+
* POST /api/backups/restore 恢复 { filename }
|
|
5
|
+
* DELETE /api/backups/:filename 删除
|
|
6
|
+
*/
|
|
7
|
+
import { Router, type Request, type Response } from 'express'
|
|
8
|
+
import {
|
|
9
|
+
createManualBackup,
|
|
10
|
+
deleteBackup,
|
|
11
|
+
listBackups,
|
|
12
|
+
restoreBackup,
|
|
13
|
+
type Scope,
|
|
14
|
+
} from '../lib/repository.js'
|
|
15
|
+
import { log } from '../lib/logger.js'
|
|
16
|
+
|
|
17
|
+
const router = Router()
|
|
18
|
+
|
|
19
|
+
router.get('/', async (_req: Request, res: Response) => {
|
|
20
|
+
try {
|
|
21
|
+
const list = await listBackups()
|
|
22
|
+
res.json({ ok: true, data: list })
|
|
23
|
+
} catch (err) {
|
|
24
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
25
|
+
res.status(500).json({ ok: false, error: msg })
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
router.post('/', async (req: Request, res: Response) => {
|
|
30
|
+
const scope = req.body?.scope as Scope | undefined
|
|
31
|
+
if (!scope || !['bridge', 'credentials', 'settings'].includes(scope)) {
|
|
32
|
+
res.status(400).json({ ok: false, error: 'scope must be bridge | credentials | settings' })
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const result = await createManualBackup(scope)
|
|
37
|
+
res.json({ ok: true, data: result })
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
40
|
+
log('ERR', 'backups', msg)
|
|
41
|
+
res.status(500).json({ ok: false, error: msg })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
router.post('/restore', async (req: Request, res: Response) => {
|
|
46
|
+
const filename = req.body?.filename
|
|
47
|
+
if (typeof filename !== 'string' || !filename) {
|
|
48
|
+
res.status(400).json({ ok: false, error: 'filename required' })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await restoreBackup(filename)
|
|
53
|
+
res.json({ ok: true, data: result })
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
56
|
+
log('ERR', 'backups', msg)
|
|
57
|
+
res.status(500).json({ ok: false, error: msg })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
router.delete('/:filename', async (req: Request, res: Response) => {
|
|
62
|
+
const filename = req.params.filename
|
|
63
|
+
try {
|
|
64
|
+
await deleteBackup(filename)
|
|
65
|
+
res.json({ ok: true })
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
68
|
+
log('ERR', 'backups', msg)
|
|
69
|
+
res.status(500).json({ ok: false, error: msg })
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export default router
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/config — 读取 bridge / credentials / settings 三个文件
|
|
3
|
+
* PUT /api/bridge — 写回 bridge(model catalog、defaults、oauth)
|
|
4
|
+
* PUT /api/credentials — 写回 credentials
|
|
5
|
+
* PUT /api/settings — 写回 settings
|
|
6
|
+
* POST /api/config/reset-catalog — 重置模型目录为 DeepSeek 官方
|
|
7
|
+
*/
|
|
8
|
+
import { Router, type Request, type Response } from 'express'
|
|
9
|
+
import {
|
|
10
|
+
BRIDGE_FILE,
|
|
11
|
+
CREDENTIALS_FILE,
|
|
12
|
+
BHG_HELPER_DIR,
|
|
13
|
+
SETTINGS_FILE,
|
|
14
|
+
} from '../lib/paths.js'
|
|
15
|
+
import { listBackups, readJson, writeJson } from '../lib/repository.js'
|
|
16
|
+
import { log } from '../lib/logger.js'
|
|
17
|
+
import type { BridgeConfig, ModelEntry } from '../lib/types.js'
|
|
18
|
+
|
|
19
|
+
const router = Router()
|
|
20
|
+
|
|
21
|
+
router.get('/', async (_req: Request, res: Response) => {
|
|
22
|
+
const [bridge, credentials, settings] = await Promise.all([
|
|
23
|
+
readJson(BRIDGE_FILE),
|
|
24
|
+
readJson(CREDENTIALS_FILE),
|
|
25
|
+
readJson(SETTINGS_FILE),
|
|
26
|
+
])
|
|
27
|
+
const backups = await listBackups().catch(() => [])
|
|
28
|
+
res.json({
|
|
29
|
+
ok: true,
|
|
30
|
+
data: {
|
|
31
|
+
bhgHelperDir: BHG_HELPER_DIR,
|
|
32
|
+
files: {
|
|
33
|
+
bridge: BRIDGE_FILE,
|
|
34
|
+
credentials: CREDENTIALS_FILE,
|
|
35
|
+
settings: SETTINGS_FILE,
|
|
36
|
+
},
|
|
37
|
+
bridge,
|
|
38
|
+
credentials,
|
|
39
|
+
settings,
|
|
40
|
+
backups,
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
router.put('/bridge', async (req: Request, res: Response) => {
|
|
46
|
+
const body = req.body
|
|
47
|
+
if (!body || typeof body !== 'object') {
|
|
48
|
+
res.status(400).json({ ok: false, error: 'body must be an object' })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await writeJson('bridge', body)
|
|
53
|
+
log('OK', 'config', 'bridge config updated via UI')
|
|
54
|
+
res.json({ ok: true, data: result })
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
57
|
+
log('ERR', 'config', `bridge write failed: ${msg}`)
|
|
58
|
+
res.status(500).json({ ok: false, error: msg })
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
router.put('/credentials', async (req: Request, res: Response) => {
|
|
63
|
+
const body = req.body
|
|
64
|
+
if (!body || typeof body !== 'object') {
|
|
65
|
+
res.status(400).json({ ok: false, error: 'body must be an object' })
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const result = await writeJson('credentials', body)
|
|
70
|
+
log('OK', 'config', 'credentials updated via UI')
|
|
71
|
+
res.json({ ok: true, data: result })
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
74
|
+
log('ERR', 'config', `credentials write failed: ${msg}`)
|
|
75
|
+
res.status(500).json({ ok: false, error: msg })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
router.put('/settings', async (req: Request, res: Response) => {
|
|
80
|
+
const body = req.body
|
|
81
|
+
if (!body || typeof body !== 'object') {
|
|
82
|
+
res.status(400).json({ ok: false, error: 'body must be an object' })
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = await writeJson('settings', body)
|
|
87
|
+
log('OK', 'config', 'settings updated via UI')
|
|
88
|
+
res.json({ ok: true, data: result })
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
91
|
+
log('ERR', 'config', `settings write failed: ${msg}`)
|
|
92
|
+
res.status(500).json({ ok: false, error: msg })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* POST /api/config/reset-catalog
|
|
98
|
+
* 用 DeepSeek 官方模型目录替换 bridge 的 bridgeModelCatalogCache.models
|
|
99
|
+
* 保留 oauthAccount 等其他字段
|
|
100
|
+
* 如果当前激活模型已不存在,自动切到新的 middle default (deepseek-chat)
|
|
101
|
+
*/
|
|
102
|
+
router.post('/reset-catalog', async (_req: Request, res: Response) => {
|
|
103
|
+
try {
|
|
104
|
+
const cur = (await readJson(BRIDGE_FILE)) as BridgeConfig | null
|
|
105
|
+
if (!cur) {
|
|
106
|
+
res.status(404).json({ ok: false, error: 'bridge config not found' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dsModels: ModelEntry[] = [
|
|
111
|
+
mkModel('deepseek-chat', 'DeepSeek V3', 'middle', 'DeepSeek V3 · 通用对话与代码,128K 上下文', 128000, 32000, 64000, 2, 8),
|
|
112
|
+
mkModel('deepseek-reasoner', 'DeepSeek R1', 'large', 'DeepSeek R1 · 强推理,64K 上下文', 64000, 16000, 32000, 4, 16),
|
|
113
|
+
mkModel('deepseek-v4-flash', 'DeepSeek V4 Flash', 'small', 'DeepSeek V4 Flash · 高速低成本版本', 128000, 32000, 64000, 0.5, 2),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
const newDefaults = { large: 'deepseek-reasoner', middle: 'deepseek-chat', small: 'deepseek-v4-flash' }
|
|
117
|
+
const validIds = new Set(dsModels.map((m) => m.id))
|
|
118
|
+
|
|
119
|
+
const next: BridgeConfig = {
|
|
120
|
+
...cur,
|
|
121
|
+
bridgeModelCatalogCache: {
|
|
122
|
+
...(cur.bridgeModelCatalogCache ?? { models: [], defaults: { large: '', middle: '', small: '' } }),
|
|
123
|
+
models: dsModels,
|
|
124
|
+
defaults: newDefaults,
|
|
125
|
+
},
|
|
126
|
+
additionalModelOptionsCache: dsModels.map((m) => ({
|
|
127
|
+
value: m.id,
|
|
128
|
+
label: m.label,
|
|
129
|
+
description: m.description,
|
|
130
|
+
descriptionForModel: m.description,
|
|
131
|
+
})),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result = await writeJson('bridge', next)
|
|
135
|
+
log('OK', 'config', 'model catalog reset to DeepSeek official')
|
|
136
|
+
|
|
137
|
+
// 检查 settings.json 的激活模型是否仍然有效,无效则改写
|
|
138
|
+
let switchedFrom: string | null = null
|
|
139
|
+
try {
|
|
140
|
+
const settings = (await readJson(SETTINGS_FILE)) as { model?: string } | null
|
|
141
|
+
if (settings && typeof settings.model === 'string' && !validIds.has(settings.model)) {
|
|
142
|
+
switchedFrom = settings.model
|
|
143
|
+
const newSettings = { ...settings, model: newDefaults.middle }
|
|
144
|
+
await writeJson('settings', newSettings)
|
|
145
|
+
log('OK', 'config', `active model switched ${settings.model} → ${newDefaults.middle}`)
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// settings 不存在或读不动就不管
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
res.json({
|
|
152
|
+
ok: true,
|
|
153
|
+
data: { ...result, modelCount: dsModels.length, switchedActiveFrom: switchedFrom },
|
|
154
|
+
})
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
157
|
+
log('ERR', 'config', `reset-catalog failed: ${msg}`)
|
|
158
|
+
res.status(500).json({ ok: false, error: msg })
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
function mkModel(
|
|
163
|
+
id: string,
|
|
164
|
+
label: string,
|
|
165
|
+
tier: 'large' | 'middle' | 'small',
|
|
166
|
+
description: string,
|
|
167
|
+
context_window: number,
|
|
168
|
+
default_max_tokens: number,
|
|
169
|
+
max_tokens: number,
|
|
170
|
+
inputPer1M: number,
|
|
171
|
+
outputPer1M: number
|
|
172
|
+
): ModelEntry {
|
|
173
|
+
return {
|
|
174
|
+
id,
|
|
175
|
+
label,
|
|
176
|
+
tier,
|
|
177
|
+
provider: 'DeepSeek',
|
|
178
|
+
context_window,
|
|
179
|
+
default_max_tokens,
|
|
180
|
+
max_tokens,
|
|
181
|
+
pricing: {
|
|
182
|
+
currency: 'CNY',
|
|
183
|
+
quota_type: 'token',
|
|
184
|
+
pricing_group: 'deepseek-official',
|
|
185
|
+
input_per_1m_tokens: inputPer1M,
|
|
186
|
+
output_per_1m_tokens: outputPer1M,
|
|
187
|
+
cache_read_per_1m_tokens: 0,
|
|
188
|
+
cache_write_per_1m_tokens: 0,
|
|
189
|
+
},
|
|
190
|
+
description,
|
|
191
|
+
protocol: 'openai',
|
|
192
|
+
enabled: true,
|
|
193
|
+
available: true,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default router
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安装管理:在 UI 里跑 `npm install`,实时拉日志
|
|
3
|
+
*/
|
|
4
|
+
import { Router } from 'express'
|
|
5
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
6
|
+
import { existsSync, statSync } from 'node:fs'
|
|
7
|
+
import { resolve, dirname } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { log } from '../lib/logger.js'
|
|
10
|
+
|
|
11
|
+
const router = Router()
|
|
12
|
+
|
|
13
|
+
// Cockpit 后端跑在 api/dist/ 或 api/ 下,项目根是上一级
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
15
|
+
const __dirname = dirname(__filename)
|
|
16
|
+
const PROJECT_ROOT = resolve(__dirname, '..', '..')
|
|
17
|
+
const NODE_MODULES = resolve(PROJECT_ROOT, 'node_modules')
|
|
18
|
+
const PACKAGE_JSON = resolve(PROJECT_ROOT, 'package.json')
|
|
19
|
+
|
|
20
|
+
// 单实例:同一时间只允许一个 npm install
|
|
21
|
+
let running: ChildProcess | null = null
|
|
22
|
+
let logBuffer: { ts: number; line: string }[] = []
|
|
23
|
+
let sseClients: Set<import('express').Response> = new Set()
|
|
24
|
+
|
|
25
|
+
function pushLog(line: string) {
|
|
26
|
+
if (!line) return
|
|
27
|
+
const entry = { ts: Date.now(), line: line.replace(/\r$/, '') }
|
|
28
|
+
logBuffer.push(entry)
|
|
29
|
+
if (logBuffer.length > 2000) logBuffer.splice(0, logBuffer.length - 2000)
|
|
30
|
+
for (const res of sseClients) {
|
|
31
|
+
try {
|
|
32
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`)
|
|
33
|
+
} catch { /* dead client */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function checkNode() {
|
|
38
|
+
return new Promise<{ ok: boolean; version?: string; error?: string }>((resolveP) => {
|
|
39
|
+
const p = spawn(process.platform === 'win32' ? 'node.exe' : 'node', ['-v'], { shell: false })
|
|
40
|
+
let out = ''
|
|
41
|
+
p.stdout.on('data', (c) => out += c.toString())
|
|
42
|
+
p.on('error', (e) => resolveP({ ok: false, error: e.message }))
|
|
43
|
+
p.on('close', (code) => {
|
|
44
|
+
if (code === 0) resolveP({ ok: true, version: out.trim() })
|
|
45
|
+
else resolveP({ ok: false, error: `node exited with code ${code}` })
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function checkNpm() {
|
|
51
|
+
return new Promise<{ ok: boolean; version?: string; error?: string }>((resolveP) => {
|
|
52
|
+
const p = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['-v'], { shell: true })
|
|
53
|
+
let out = ''
|
|
54
|
+
p.stdout.on('data', (c) => out += c.toString())
|
|
55
|
+
p.on('error', (e) => resolveP({ ok: false, error: e.message }))
|
|
56
|
+
p.on('close', (code) => {
|
|
57
|
+
if (code === 0) resolveP({ ok: true, version: out.trim() })
|
|
58
|
+
else resolveP({ ok: false, error: `npm exited with code ${code}` })
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
router.get('/status', async (_req, res) => {
|
|
64
|
+
const hasNodeModules = existsSync(NODE_MODULES)
|
|
65
|
+
const hasPackageJson = existsSync(PACKAGE_JSON)
|
|
66
|
+
// 检查 node_modules 是否真的完整安装(有 react 包确认不是空壳)
|
|
67
|
+
const hasReact = hasNodeModules && existsSync(resolve(NODE_MODULES, 'react'))
|
|
68
|
+
const nodeModMtime = hasNodeModules ? statSync(NODE_MODULES).mtimeMs : 0
|
|
69
|
+
const pkgMtime = hasPackageJson ? statSync(PACKAGE_JSON).mtimeMs : 0
|
|
70
|
+
// 需要安装的条件:node_modules 不存在、关键包缺失、或 package.json 更新时间晚于 node_modules
|
|
71
|
+
const needsInstall = !hasNodeModules || !hasReact || (pkgMtime > nodeModMtime)
|
|
72
|
+
|
|
73
|
+
const node = await checkNode()
|
|
74
|
+
const npm = await checkNpm()
|
|
75
|
+
|
|
76
|
+
res.json({
|
|
77
|
+
ok: true,
|
|
78
|
+
data: {
|
|
79
|
+
projectRoot: PROJECT_ROOT,
|
|
80
|
+
hasNodeModules,
|
|
81
|
+
hasPackageJson,
|
|
82
|
+
needsInstall,
|
|
83
|
+
isRunning: !!running,
|
|
84
|
+
logCount: logBuffer.length,
|
|
85
|
+
node: { ok: node.ok, version: node.version, error: node.error },
|
|
86
|
+
npm: { ok: npm.ok, version: npm.version, error: npm.error },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
router.get('/logs', (req, res) => {
|
|
92
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
93
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
94
|
+
res.setHeader('Connection', 'keep-alive')
|
|
95
|
+
res.setHeader('X-Accel-Buffering', 'no')
|
|
96
|
+
res.flushHeaders?.()
|
|
97
|
+
|
|
98
|
+
// 重放历史日志
|
|
99
|
+
for (const e of logBuffer) {
|
|
100
|
+
res.write(`data: ${JSON.stringify(e)}\n\n`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
sseClients.add(res)
|
|
104
|
+
const heartbeat = setInterval(() => {
|
|
105
|
+
try { res.write(':heartbeat\n\n') } catch { /* ignore */ }
|
|
106
|
+
}, 15000)
|
|
107
|
+
|
|
108
|
+
req.on('close', () => {
|
|
109
|
+
clearInterval(heartbeat)
|
|
110
|
+
sseClients.delete(res)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
router.post('/run', (_req, res) => {
|
|
115
|
+
if (running) {
|
|
116
|
+
res.status(409).json({ ok: false, error: 'install already running' })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pushLog(`[bridge] spawning npm install in ${PROJECT_ROOT}`)
|
|
121
|
+
const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
122
|
+
const child = spawn(cmd, ['install', '--no-audit', '--no-fund', '--loglevel=error'], {
|
|
123
|
+
cwd: PROJECT_ROOT,
|
|
124
|
+
shell: true,
|
|
125
|
+
env: { ...process.env, FORCE_COLOR: '0', NPM_CONFIG_COLOR: 'false' },
|
|
126
|
+
})
|
|
127
|
+
running = child
|
|
128
|
+
|
|
129
|
+
child.stdout.on('data', (b) => {
|
|
130
|
+
for (const line of b.toString().split(/\r?\n/)) pushLog(line)
|
|
131
|
+
})
|
|
132
|
+
child.stderr.on('data', (b) => {
|
|
133
|
+
for (const line of b.toString().split(/\r?\n/)) pushLog('[stderr] ' + line)
|
|
134
|
+
})
|
|
135
|
+
child.on('error', (e) => {
|
|
136
|
+
pushLog('[error] ' + e.message)
|
|
137
|
+
log('ERR', 'install', e.message)
|
|
138
|
+
})
|
|
139
|
+
child.on('close', (code) => {
|
|
140
|
+
pushLog(code === 0 ? '[bridge] npm install completed ✓' : `[bridge] npm install failed (exit ${code})`)
|
|
141
|
+
log(code === 0 ? 'OK' : 'ERR', 'install', `npm install exited ${code}`)
|
|
142
|
+
running = null
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
res.json({ ok: true, data: { started: true, pid: child.pid } })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
router.post('/cancel', (_req, res) => {
|
|
149
|
+
if (!running) {
|
|
150
|
+
res.status(400).json({ ok: false, error: 'no install running' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
running.kill('SIGTERM')
|
|
154
|
+
pushLog('[bridge] cancel requested')
|
|
155
|
+
res.json({ ok: true, data: { killed: true } })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
export default router
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/logs 最近 200 条
|
|
3
|
+
* GET /api/logs/stream SSE 实时推送
|
|
4
|
+
*/
|
|
5
|
+
import { Router, type Request, type Response } from 'express'
|
|
6
|
+
import { addSseClient, listLogs } from '../lib/logger.js'
|
|
7
|
+
|
|
8
|
+
const router = Router()
|
|
9
|
+
|
|
10
|
+
router.get('/', (_req: Request, res: Response) => {
|
|
11
|
+
res.json({ ok: true, data: listLogs() })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
router.get('/stream', (req: Request, res: Response) => {
|
|
15
|
+
addSseClient(res)
|
|
16
|
+
// 不结束响应;客户端断开会触发 close 事件
|
|
17
|
+
void req
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export default router
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/providers/presets — 列出预置的 Provider 配置
|
|
3
|
+
*/
|
|
4
|
+
import { Router, type Request, type Response } from 'express'
|
|
5
|
+
import { PROVIDER_PRESETS } from '../lib/providers.js'
|
|
6
|
+
|
|
7
|
+
const router = Router()
|
|
8
|
+
|
|
9
|
+
router.get('/presets', (_req: Request, res: Response) => {
|
|
10
|
+
res.json({ ok: true, data: PROVIDER_PRESETS })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export default router
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/relay/status 中转服务运行状态
|
|
3
|
+
* POST /api/relay/start 启动
|
|
4
|
+
* POST /api/relay/stop 停止
|
|
5
|
+
* POST /api/relay/restart 重启
|
|
6
|
+
* GET /api/relay/config 读配置(Key 脱敏)
|
|
7
|
+
* PUT /api/relay/config 写配置
|
|
8
|
+
*/
|
|
9
|
+
import { Router, type Request, type Response } from 'express'
|
|
10
|
+
import { isRelayRunning, getRelayInfo, startRelay, stopRelay, restartRelay } from '../relay/server.js'
|
|
11
|
+
import { getRelayConfigPath, loadRelayConfig, saveRelayConfig } from '../relay/config.js'
|
|
12
|
+
import { log } from '../lib/logger.js'
|
|
13
|
+
|
|
14
|
+
const router = Router()
|
|
15
|
+
|
|
16
|
+
router.get('/status', async (_req: Request, res: Response) => {
|
|
17
|
+
const cfg = await loadRelayConfig()
|
|
18
|
+
res.json({
|
|
19
|
+
ok: true,
|
|
20
|
+
data: {
|
|
21
|
+
running: isRelayRunning(),
|
|
22
|
+
info: getRelayInfo(),
|
|
23
|
+
config: {
|
|
24
|
+
port: cfg.port,
|
|
25
|
+
deepseekBaseUrl: cfg.deepseekBaseUrl,
|
|
26
|
+
spoofProvider: cfg.spoofProvider,
|
|
27
|
+
verbose: cfg.verbose,
|
|
28
|
+
deepseekApiKey: cfg.deepseekApiKey ? maskKey(cfg.deepseekApiKey) : '',
|
|
29
|
+
modelMap: cfg.modelMap,
|
|
30
|
+
configPath: getRelayConfigPath(),
|
|
31
|
+
apiKeyConfigured: !!cfg.deepseekApiKey,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
router.get('/config', async (_req: Request, res: Response) => {
|
|
38
|
+
const cfg = await loadRelayConfig()
|
|
39
|
+
res.json({
|
|
40
|
+
ok: true,
|
|
41
|
+
data: {
|
|
42
|
+
port: cfg.port,
|
|
43
|
+
deepseekBaseUrl: cfg.deepseekBaseUrl,
|
|
44
|
+
spoofProvider: cfg.spoofProvider,
|
|
45
|
+
verbose: cfg.verbose,
|
|
46
|
+
deepseekApiKey: cfg.deepseekApiKey ? maskKey(cfg.deepseekApiKey) : '',
|
|
47
|
+
modelMap: cfg.modelMap,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
router.put('/config', async (req: Request, res: Response) => {
|
|
53
|
+
const body = req.body
|
|
54
|
+
if (!body || typeof body !== 'object') {
|
|
55
|
+
res.status(400).json({ ok: false, error: 'body must be object' })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
// 接受部分字段
|
|
59
|
+
const update: Record<string, unknown> = {}
|
|
60
|
+
if (typeof body.port === 'number' && body.port > 0 && body.port < 65535) update.port = body.port
|
|
61
|
+
if (typeof body.deepseekBaseUrl === 'string') update.deepseekBaseUrl = body.deepseekBaseUrl.trim()
|
|
62
|
+
if (typeof body.deepseekApiKey === 'string') update.deepseekApiKey = body.deepseekApiKey.trim()
|
|
63
|
+
if (typeof body.spoofProvider === 'string') update.spoofProvider = body.spoofProvider.trim()
|
|
64
|
+
if (typeof body.verbose === 'boolean') update.verbose = body.verbose
|
|
65
|
+
if (body.modelMap && typeof body.modelMap === 'object') update.modelMap = body.modelMap
|
|
66
|
+
|
|
67
|
+
const next = await saveRelayConfig(update as never)
|
|
68
|
+
// 端口或 baseUrl 变了 → 自动重启
|
|
69
|
+
if (isRelayRunning() && (update.port || update.deepseekBaseUrl)) {
|
|
70
|
+
await restartRelay()
|
|
71
|
+
}
|
|
72
|
+
res.json({ ok: true, data: { port: next.port, deepseekBaseUrl: next.deepseekBaseUrl } })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
router.post('/start', async (_req: Request, res: Response) => {
|
|
76
|
+
try {
|
|
77
|
+
await startRelay()
|
|
78
|
+
res.json({ ok: true })
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
81
|
+
log('ERR', 'relay', `start failed: ${msg}`)
|
|
82
|
+
res.status(500).json({ ok: false, error: msg })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
router.post('/stop', async (_req: Request, res: Response) => {
|
|
87
|
+
await stopRelay()
|
|
88
|
+
res.json({ ok: true })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
router.post('/restart', async (_req: Request, res: Response) => {
|
|
92
|
+
try {
|
|
93
|
+
await restartRelay()
|
|
94
|
+
res.json({ ok: true })
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
97
|
+
res.status(500).json({ ok: false, error: msg })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
function maskKey(k: string): string {
|
|
102
|
+
if (k.length <= 8) return '••••'
|
|
103
|
+
return k.slice(0, 4) + '••••' + k.slice(-4)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default router
|
package/api/server.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BHG-helper 后端入口
|
|
3
|
+
* - 3001 端口:API (BHG-helper UI 用)
|
|
4
|
+
* - 8787 端口:中转服务 (Claude Code 用)
|
|
5
|
+
*/
|
|
6
|
+
import app from './app.js'
|
|
7
|
+
import { loadRelayConfig } from './relay/config.js'
|
|
8
|
+
import { startRelay, stopRelay } from './relay/server.js'
|
|
9
|
+
import { log } from './lib/logger.js'
|
|
10
|
+
|
|
11
|
+
const PORT = Number(process.env.PORT || 3001)
|
|
12
|
+
|
|
13
|
+
const server = app.listen(PORT, () => {
|
|
14
|
+
log('OK', 'http', `BHG-helper api listening on http://127.0.0.1:${PORT}`)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// 启动时如果 BHG-helper relay 配置里有 Key,就自动跑中转
|
|
18
|
+
loadRelayConfig().then((cfg) => {
|
|
19
|
+
if (cfg.deepseekApiKey) {
|
|
20
|
+
startRelay().catch((err) => {
|
|
21
|
+
log('ERR', 'relay', `auto-start failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
22
|
+
})
|
|
23
|
+
} else {
|
|
24
|
+
log('INFO', 'relay', 'no deepseek key configured, relay not auto-started')
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
process.on('SIGTERM', () => {
|
|
29
|
+
log('INFO', 'http', 'SIGTERM received, shutting down')
|
|
30
|
+
stopRelay().catch(() => {})
|
|
31
|
+
server.close(() => process.exit(0))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
process.on('SIGINT', () => {
|
|
35
|
+
log('INFO', 'http', 'SIGINT received, shutting down')
|
|
36
|
+
stopRelay().catch(() => {})
|
|
37
|
+
server.close(() => process.exit(0))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export default app
|