@simonyea/holysheep-cli 1.7.53 → 1.7.55
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 +2 -2
- package/src/commands/webui.js +42 -0
- package/src/index.js +12 -0
- package/src/tools/claude-process-proxy.js +37 -42
- package/src/tools/codex.js +6 -8
- package/src/webui/index.html +536 -0
- package/src/webui/server.js +621 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>HolySheep WebUI</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f5f6fa; --surface: #fff; --surface2: #f0f1f5;
|
|
11
|
+
--text: #1a1a2e; --text2: #777; --border: #e2e4ea;
|
|
12
|
+
--primary: #e8a46a; --primary-dim: rgba(232,164,106,0.12);
|
|
13
|
+
--success: #22c55e; --success-dim: rgba(34,197,94,0.10);
|
|
14
|
+
--warning: #f59e0b; --warning-dim: rgba(245,158,11,0.10);
|
|
15
|
+
--error: #ef4444; --error-dim: rgba(239,68,68,0.10);
|
|
16
|
+
--radius: 12px; --shadow: 0 1px 4px rgba(0,0,0,0.06);
|
|
17
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
18
|
+
--mono: "SF Mono", "Cascadia Code", Consolas, monospace;
|
|
19
|
+
}
|
|
20
|
+
@media (prefers-color-scheme: dark) {
|
|
21
|
+
:root {
|
|
22
|
+
--bg: #0d0d1a; --surface: #181828; --surface2: #1e1e32;
|
|
23
|
+
--text: #e4e4f0; --text2: #888; --border: #2a2a44;
|
|
24
|
+
--shadow: 0 1px 4px rgba(0,0,0,0.3);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
html { font-size: 15px; }
|
|
28
|
+
body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
29
|
+
a { color: var(--primary); text-decoration: none; }
|
|
30
|
+
a:hover { text-decoration: underline; }
|
|
31
|
+
|
|
32
|
+
/* Layout */
|
|
33
|
+
.app { max-width: 720px; margin: 0 auto; padding: 16px 20px 40px; }
|
|
34
|
+
|
|
35
|
+
/* Header */
|
|
36
|
+
.header { display: flex; align-items: center; justify-content: space-between; padding: 16px 0 12px; }
|
|
37
|
+
.header-left { display: flex; align-items: center; gap: 12px; }
|
|
38
|
+
.header-left h1 { font-size: 1.3rem; font-weight: 700; }
|
|
39
|
+
.header-left h1 span { color: var(--primary); }
|
|
40
|
+
.header-left a { font-size: 0.8rem; color: var(--text2); }
|
|
41
|
+
.header-right { display: flex; align-items: center; gap: 12px; }
|
|
42
|
+
.lang-btn { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; font-size: 0.75rem; color: var(--text2); cursor: pointer; font-family: var(--font); }
|
|
43
|
+
.lang-btn:hover { border-color: var(--primary); color: var(--primary); }
|
|
44
|
+
.ver { color: var(--text2); font-size: 0.8rem; }
|
|
45
|
+
|
|
46
|
+
/* Sections */
|
|
47
|
+
.section { margin-bottom: 24px; }
|
|
48
|
+
.section-title { font-size: 1rem; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
|
|
49
|
+
.section-title .hint { font-size: 0.8rem; color: var(--text2); font-weight: 400; }
|
|
50
|
+
|
|
51
|
+
/* Cards */
|
|
52
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; box-shadow: var(--shadow); }
|
|
53
|
+
|
|
54
|
+
/* Account */
|
|
55
|
+
.account-card { margin-bottom: 24px; }
|
|
56
|
+
.account-logged-in .account-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
57
|
+
.account-top .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
58
|
+
.dot-ok { background: var(--success); }
|
|
59
|
+
.dot-warn { background: var(--warning); }
|
|
60
|
+
.dot-err { background: var(--error); }
|
|
61
|
+
.dot-gray { background: var(--text2); }
|
|
62
|
+
.account-top .status-text { font-weight: 600; }
|
|
63
|
+
.account-top .key-text { font-family: var(--mono); font-size: 0.85rem; color: var(--text2); }
|
|
64
|
+
.account-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0 12px; text-align: center; }
|
|
65
|
+
.account-stats .stat-val { font-size: 1.2rem; font-weight: 700; color: var(--primary); }
|
|
66
|
+
.account-stats .stat-lbl { font-size: 0.75rem; color: var(--text2); margin-top: 2px; }
|
|
67
|
+
.account-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
68
|
+
.login-form { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
|
|
69
|
+
.login-form input { flex: 1; font-family: var(--mono); background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; font-size: 0.9rem; outline: none; }
|
|
70
|
+
.login-form input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-dim); }
|
|
71
|
+
.register-link { font-size: 0.8rem; color: var(--text2); margin-top: 8px; }
|
|
72
|
+
|
|
73
|
+
/* Buttons */
|
|
74
|
+
.btn { font-family: var(--font); cursor: pointer; border: none; border-radius: 6px; padding: 7px 14px; font-size: 0.85rem; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
|
75
|
+
.btn-primary { background: var(--primary); color: #fff; font-weight: 600; }
|
|
76
|
+
.btn-primary:hover { filter: brightness(1.08); }
|
|
77
|
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
78
|
+
.btn-outline { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
|
79
|
+
.btn-outline:hover { border-color: var(--primary); color: var(--primary); }
|
|
80
|
+
.btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error-dim); }
|
|
81
|
+
.btn-danger:hover { background: var(--error-dim); }
|
|
82
|
+
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
|
|
83
|
+
.btn-link { background: none; color: var(--primary); padding: 0; font-size: 0.85rem; }
|
|
84
|
+
.btn-link:hover { text-decoration: underline; }
|
|
85
|
+
|
|
86
|
+
/* Tool cards */
|
|
87
|
+
.tool-grid { display: flex; flex-direction: column; gap: 10px; }
|
|
88
|
+
.tool-card { display: flex; align-items: center; gap: 14px; padding: 16px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); transition: border-color .15s; }
|
|
89
|
+
.tool-card:hover { border-color: color-mix(in srgb, var(--primary) 40%, var(--border)); }
|
|
90
|
+
.tool-card .tool-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
91
|
+
.tool-card .tool-body { flex: 1; min-width: 0; }
|
|
92
|
+
.tool-card .tool-name { font-weight: 600; font-size: 0.95rem; }
|
|
93
|
+
.tool-card .tool-meta { font-size: 0.8rem; color: var(--text2); margin-top: 2px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
94
|
+
.tool-card .tool-hint { font-size: 0.78rem; color: var(--text2); margin-top: 4px; }
|
|
95
|
+
.tool-card .tool-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
|
96
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.72rem; font-weight: 600; }
|
|
97
|
+
.badge-ok { background: var(--success-dim); color: var(--success); }
|
|
98
|
+
.badge-warn { background: var(--warning-dim); color: var(--warning); }
|
|
99
|
+
.badge-gray { background: var(--surface2); color: var(--text2); }
|
|
100
|
+
|
|
101
|
+
/* Console */
|
|
102
|
+
.console-area { margin-top: 12px; display: none; }
|
|
103
|
+
.console-area.open { display: block; }
|
|
104
|
+
.console-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
|
105
|
+
.console-header span { font-size: 0.85rem; font-weight: 600; }
|
|
106
|
+
.console { background: #111; color: #ccc; font-family: var(--mono); font-size: 0.78rem; padding: 12px; border-radius: 8px; max-height: 260px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
|
|
107
|
+
.console .ok { color: #4ade80; }
|
|
108
|
+
.console .err { color: #f87171; }
|
|
109
|
+
.console .warn { color: #fbbf24; }
|
|
110
|
+
.console .info { color: #60a5fa; }
|
|
111
|
+
|
|
112
|
+
/* Environment */
|
|
113
|
+
.env-table { width: 100%; }
|
|
114
|
+
.env-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
|
|
115
|
+
.env-row:last-child { border-bottom: none; }
|
|
116
|
+
.env-key { font-family: var(--mono); font-size: 0.8rem; flex: 1; }
|
|
117
|
+
.env-val { font-size: 0.8rem; }
|
|
118
|
+
.env-rc { font-size: 0.8rem; color: var(--text2); margin-top: 10px; }
|
|
119
|
+
|
|
120
|
+
/* Footer */
|
|
121
|
+
.footer { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 32px; text-align: center; }
|
|
122
|
+
.footer-brand { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
|
|
123
|
+
.footer-brand span { color: var(--primary); }
|
|
124
|
+
.footer-links { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; font-size: 0.85rem; margin-bottom: 8px; }
|
|
125
|
+
.footer-sub { font-size: 0.78rem; color: var(--text2); }
|
|
126
|
+
|
|
127
|
+
/* Misc */
|
|
128
|
+
.loading { color: var(--text2); font-size: 0.85rem; }
|
|
129
|
+
.hidden { display: none !important; }
|
|
130
|
+
.mono { font-family: var(--mono); }
|
|
131
|
+
</style>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<div class="app" id="app">
|
|
135
|
+
<div class="header">
|
|
136
|
+
<div class="header-left">
|
|
137
|
+
<h1><span>HolySheep</span></h1>
|
|
138
|
+
<a href="https://holysheep.ai" target="_blank">holysheep.ai</a>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="header-right">
|
|
141
|
+
<button class="lang-btn" onclick="toggleLang()" id="lang-btn"></button>
|
|
142
|
+
<span class="ver" id="version"></span>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div id="account-section" class="section"></div>
|
|
147
|
+
<div id="tools-section" class="section"></div>
|
|
148
|
+
<div id="console-section" class="console-area" id="console-area">
|
|
149
|
+
<div class="console-header">
|
|
150
|
+
<span id="console-title"></span>
|
|
151
|
+
<button class="btn btn-sm btn-outline" onclick="closeConsole()" id="console-close-btn"></button>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="console" id="console-output"></div>
|
|
154
|
+
</div>
|
|
155
|
+
<div id="env-section" class="section"></div>
|
|
156
|
+
<div id="footer-section"></div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<script>
|
|
160
|
+
// ── i18n ─────────────────────────────────────────────────────────────────────
|
|
161
|
+
const I18N = {
|
|
162
|
+
zh: {
|
|
163
|
+
loggedIn: '已登录', notLoggedIn: '未登录', login: '登录', logout: '退出登录',
|
|
164
|
+
balance: '余额', today: '今日消费', month: '本月消费', calls: '累计调用',
|
|
165
|
+
recharge: '充值', register: '没有账号?去注册',
|
|
166
|
+
apiKeyPlaceholder: '请输入 API Key (cr_xxx)',
|
|
167
|
+
tools: 'AI 工具', toolsHint: '一键配置使用 HolySheep API',
|
|
168
|
+
installed: '已安装', notInstalled: '未安装', configured: '已配置', notConfigured: '未配置',
|
|
169
|
+
configure: '一键配置', reconfigure: '重新配置', reset: '重置', install: '安装',
|
|
170
|
+
installManual: '手动安装',
|
|
171
|
+
env: '环境变量', cleanConflicts: '清理冲突变量', cleaning: '清理中...',
|
|
172
|
+
set: '已设置', notSet: '未设置',
|
|
173
|
+
shellConfig: 'Shell 配置', managedBlock: '有托管块', noManagedBlock: '无托管块',
|
|
174
|
+
docs: '使用文档', pricing: '价格', support: '联系支持',
|
|
175
|
+
slogan: '官方 Claude / GPT / Gemini API 代理 · ¥1 = $1',
|
|
176
|
+
checking: '加载中...', close: '关闭', log: '操作日志',
|
|
177
|
+
confirmReset: '确认重置此工具的 HolySheep 配置?',
|
|
178
|
+
configSuccess: '配置成功', configFailed: '配置失败',
|
|
179
|
+
installSuccess: '安装完成', installFailed: '安装失败',
|
|
180
|
+
needLogin: '请先登录', cleanDone: '已清理',
|
|
181
|
+
hotReload: '已生效,无需重启', needRestart: '重启终端后生效',
|
|
182
|
+
launch: '启动命令',
|
|
183
|
+
},
|
|
184
|
+
en: {
|
|
185
|
+
loggedIn: 'Logged in', notLoggedIn: 'Not logged in', login: 'Login', logout: 'Logout',
|
|
186
|
+
balance: 'Balance', today: 'Today', month: 'This Month', calls: 'Total Calls',
|
|
187
|
+
recharge: 'Recharge', register: 'No account? Register',
|
|
188
|
+
apiKeyPlaceholder: 'Enter API Key (cr_xxx)',
|
|
189
|
+
tools: 'AI Tools', toolsHint: 'One-click setup for HolySheep API',
|
|
190
|
+
installed: 'Installed', notInstalled: 'Not installed', configured: 'Configured', notConfigured: 'Not configured',
|
|
191
|
+
configure: 'Configure', reconfigure: 'Reconfigure', reset: 'Reset', install: 'Install',
|
|
192
|
+
installManual: 'Manual install',
|
|
193
|
+
env: 'Environment Variables', cleanConflicts: 'Clean Conflicts', cleaning: 'Cleaning...',
|
|
194
|
+
set: 'Set', notSet: 'Not set',
|
|
195
|
+
shellConfig: 'Shell Config', managedBlock: 'managed block', noManagedBlock: 'no managed block',
|
|
196
|
+
docs: 'Docs', pricing: 'Pricing', support: 'Support',
|
|
197
|
+
slogan: 'Official Claude / GPT / Gemini API Proxy',
|
|
198
|
+
checking: 'Loading...', close: 'Close', log: 'Activity Log',
|
|
199
|
+
confirmReset: 'Reset HolySheep config for this tool?',
|
|
200
|
+
configSuccess: 'Configured', configFailed: 'Config failed',
|
|
201
|
+
installSuccess: 'Installed', installFailed: 'Install failed',
|
|
202
|
+
needLogin: 'Please login first', cleanDone: 'Cleaned',
|
|
203
|
+
hotReload: 'Active, no restart needed', needRestart: 'Restart terminal to apply',
|
|
204
|
+
launch: 'Launch',
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let lang = localStorage.getItem('hs-lang') || (navigator.language.startsWith('zh') ? 'zh' : 'en')
|
|
209
|
+
const t = (k) => I18N[lang]?.[k] || I18N['en'][k] || k
|
|
210
|
+
|
|
211
|
+
function toggleLang() {
|
|
212
|
+
lang = lang === 'zh' ? 'en' : 'zh'
|
|
213
|
+
localStorage.setItem('hs-lang', lang)
|
|
214
|
+
init()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
218
|
+
let busy = false
|
|
219
|
+
|
|
220
|
+
// ── Init ─────────────────────────────────────────────────────────────────────
|
|
221
|
+
document.addEventListener('DOMContentLoaded', init)
|
|
222
|
+
|
|
223
|
+
async function init() {
|
|
224
|
+
document.getElementById('lang-btn').textContent = lang === 'zh' ? 'EN' : '中文'
|
|
225
|
+
document.getElementById('console-close-btn').textContent = t('close')
|
|
226
|
+
loadAccount()
|
|
227
|
+
loadTools()
|
|
228
|
+
loadEnv()
|
|
229
|
+
renderFooter()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── API helper ───────────────────────────────────────────────────────────────
|
|
233
|
+
async function api(path, opts) {
|
|
234
|
+
const res = await fetch('/api/' + path, opts)
|
|
235
|
+
return res.json()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Account section ──────────────────────────────────────────────────────────
|
|
239
|
+
async function loadAccount() {
|
|
240
|
+
const el = document.getElementById('account-section')
|
|
241
|
+
el.innerHTML = `<div class="card account-card"><span class="loading">${t('checking')}</span></div>`
|
|
242
|
+
|
|
243
|
+
const [status, balance] = await Promise.allSettled([api('status'), api('balance')])
|
|
244
|
+
const s = status.value || {}
|
|
245
|
+
const b = balance.value || {}
|
|
246
|
+
|
|
247
|
+
document.getElementById('version').textContent = 'v' + (s.version || '')
|
|
248
|
+
|
|
249
|
+
if (s.loggedIn) {
|
|
250
|
+
const hasBalance = !b.error && typeof b.balance === 'number'
|
|
251
|
+
el.innerHTML = `<div class="card account-card account-logged-in">
|
|
252
|
+
<div class="account-top">
|
|
253
|
+
<span class="dot dot-ok"></span>
|
|
254
|
+
<span class="status-text">${t('loggedIn')}</span>
|
|
255
|
+
<span class="key-text">${esc(s.apiKey)}</span>
|
|
256
|
+
</div>
|
|
257
|
+
${hasBalance ? `
|
|
258
|
+
<div class="account-stats">
|
|
259
|
+
<div><div class="stat-val">$${fmtNum(b.balance)}</div><div class="stat-lbl">${t('balance')}</div></div>
|
|
260
|
+
<div><div class="stat-val">$${fmtNum(b.todayCost)}</div><div class="stat-lbl">${t('today')}</div></div>
|
|
261
|
+
<div><div class="stat-val">$${fmtNum(b.monthCost)}</div><div class="stat-lbl">${t('month')}</div></div>
|
|
262
|
+
<div><div class="stat-val">${b.totalCalls.toLocaleString()}</div><div class="stat-lbl">${t('calls')}</div></div>
|
|
263
|
+
</div>` : ''}
|
|
264
|
+
<div class="account-actions">
|
|
265
|
+
<a class="btn btn-primary btn-sm" href="https://holysheep.ai/app/recharge" target="_blank">${t('recharge')} →</a>
|
|
266
|
+
<button class="btn btn-danger btn-sm" onclick="doLogout()">${t('logout')}</button>
|
|
267
|
+
</div>
|
|
268
|
+
</div>`
|
|
269
|
+
} else {
|
|
270
|
+
el.innerHTML = `<div class="card account-card">
|
|
271
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
272
|
+
<span class="dot dot-gray"></span>
|
|
273
|
+
<span>${t('notLoggedIn')}</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div class="login-form">
|
|
276
|
+
<input type="password" id="login-key" placeholder="${t('apiKeyPlaceholder')}">
|
|
277
|
+
<button class="btn btn-primary" onclick="doLogin()" id="login-btn">${t('login')}</button>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="register-link"><a href="https://holysheep.ai/register" target="_blank">${t('register')}</a></div>
|
|
280
|
+
</div>`
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function doLogin() {
|
|
285
|
+
const input = document.getElementById('login-key')
|
|
286
|
+
const key = input.value.trim()
|
|
287
|
+
if (!key) return
|
|
288
|
+
const btn = document.getElementById('login-btn')
|
|
289
|
+
btn.disabled = true
|
|
290
|
+
try {
|
|
291
|
+
const r = await api('login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: key }) })
|
|
292
|
+
if (r.success) { init() } else { alert(r.message) }
|
|
293
|
+
} catch (e) { alert(e.message) }
|
|
294
|
+
btn.disabled = false
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function doLogout() {
|
|
298
|
+
await api('logout', { method: 'POST' })
|
|
299
|
+
init()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Tools section ────────────────────────────────────────────────────────────
|
|
303
|
+
async function loadTools() {
|
|
304
|
+
const el = document.getElementById('tools-section')
|
|
305
|
+
el.innerHTML = `<div class="section-title"><span>${t('tools')}</span><span class="hint">${t('toolsHint')}</span></div>
|
|
306
|
+
<div class="tool-grid"><span class="loading">${t('checking')}</span></div>`
|
|
307
|
+
|
|
308
|
+
const tools = await api('tools')
|
|
309
|
+
const grid = el.querySelector('.tool-grid')
|
|
310
|
+
grid.innerHTML = tools.map(tool => renderToolCard(tool)).join('')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderToolCard(tool) {
|
|
314
|
+
let dotClass, statusBadges, actions, hintLine
|
|
315
|
+
|
|
316
|
+
if (!tool.installed) {
|
|
317
|
+
dotClass = 'dot-gray'
|
|
318
|
+
statusBadges = `<span class="badge badge-gray">${t('notInstalled')}</span>`
|
|
319
|
+
if (tool.canAutoInstall) {
|
|
320
|
+
actions = `<button class="btn btn-primary btn-sm" onclick="doInstallTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('install')}</button>`
|
|
321
|
+
} else {
|
|
322
|
+
actions = `<span class="badge badge-gray" style="font-weight:400">${t('installManual')}: ${esc(tool.installCmd)}</span>`
|
|
323
|
+
}
|
|
324
|
+
hintLine = ''
|
|
325
|
+
} else if (!tool.configured) {
|
|
326
|
+
dotClass = 'dot-warn'
|
|
327
|
+
statusBadges = `<span class="badge badge-ok">${t('installed')}</span> <span class="badge badge-warn">${t('notConfigured')}</span>`
|
|
328
|
+
actions = `<button class="btn btn-primary btn-sm" onclick="doConfigureTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('configure')}</button>`
|
|
329
|
+
hintLine = tool.version ? `<span class="mono" style="font-size:0.78rem;color:var(--text2)">${esc(tool.version)}</span>` : ''
|
|
330
|
+
} else {
|
|
331
|
+
dotClass = 'dot-ok'
|
|
332
|
+
statusBadges = `<span class="badge badge-ok">${t('installed')}</span> <span class="badge badge-ok">${t('configured')}</span>`
|
|
333
|
+
actions = `<button class="btn btn-outline btn-sm" onclick="doConfigureTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('reconfigure')}</button>
|
|
334
|
+
<button class="btn btn-danger btn-sm" onclick="doResetTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('reset')}</button>`
|
|
335
|
+
hintLine = tool.version ? `<span class="mono" style="font-size:0.78rem;color:var(--text2)">${esc(tool.version)}</span>` : ''
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const launchLine = tool.installed && tool.launchCmd
|
|
339
|
+
? `<div class="tool-hint">${t('launch')}: <code class="mono">${esc(tool.launchCmd)}</code></div>`
|
|
340
|
+
: ''
|
|
341
|
+
const hintText = tool.installed && tool.hint
|
|
342
|
+
? `<div class="tool-hint">${esc(tool.hint)}</div>`
|
|
343
|
+
: ''
|
|
344
|
+
|
|
345
|
+
return `<div class="tool-card" id="tool-${tool.id}">
|
|
346
|
+
<div class="tool-dot ${dotClass}"></div>
|
|
347
|
+
<div class="tool-body">
|
|
348
|
+
<div class="tool-name">${esc(tool.name)}</div>
|
|
349
|
+
<div class="tool-meta">${statusBadges} ${hintLine}</div>
|
|
350
|
+
${hintText}${launchLine}
|
|
351
|
+
</div>
|
|
352
|
+
<div class="tool-actions">${actions}</div>
|
|
353
|
+
</div>`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Tool actions ─────────────────────────────────────────────────────────────
|
|
357
|
+
async function doConfigureTool(id, name) {
|
|
358
|
+
const status = await api('status')
|
|
359
|
+
if (!status.loggedIn) { alert(t('needLogin')); return }
|
|
360
|
+
if (busy) return
|
|
361
|
+
busy = true
|
|
362
|
+
openConsole(`${t('configure')}: ${name}`)
|
|
363
|
+
|
|
364
|
+
await streamSSE('/api/tool/configure', { toolId: id }, (ev) => {
|
|
365
|
+
if (ev.type === 'progress') appendLog(ev.message, 'info')
|
|
366
|
+
else if (ev.type === 'result') {
|
|
367
|
+
const cls = ev.status === 'ok' ? 'ok' : ev.status === 'warning' ? 'warn' : 'err'
|
|
368
|
+
appendLog(`${ev.status === 'ok' ? '✓' : '⚠'} ${ev.message}`, cls)
|
|
369
|
+
if (ev.file) appendLog(` → ${ev.file}`, 'info')
|
|
370
|
+
if (ev.hot) appendLog(` ${t('hotReload')}`, 'ok')
|
|
371
|
+
else if (ev.status === 'ok') appendLog(` ${t('needRestart')}`, 'warn')
|
|
372
|
+
if (ev.steps) ev.steps.forEach(s => appendLog(` · ${s}`, 'info'))
|
|
373
|
+
}
|
|
374
|
+
else if (ev.type === 'error') appendLog(`✗ ${ev.message}`, 'err')
|
|
375
|
+
else if (ev.type === 'done') {
|
|
376
|
+
appendLog(ev.success ? `\n✓ ${t('configSuccess')}` : `\n✗ ${t('configFailed')}`, ev.success ? 'ok' : 'err')
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
busy = false
|
|
381
|
+
loadTools()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function doResetTool(id, name) {
|
|
385
|
+
if (!confirm(t('confirmReset'))) return
|
|
386
|
+
try {
|
|
387
|
+
await api('tool/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toolId: id }) })
|
|
388
|
+
loadTools()
|
|
389
|
+
} catch (e) { alert(e.message) }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function doInstallTool(id, name) {
|
|
393
|
+
if (busy) return
|
|
394
|
+
busy = true
|
|
395
|
+
openConsole(`${t('install')}: ${name}`)
|
|
396
|
+
|
|
397
|
+
await streamSSE('/api/tool/install', { toolId: id }, (ev) => {
|
|
398
|
+
if (ev.type === 'progress') appendLog(ev.message, 'info')
|
|
399
|
+
else if (ev.type === 'output') appendLogRaw(ev.text)
|
|
400
|
+
else if (ev.type === 'done') {
|
|
401
|
+
appendLog(ev.success ? `\n✓ ${t('installSuccess')}` : `\n✗ ${t('installFailed')}`, ev.success ? 'ok' : 'err')
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
busy = false
|
|
406
|
+
loadTools()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Console ──────────────────────────────────────────────────────────────────
|
|
410
|
+
function openConsole(title) {
|
|
411
|
+
const area = document.getElementById('console-section')
|
|
412
|
+
document.getElementById('console-title').textContent = title || t('log')
|
|
413
|
+
document.getElementById('console-output').innerHTML = ''
|
|
414
|
+
area.classList.add('open')
|
|
415
|
+
area.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function closeConsole() {
|
|
419
|
+
document.getElementById('console-section').classList.remove('open')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function appendLog(msg, cls) {
|
|
423
|
+
const el = document.getElementById('console-output')
|
|
424
|
+
el.innerHTML += `<span class="${cls || ''}">${esc(msg)}</span>\n`
|
|
425
|
+
el.scrollTop = el.scrollHeight
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function appendLogRaw(text) {
|
|
429
|
+
const el = document.getElementById('console-output')
|
|
430
|
+
el.innerHTML += esc(text)
|
|
431
|
+
el.scrollTop = el.scrollHeight
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── SSE helper ───────────────────────────────────────────────────────────────
|
|
435
|
+
async function streamSSE(url, body, onEvent) {
|
|
436
|
+
const res = await fetch(url, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'Content-Type': 'application/json' },
|
|
439
|
+
body: JSON.stringify(body),
|
|
440
|
+
})
|
|
441
|
+
const reader = res.body.getReader()
|
|
442
|
+
const decoder = new TextDecoder()
|
|
443
|
+
let buffer = ''
|
|
444
|
+
while (true) {
|
|
445
|
+
const { done, value } = await reader.read()
|
|
446
|
+
if (done) break
|
|
447
|
+
buffer += decoder.decode(value, { stream: true })
|
|
448
|
+
const parts = buffer.split('\n\n')
|
|
449
|
+
buffer = parts.pop()
|
|
450
|
+
for (const part of parts) {
|
|
451
|
+
const line = part.split('\n').find(l => l.startsWith('data: '))
|
|
452
|
+
if (!line) continue
|
|
453
|
+
try { onEvent(JSON.parse(line.slice(6))) } catch {}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── Environment section ──────────────────────────────────────────────────────
|
|
459
|
+
async function loadEnv() {
|
|
460
|
+
const el = document.getElementById('env-section')
|
|
461
|
+
el.innerHTML = `<div class="section-title">${t('env')}</div><div class="card"><span class="loading">${t('checking')}</span></div>`
|
|
462
|
+
|
|
463
|
+
const data = await api('env')
|
|
464
|
+
const vars = data.vars || {}
|
|
465
|
+
const rcFiles = data.rcFiles || []
|
|
466
|
+
|
|
467
|
+
let rows = ''
|
|
468
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
469
|
+
const isSet = v !== null
|
|
470
|
+
rows += `<div class="env-row">
|
|
471
|
+
<span class="dot ${isSet ? 'dot-ok' : 'dot-gray'}" style="width:8px;height:8px"></span>
|
|
472
|
+
<span class="env-key">${k}</span>
|
|
473
|
+
<span class="env-val" style="color:var(${isSet ? '--success' : '--text2'})">${isSet ? t('set') : t('notSet')}</span>
|
|
474
|
+
</div>`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let rcInfo = ''
|
|
478
|
+
if (rcFiles.length) {
|
|
479
|
+
rcInfo = `<div class="env-rc">${t('shellConfig')}: ${rcFiles.map(f =>
|
|
480
|
+
`<span class="mono">${esc(f.path)}</span> (${f.hasManagedBlock ? t('managedBlock') : t('noManagedBlock')})`
|
|
481
|
+
).join(', ')}</div>`
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
el.innerHTML = `<div class="section-title">
|
|
485
|
+
<span>${t('env')}</span>
|
|
486
|
+
<button class="btn btn-outline btn-sm" onclick="doCleanEnv(this)">${t('cleanConflicts')}</button>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="card">
|
|
489
|
+
<div class="env-table">${rows}</div>
|
|
490
|
+
${rcInfo}
|
|
491
|
+
</div>`
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function doCleanEnv(btn) {
|
|
495
|
+
btn.disabled = true
|
|
496
|
+
btn.textContent = t('cleaning')
|
|
497
|
+
try {
|
|
498
|
+
const r = await api('env/clean', { method: 'POST' })
|
|
499
|
+
btn.textContent = `✓ ${t('cleanDone')}`
|
|
500
|
+
setTimeout(() => loadEnv(), 1500)
|
|
501
|
+
} catch (e) {
|
|
502
|
+
btn.textContent = e.message
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
507
|
+
function renderFooter() {
|
|
508
|
+
document.getElementById('footer-section').innerHTML = `<div class="footer">
|
|
509
|
+
<div class="footer-brand">🐑 <span>HolySheep</span></div>
|
|
510
|
+
<div class="footer-links">
|
|
511
|
+
<a href="https://holysheep.ai" target="_blank">${t('docs')}</a>
|
|
512
|
+
<a href="https://holysheep.ai/app/recharge" target="_blank">${t('recharge')}</a>
|
|
513
|
+
<a href="https://holysheep.ai/register" target="_blank">${lang === 'zh' ? '注册' : 'Register'}</a>
|
|
514
|
+
<a href="https://holysheep.ai/pricing" target="_blank">${t('pricing')}</a>
|
|
515
|
+
</div>
|
|
516
|
+
<div class="footer-sub">${t('slogan')}</div>
|
|
517
|
+
</div>`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Util ─────────────────────────────────────────────────────────────────────
|
|
521
|
+
function esc(s) {
|
|
522
|
+
if (!s) return ''
|
|
523
|
+
const d = document.createElement('div')
|
|
524
|
+
d.textContent = String(s)
|
|
525
|
+
return d.innerHTML
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function fmtNum(n) {
|
|
529
|
+
if (n >= 10000) return Math.floor(n).toLocaleString()
|
|
530
|
+
if (n >= 100) return n.toFixed(1)
|
|
531
|
+
if (n >= 1) return n.toFixed(2)
|
|
532
|
+
return n.toFixed(4)
|
|
533
|
+
}
|
|
534
|
+
</script>
|
|
535
|
+
</body>
|
|
536
|
+
</html>
|