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,195 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { api } from '../lib/api'
|
|
3
|
+
import { Terminal, Download, AlertTriangle, CheckCircle2, Loader2, Play } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
interface InstallStatus {
|
|
6
|
+
projectRoot: string
|
|
7
|
+
hasNodeModules: boolean
|
|
8
|
+
hasPackageJson: boolean
|
|
9
|
+
needsInstall: boolean
|
|
10
|
+
isRunning: boolean
|
|
11
|
+
logCount: number
|
|
12
|
+
node: { ok: boolean; version?: string; error?: string }
|
|
13
|
+
npm: { ok: boolean; version?: string; error?: string }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function Install() {
|
|
17
|
+
const [status, setStatus] = useState<InstallStatus | null>(null)
|
|
18
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
19
|
+
const [busy, setBusy] = useState(false)
|
|
20
|
+
const [error, setError] = useState<string>('')
|
|
21
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
22
|
+
|
|
23
|
+
// 拉状态
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let stop = false
|
|
26
|
+
async function tick() {
|
|
27
|
+
try {
|
|
28
|
+
const s = await api.getInstallStatus()
|
|
29
|
+
if (!stop) setStatus(s as InstallStatus)
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (!stop) setError(String(e))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
void tick()
|
|
35
|
+
const t = setInterval(tick, 2000)
|
|
36
|
+
return () => { stop = true; clearInterval(t) }
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
// SSE 拉日志
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const es = new EventSource('/api/install/logs')
|
|
42
|
+
es.onmessage = (e) => {
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(e.data) as { line: string; ts: number }
|
|
45
|
+
setLogs((prev) => {
|
|
46
|
+
const next = [...prev, data.line]
|
|
47
|
+
return next.length > 1000 ? next.slice(-1000) : next
|
|
48
|
+
})
|
|
49
|
+
} catch { /* ignore */ }
|
|
50
|
+
}
|
|
51
|
+
es.onerror = () => { /* will reconnect */ }
|
|
52
|
+
return () => es.close()
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// 自动滚到底
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
58
|
+
}, [logs])
|
|
59
|
+
|
|
60
|
+
async function handleInstall() {
|
|
61
|
+
setBusy(true)
|
|
62
|
+
setError('')
|
|
63
|
+
setLogs([])
|
|
64
|
+
try {
|
|
65
|
+
await api.runInstall()
|
|
66
|
+
} catch (e) {
|
|
67
|
+
setError(String(e))
|
|
68
|
+
} finally {
|
|
69
|
+
setBusy(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleCancel() {
|
|
74
|
+
setBusy(true)
|
|
75
|
+
try { await api.cancelInstall() } catch { /* */ } finally { setBusy(false) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 安装完成且 needsInstall=false → 自动刷新进入主界面
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (status && !status.isRunning && !status.needsInstall && status.hasNodeModules) {
|
|
81
|
+
const t = setTimeout(() => window.location.reload(), 500)
|
|
82
|
+
return () => clearTimeout(t)
|
|
83
|
+
}
|
|
84
|
+
}, [status])
|
|
85
|
+
|
|
86
|
+
if (!status) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="install-page">
|
|
89
|
+
<div className="install-card">
|
|
90
|
+
<Loader2 className="spin" size={32} />
|
|
91
|
+
<p>检查环境...</p>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { node, npm, hasNodeModules, hasPackageJson, needsInstall, isRunning } = status
|
|
98
|
+
const envOK = node.ok && npm.ok
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="install-page">
|
|
102
|
+
<div className="install-card">
|
|
103
|
+
<div className="install-header">
|
|
104
|
+
<Terminal size={28} />
|
|
105
|
+
<div>
|
|
106
|
+
<h1>BHG-helper</h1>
|
|
107
|
+
<p className="install-subtitle">
|
|
108
|
+
{needsInstall ? '首次使用 — 完成安装即可启动' : '依赖已就绪 — 直接进入'}
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* 环境检查 */}
|
|
114
|
+
<div className="install-checks">
|
|
115
|
+
<CheckRow ok={hasPackageJson} label="package.json" detail={status.projectRoot} />
|
|
116
|
+
<CheckRow ok={hasNodeModules} label="node_modules" detail={hasNodeModules ? '已安装' : '缺失'} />
|
|
117
|
+
<CheckRow ok={node.ok} label="Node.js" detail={node.version || node.error || '未检测到'} />
|
|
118
|
+
<CheckRow ok={npm.ok} label="npm" detail={npm.version || npm.error || '未检测到'} />
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{!envOK && (
|
|
122
|
+
<div className="install-error">
|
|
123
|
+
<AlertTriangle size={20} />
|
|
124
|
+
<div>
|
|
125
|
+
<strong>环境缺失</strong>
|
|
126
|
+
<p>需要先安装 Node.js 18+(自带 npm):<a href="https://nodejs.org/" target="_blank" rel="noreferrer">nodejs.org</a></p>
|
|
127
|
+
<p>安装完后点下方「重新检查」。</p>
|
|
128
|
+
</div>
|
|
129
|
+
<button className="btn" onClick={() => window.location.reload()}>重新检查</button>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{envOK && needsInstall && !isRunning && (
|
|
134
|
+
<div className="install-action">
|
|
135
|
+
<button className="btn btn-primary btn-large" onClick={handleInstall} disabled={busy}>
|
|
136
|
+
<Download size={20} />
|
|
137
|
+
一键安装依赖(约 1-2 分钟)
|
|
138
|
+
</button>
|
|
139
|
+
<p className="install-hint">会执行 <code>npm install</code>,约 200+ 包,无网络代理需求。</p>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{envOK && !needsInstall && !isRunning && (
|
|
144
|
+
<div className="install-done">
|
|
145
|
+
<CheckCircle2 size={28} />
|
|
146
|
+
<div>
|
|
147
|
+
<strong>已就绪</strong>
|
|
148
|
+
<p>所有依赖已安装,正在进入 Cockpit...</p>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{isRunning && (
|
|
154
|
+
<div className="install-action">
|
|
155
|
+
<div className="install-running">
|
|
156
|
+
<Loader2 className="spin" size={20} />
|
|
157
|
+
<span>正在安装依赖...</span>
|
|
158
|
+
</div>
|
|
159
|
+
<button className="btn" onClick={handleCancel} disabled={busy}>取消</button>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* 日志区 */}
|
|
164
|
+
{logs.length > 0 && (
|
|
165
|
+
<div className="install-logs" ref={logRef}>
|
|
166
|
+
{logs.map((line, i) => (
|
|
167
|
+
<div key={i} className={line.includes('[stderr]') || line.includes('failed') ? 'log-err' : 'log-line'}>
|
|
168
|
+
{line}
|
|
169
|
+
</div>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{error && <div className="install-error-text">{error}</div>}
|
|
175
|
+
|
|
176
|
+
<div className="install-footer">
|
|
177
|
+
<span>路径:<code>{status.projectRoot}</code></span>
|
|
178
|
+
<a href="https://github.com/" target="_blank" rel="noreferrer" className="install-link">
|
|
179
|
+
<Play size={12} /> 查看文档
|
|
180
|
+
</a>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function CheckRow({ ok, label, detail }: { ok: boolean; label: string; detail: string }) {
|
|
188
|
+
return (
|
|
189
|
+
<div className="check-row">
|
|
190
|
+
{ok ? <CheckCircle2 size={18} className="check-ok" /> : <AlertTriangle size={18} className="check-warn" />}
|
|
191
|
+
<span className="check-label">{label}</span>
|
|
192
|
+
<code className="check-detail">{detail}</code>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useStore } from '../lib/store'
|
|
3
|
+
import { api } from '../lib/api'
|
|
4
|
+
import Page, { Panel, StatusCard } from '../components/Page'
|
|
5
|
+
import {
|
|
6
|
+
Play,
|
|
7
|
+
Square,
|
|
8
|
+
RotateCcw,
|
|
9
|
+
Save,
|
|
10
|
+
Eye,
|
|
11
|
+
EyeOff,
|
|
12
|
+
Zap,
|
|
13
|
+
Server,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
|
|
16
|
+
interface RelayConfigState {
|
|
17
|
+
port: number
|
|
18
|
+
deepseekBaseUrl: string
|
|
19
|
+
deepseekApiKey: string
|
|
20
|
+
spoofProvider: string
|
|
21
|
+
verbose: boolean
|
|
22
|
+
modelMap: Record<string, string>
|
|
23
|
+
configPath?: string
|
|
24
|
+
apiKeyConfigured?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function RelayPage() {
|
|
28
|
+
const config = useStore((s) => s.config)
|
|
29
|
+
const loadAll = useStore((s) => s.loadAll)
|
|
30
|
+
const [status, setStatus] = useState<{ running: boolean; info: { port: number; running: boolean; baseUrl: string; modelMap: Record<string, string>; deepseekApiKey: string } | null; config: RelayConfigState } | null>(null)
|
|
31
|
+
const [draft, setDraft] = useState<RelayConfigState | null>(null)
|
|
32
|
+
const [showKey, setShowKey] = useState(false)
|
|
33
|
+
const [busy, setBusy] = useState(false)
|
|
34
|
+
const [copied, setCopied] = useState(false)
|
|
35
|
+
|
|
36
|
+
// 复制启动命令到剪贴板
|
|
37
|
+
async function copyLaunchCmd() {
|
|
38
|
+
try {
|
|
39
|
+
await navigator.clipboard.writeText('claude')
|
|
40
|
+
setCopied(true)
|
|
41
|
+
setTimeout(() => setCopied(false), 1500)
|
|
42
|
+
} catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void refresh()
|
|
47
|
+
const t = setInterval(refresh, 5000)
|
|
48
|
+
return () => {
|
|
49
|
+
clearInterval(t)
|
|
50
|
+
}
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
async function refresh() {
|
|
54
|
+
try {
|
|
55
|
+
const s = await api.getRelayStatus()
|
|
56
|
+
setStatus(s as never)
|
|
57
|
+
if (!draft) setDraft(s.config)
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!status || !draft) {
|
|
64
|
+
return (
|
|
65
|
+
<Page title="中转服务" code="07">
|
|
66
|
+
<div className="empty">加载中…</div>
|
|
67
|
+
</Page>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function handleStart() {
|
|
72
|
+
setBusy(true)
|
|
73
|
+
try {
|
|
74
|
+
await api.startRelay()
|
|
75
|
+
await refresh()
|
|
76
|
+
} finally {
|
|
77
|
+
setBusy(false)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleStop() {
|
|
82
|
+
setBusy(true)
|
|
83
|
+
try {
|
|
84
|
+
await api.stopRelay()
|
|
85
|
+
await refresh()
|
|
86
|
+
} finally {
|
|
87
|
+
setBusy(false)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function handleRestart() {
|
|
92
|
+
setBusy(true)
|
|
93
|
+
try {
|
|
94
|
+
await api.restartRelay()
|
|
95
|
+
await refresh()
|
|
96
|
+
} finally {
|
|
97
|
+
setBusy(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleSave() {
|
|
102
|
+
if (!draft) return
|
|
103
|
+
setBusy(true)
|
|
104
|
+
try {
|
|
105
|
+
// 如果 key 是脱敏的(中间有 ••••),不提交 deepseekApiKey 字段,避免覆盖真实密钥
|
|
106
|
+
const patch: Record<string, unknown> = {
|
|
107
|
+
port: draft.port,
|
|
108
|
+
deepseekBaseUrl: draft.deepseekBaseUrl,
|
|
109
|
+
spoofProvider: draft.spoofProvider,
|
|
110
|
+
verbose: draft.verbose,
|
|
111
|
+
}
|
|
112
|
+
if (!draft.deepseekApiKey.includes('••••')) patch.deepseekApiKey = draft.deepseekApiKey
|
|
113
|
+
await api.updateRelayConfig(patch)
|
|
114
|
+
await refresh()
|
|
115
|
+
await loadAll()
|
|
116
|
+
} finally {
|
|
117
|
+
setBusy(false)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleOneClickActivate() {
|
|
122
|
+
// 一键:保存当前 draft + 启动中转
|
|
123
|
+
await handleSave()
|
|
124
|
+
if (!status.running) await handleStart()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const running = status.running
|
|
128
|
+
const currentModel = config?.settings?.model ?? '—'
|
|
129
|
+
const hasKey = !!(status?.config?.deepseekApiKey?.trim()) || !!(status?.info?.deepseekApiKey?.trim())
|
|
130
|
+
const isFirstRun = !hasKey && !running
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<>
|
|
134
|
+
{isFirstRun && <FirstRunBanner onDismiss={() => void refresh()} />}
|
|
135
|
+
{running && hasKey && <ReadyBanner onCopy={copyLaunchCmd} copied={copied} />}
|
|
136
|
+
<Page
|
|
137
|
+
title="中转服务"
|
|
138
|
+
code="07"
|
|
139
|
+
subtitle="本地 Anthropic ↔ DeepSeek 中转,集中管理 API Key 与模型映射"
|
|
140
|
+
actions={
|
|
141
|
+
<>
|
|
142
|
+
{running ? (
|
|
143
|
+
<button className="btn btn-danger" onClick={handleStop} disabled={busy}>
|
|
144
|
+
<Square size={14} /> 停止
|
|
145
|
+
</button>
|
|
146
|
+
) : (
|
|
147
|
+
<button className="btn btn-primary" onClick={handleStart} disabled={busy}>
|
|
148
|
+
<Play size={14} /> 启动
|
|
149
|
+
</button>
|
|
150
|
+
)}
|
|
151
|
+
<button className="btn" onClick={handleRestart} disabled={busy || !running}>
|
|
152
|
+
<RotateCcw size={14} /> 重启
|
|
153
|
+
</button>
|
|
154
|
+
</>
|
|
155
|
+
}
|
|
156
|
+
>
|
|
157
|
+
<div className="panel-grid cols-3" style={{ marginBottom: 'var(--sp-5)' }}>
|
|
158
|
+
<StatusCard
|
|
159
|
+
tone={running ? 'green' : 'red'}
|
|
160
|
+
label="中转服务"
|
|
161
|
+
value={running ? `RUNNING :${draft.port}` : 'STOPPED'}
|
|
162
|
+
meta={running ? `listening on 127.0.0.1:${draft.port}` : '点击「启动」开启'}
|
|
163
|
+
/>
|
|
164
|
+
<StatusCard
|
|
165
|
+
tone={draft.deepseekApiKey && !draft.deepseekApiKey.includes('••••') ? 'green' : 'red'}
|
|
166
|
+
label="DeepSeek Key"
|
|
167
|
+
value={
|
|
168
|
+
draft.deepseekApiKey
|
|
169
|
+
? showKey
|
|
170
|
+
? draft.deepseekApiKey
|
|
171
|
+
: draft.deepseekApiKey
|
|
172
|
+
: '未填写'
|
|
173
|
+
}
|
|
174
|
+
meta="platform.deepseek.com 申请的 sk-…"
|
|
175
|
+
/>
|
|
176
|
+
<StatusCard
|
|
177
|
+
tone={config?.settings?.model?.startsWith('deepseek') ? 'green' : 'amber'}
|
|
178
|
+
label="Bridge 当前模型"
|
|
179
|
+
value={currentModel}
|
|
180
|
+
meta="切换到 deepseek-* 才能用中转"
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<Panel title="CONFIG · 中转配置">
|
|
185
|
+
<div className="panel-grid cols-2">
|
|
186
|
+
<div className="field" style={{ margin: 0 }}>
|
|
187
|
+
<label className="field-label">
|
|
188
|
+
<span className="key">port /</span> 监听端口
|
|
189
|
+
</label>
|
|
190
|
+
<input
|
|
191
|
+
className="input"
|
|
192
|
+
type="number"
|
|
193
|
+
value={draft.port}
|
|
194
|
+
onChange={(e) => setDraft({ ...draft, port: Number(e.target.value) || 8787 })}
|
|
195
|
+
/>
|
|
196
|
+
<div className="field-hint">默认 8787,仅监听 127.0.0.1</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div className="field" style={{ margin: 0 }}>
|
|
199
|
+
<label className="field-label">
|
|
200
|
+
<span className="key">spoof /</span> 伪装 Provider
|
|
201
|
+
</label>
|
|
202
|
+
<input
|
|
203
|
+
className="input"
|
|
204
|
+
value={draft.spoofProvider}
|
|
205
|
+
onChange={(e) => setDraft({ ...draft, spoofProvider: e.target.value })}
|
|
206
|
+
/>
|
|
207
|
+
<div className="field-hint">写到 /v1/models 的 owned_by 字段</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="field">
|
|
212
|
+
<label className="field-label">
|
|
213
|
+
<span className="key">upstream /</span> DeepSeek Base URL
|
|
214
|
+
</label>
|
|
215
|
+
<input
|
|
216
|
+
className="input"
|
|
217
|
+
value={draft.deepseekBaseUrl}
|
|
218
|
+
onChange={(e) => setDraft({ ...draft, deepseekBaseUrl: e.target.value })}
|
|
219
|
+
/>
|
|
220
|
+
<div className="field-hint">默认 https://api.deepseek.com</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="field">
|
|
224
|
+
<label className="field-label">
|
|
225
|
+
<span className="key">api_key /</span> DeepSeek API Key
|
|
226
|
+
</label>
|
|
227
|
+
<div className="input-group">
|
|
228
|
+
<input
|
|
229
|
+
className="input"
|
|
230
|
+
type={showKey ? 'text' : 'password'}
|
|
231
|
+
value={draft.deepseekApiKey}
|
|
232
|
+
onChange={(e) => setDraft({ ...draft, deepseekApiKey: e.target.value })}
|
|
233
|
+
placeholder="sk-..."
|
|
234
|
+
/>
|
|
235
|
+
<button className="btn" onClick={() => setShowKey(!showKey)}>
|
|
236
|
+
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="field-hint">API Key 只保存在本机 BHG-helper 配置文件中,页面读取时自动脱敏。</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="divider" />
|
|
243
|
+
|
|
244
|
+
<div style={{ display: 'flex', gap: 'var(--sp-2)' }}>
|
|
245
|
+
<button className="btn btn-primary" onClick={handleSave} disabled={busy}>
|
|
246
|
+
<Save size={14} /> 保存配置
|
|
247
|
+
</button>
|
|
248
|
+
<button className="btn" onClick={handleOneClickActivate} disabled={busy}>
|
|
249
|
+
<Zap size={14} /> 一键:保存 + 启动
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</Panel>
|
|
253
|
+
|
|
254
|
+
<div style={{ marginTop: 'var(--sp-5)' }}>
|
|
255
|
+
<Panel title="MODEL MAP · 模型名映射">
|
|
256
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)' }}>
|
|
257
|
+
{Object.entries(draft.modelMap).map(([from, to]) => (
|
|
258
|
+
<div
|
|
259
|
+
key={from}
|
|
260
|
+
style={{
|
|
261
|
+
display: 'grid',
|
|
262
|
+
gridTemplateColumns: '1fr auto 1fr auto',
|
|
263
|
+
gap: 'var(--sp-3)',
|
|
264
|
+
alignItems: 'center',
|
|
265
|
+
padding: 'var(--sp-2) var(--sp-3)',
|
|
266
|
+
background: 'var(--bg-1)',
|
|
267
|
+
border: '1px solid var(--line-1)',
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<code className="cell-mono">{from}</code>
|
|
271
|
+
<span style={{ color: 'var(--ink-3)' }}>→</span>
|
|
272
|
+
<input
|
|
273
|
+
className="input"
|
|
274
|
+
value={to}
|
|
275
|
+
onChange={(e) =>
|
|
276
|
+
setDraft({
|
|
277
|
+
...draft,
|
|
278
|
+
modelMap: { ...draft.modelMap, [from]: e.target.value },
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
/>
|
|
282
|
+
<button
|
|
283
|
+
className="btn btn-ghost"
|
|
284
|
+
onClick={() => {
|
|
285
|
+
const next = { ...draft.modelMap }
|
|
286
|
+
delete next[from]
|
|
287
|
+
setDraft({ ...draft, modelMap: next })
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
✕
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
<button
|
|
295
|
+
className="btn"
|
|
296
|
+
onClick={() => {
|
|
297
|
+
const id = `custom-${Date.now()}`
|
|
298
|
+
setDraft({ ...draft, modelMap: { ...draft.modelMap, [id]: 'deepseek-chat' } })
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
+ 新增映射
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</Panel>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div style={{ marginTop: 'var(--sp-5)' }}>
|
|
308
|
+
<Panel title="DEPLOY · 怎么让 Claude Code 走中转">
|
|
309
|
+
<ol style={{ paddingLeft: 20, color: 'var(--ink-1)', lineHeight: 1.8, fontSize: 13 }}>
|
|
310
|
+
<li>
|
|
311
|
+
<strong>启动中转</strong>:本页面点「启动」,确保状态变绿。
|
|
312
|
+
</li>
|
|
313
|
+
<li>
|
|
314
|
+
<strong>写 <code>~/.claude/settings.json</code></strong>:让 claude.exe 把请求发到本地中转:
|
|
315
|
+
<pre style={{
|
|
316
|
+
margin: '8px 0', padding: 12, background: 'var(--bg-1)',
|
|
317
|
+
border: '1px solid var(--line-1)', borderRadius: 4,
|
|
318
|
+
fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--ink-1)'
|
|
319
|
+
}}>{`{
|
|
320
|
+
"env": {
|
|
321
|
+
"ANTHROPIC_BASE_URL": "http://127.0.0.1:${draft.port}",
|
|
322
|
+
"ANTHROPIC_AUTH_TOKEN": "any-dummy-value"
|
|
323
|
+
}
|
|
324
|
+
}`}</pre>
|
|
325
|
+
<span style={{ color: 'var(--ink-3)' }}>其中 <code>ANTHROPIC_AUTH_TOKEN</code> 是任意字符串,中转不验证它。</span>
|
|
326
|
+
</li>
|
|
327
|
+
<li>
|
|
328
|
+
<strong>重启 claude.exe</strong>:让 SDK 重新读 <code>settings.json</code> 的环境变量。
|
|
329
|
+
</li>
|
|
330
|
+
<li>
|
|
331
|
+
<strong>验证</strong>:发起一次对话,看「事件日志」里是否出现
|
|
332
|
+
<code style={{ marginLeft: 4 }}>[INFO] relay POST /v1/messages from 127.0.0.1</code>。
|
|
333
|
+
</li>
|
|
334
|
+
</ol>
|
|
335
|
+
<div
|
|
336
|
+
style={{
|
|
337
|
+
marginTop: 'var(--sp-3)',
|
|
338
|
+
padding: 'var(--sp-3) var(--sp-4)',
|
|
339
|
+
background: 'var(--bg-1)',
|
|
340
|
+
border: '1px dashed var(--line-2)',
|
|
341
|
+
fontFamily: 'var(--font-mono)',
|
|
342
|
+
fontSize: 12,
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<div style={{ color: 'var(--ink-2)', marginBottom: 4 }}># 直接 curl 测一下本地中转</div>
|
|
346
|
+
<div>curl http://127.0.0.1:{draft.port}/v1/models</div>
|
|
347
|
+
<div style={{ color: 'var(--ink-2)', marginTop: 8 }}># 模拟一次 Anthropic 请求</div>
|
|
348
|
+
<div>
|
|
349
|
+
curl -X POST http://127.0.0.1:{draft.port}/v1/messages \<br />
|
|
350
|
+
-H "Content-Type: application/json" \<br />
|
|
351
|
+
-d '{`{`}"model":"deepseek-chat","max_tokens":32,"messages":[{`{`}"role":"user","content":"hi"{`}`}]{`}`}'
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</Panel>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div style={{ marginTop: 'var(--sp-4)', display: 'flex', gap: 'var(--sp-2)', alignItems: 'center' }}>
|
|
358
|
+
<span className="led-dot gray" />
|
|
359
|
+
<span style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>
|
|
360
|
+
配置文件: {draft.configPath ?? '~/.bhg-helper/relay.config.json'} · 端口 {draft.port} · 仅本机监听
|
|
361
|
+
</span>
|
|
362
|
+
</div>
|
|
363
|
+
</Page>
|
|
364
|
+
</>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function FirstRunBanner({ onDismiss }: { onDismiss: () => void }) {
|
|
369
|
+
return (
|
|
370
|
+
<div style={{
|
|
371
|
+
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50,
|
|
372
|
+
background: 'linear-gradient(90deg, #f59e0b, #f97316)',
|
|
373
|
+
color: '#000', padding: '12px 24px', display: 'flex', gap: 16, alignItems: 'center',
|
|
374
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
|
|
375
|
+
}}>
|
|
376
|
+
<div style={{ fontWeight: 700, fontSize: 14 }}>⚡ 首次使用</div>
|
|
377
|
+
<div style={{ flex: 1, fontSize: 13 }}>
|
|
378
|
+
1) 申请 DeepSeek API Key(platform.deepseek.com)
|
|
379
|
+
→ 2) 粘贴到下方「DeepSeek API Key」并「保存配置」
|
|
380
|
+
→ 3) 点「启动」
|
|
381
|
+
</div>
|
|
382
|
+
<button
|
|
383
|
+
onClick={onDismiss}
|
|
384
|
+
style={{ background: 'rgba(0,0,0,0.15)', border: 'none', padding: '4px 12px', borderRadius: 4, cursor: 'pointer', color: '#000' }}
|
|
385
|
+
>知道了</button>
|
|
386
|
+
</div>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function ReadyBanner({ onCopy, copied }: { onCopy: () => void; copied: boolean }) {
|
|
391
|
+
return (
|
|
392
|
+
<div style={{
|
|
393
|
+
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50,
|
|
394
|
+
background: 'linear-gradient(90deg, #10b981, #14b8a6)',
|
|
395
|
+
color: '#000', padding: '12px 24px', display: 'flex', gap: 16, alignItems: 'center',
|
|
396
|
+
boxShadow: '0 -4px 12px rgba(0,0,0,0.2)'
|
|
397
|
+
}}>
|
|
398
|
+
<div style={{ fontWeight: 700, fontSize: 14 }}>✓ 中转已就绪</div>
|
|
399
|
+
<div style={{ flex: 1, fontSize: 13 }}>
|
|
400
|
+
打开<strong>新终端</strong>,跑下面的命令启动 Claude Code:
|
|
401
|
+
</div>
|
|
402
|
+
<code style={{ background: 'rgba(0,0,0,0.2)', padding: '4px 10px', borderRadius: 4, fontFamily: 'monospace' }}>claude</code>
|
|
403
|
+
<button
|
|
404
|
+
onClick={onCopy}
|
|
405
|
+
style={{ background: '#000', color: '#10b981', border: 'none', padding: '6px 14px', borderRadius: 4, cursor: 'pointer', fontWeight: 600 }}
|
|
406
|
+
>{copied ? '已复制' : '复制命令'}</button>
|
|
407
|
+
</div>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "ES2020",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2020",
|
|
8
|
+
"DOM",
|
|
9
|
+
"DOM.Iterable"
|
|
10
|
+
],
|
|
11
|
+
"module": "ESNext",
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"moduleResolution": "bundler",
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"verbatimModuleSyntax": false,
|
|
16
|
+
"moduleDetection": "force",
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"strict": false,
|
|
20
|
+
"noUnusedLocals": false,
|
|
21
|
+
"noUnusedParameters": false,
|
|
22
|
+
"noFallthroughCasesInSwitch": false,
|
|
23
|
+
"noUncheckedSideEffectImports": false,
|
|
24
|
+
"forceConsistentCasingInFileNames": false,
|
|
25
|
+
"baseUrl": "./",
|
|
26
|
+
"paths": {
|
|
27
|
+
"@/*": [
|
|
28
|
+
"./src/*"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"types": [
|
|
32
|
+
"node",
|
|
33
|
+
"express"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"include": [
|
|
37
|
+
"src",
|
|
38
|
+
"api"
|
|
39
|
+
]
|
|
40
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// https://vite.dev/config/
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react()],
|
|
8
|
+
resolve: {
|
|
9
|
+
alias: {
|
|
10
|
+
'@': resolve(__dirname, './src'),
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
server: {
|
|
14
|
+
port: 5173,
|
|
15
|
+
proxy: {
|
|
16
|
+
// 把 /api/* 转到 Cockpit 后端 (3001)
|
|
17
|
+
'/api': {
|
|
18
|
+
target: 'http://127.0.0.1:3001',
|
|
19
|
+
changeOrigin: true,
|
|
20
|
+
secure: false,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
watch: {
|
|
24
|
+
// 不监听 node_modules 下的 tsconfig.json(避免被 transient 路径搞乱)
|
|
25
|
+
ignored: ['**/node_modules/**'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
})
|