Coverage for agentos/cli/config_panel.py: 16%
154 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
1"""
2AgentOS 配置面板 — Web GUI,一键浏览器配置 API Key。
4启动: agentos config-panel
5访问: http://localhost:18480
6"""
8from __future__ import annotations
10import http.server
11import json
12import os
13import sys
14import webbrowser
15from pathlib import Path
18CONFIG_DIR = Path.home() / ".agentos"
19CONFIG_FILE = CONFIG_DIR / "config.yaml"
20ENV_FILE = CONFIG_DIR / ".env"
22CSS = """
23* { margin: 0; padding: 0; box-sizing: border-box; }
24body {
25 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
26 "Microsoft YaHei", "Helvetica Neue", sans-serif;
27 background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
28 min-height: 100vh; color: #e0e0e0; padding: 24px;
29}
30.container { max-width: 720px; margin: 0 auto; }
31.header {
32 text-align: center; padding: 40px 0 32px;
33}
34.header h1 {
35 font-size: 28px; font-weight: 700;
36 background: linear-gradient(90deg, #667eea, #764ba2);
37 -webkit-background-clip: text; -webkit-text-fill-color: transparent;
38}
39.header p { color: #a0a0b8; margin-top: 8px; font-size: 14px; }
40.card {
41 background: rgba(255,255,255,0.06);
42 backdrop-filter: blur(12px);
43 border: 1px solid rgba(255,255,255,0.10);
44 border-radius: 16px; padding: 28px; margin-bottom: 20px;
45 transition: border-color 0.2s;
46}
47.card:hover { border-color: rgba(102,126,234,0.4); }
48.card h3 {
49 font-size: 17px; font-weight: 600; margin-bottom: 6px;
50 display: flex; align-items: center; gap: 10px;
51}
52.card h3 .badge {
53 font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 10px;
54}
55.badge-recommend { background: #667eea22; color: #667eea; }
56.badge cheap { background: #4caf5022; color: #4caf50; }
57.badge strong { background: #ff980022; color: #ff9800; }
58.card .desc { color: #9090a8; font-size: 13px; margin-bottom: 14px; line-height: 1.6; }
59.card .info-row {
60 display: flex; gap: 20px; margin-bottom: 14px; flex-wrap: wrap;
61}
62.card .info-item {
63 font-size: 12px; color: #808098;
64}
65.card .info-item strong { color: #c0c0d0; font-weight: 600; }
66.input-row { display: flex; gap: 10px; }
67.input-row input {
68 flex: 1; padding: 10px 14px;
69 background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.12);
70 border-radius: 10px; color: #e0e0e0; font-size: 14px;
71 outline: none; transition: border-color 0.2s;
72}
73.input-row input:focus { border-color: #667eea; }
74.input-row input::placeholder { color: #555; }
75.btn {
76 padding: 10px 20px; border-radius: 10px; border: none;
77 font-size: 14px; font-weight: 600; cursor: pointer;
78 transition: all 0.2s;
79}
80.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; }
81.btn-primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
82.btn-outline {
83 background: transparent; border: 1px solid rgba(255,255,255,0.15);
84 color: #c0c0d8;
85}
86.btn-outline:hover { background: rgba(255,255,255,0.06); }
87.btn-success { background: #4caf50; color: #fff; }
88.status { font-size: 12px; margin-top: 8px; height: 20px; }
89.status-ok { color: #4caf50; }
90.status-err { color: #f44336; }
91.status-checking { color: #ff9800; }
92.status-bar {
93 text-align: center; margin-top: 24px; padding: 16px;
94 background: rgba(76,175,80,0.10); border-radius: 12px;
95 font-size: 14px; color: #81c784;
96}
97.status-bar.warning {
98 background: rgba(255,152,0,0.10); color: #ffb74d;
99}
100.footer { text-align: center; padding: 24px; color: #606078; font-size: 12px; }
101"""
103HTML = """<!DOCTYPE html>
104<html lang="zh-CN">
105<head>
106<meta charset="utf-8">
107<meta name="viewport" content="width=device-width, initial-scale=1">
108<title>AgentOS 配置面板</title>
109<style>{css}</style>
110</head>
111<body>
112<div class="container">
113 <div class="header">
114 <h1>🔧 AgentOS 配置面板</h1>
115 <p>选择一个 AI 服务商,填入 API Key,30 秒完成配置</p>
116 </div>
118 <!-- OpenAI -->
119 <div class="card" id="card-openai">
120 <h3>
121 <span>🔵 OpenAI</span>
122 <span class="badge badge-recommend">推荐</span>
123 </h3>
124 <div class="desc">
125 最成熟的 AI 服务商,提供 GPT-4o、GPT-4o-mini 等模型。
126 适合日常对话、代码生成、文档处理,中英文均出色。响应快、稳定性高。
127 </div>
128 <div class="info-row">
129 <div class="info-item">默认模型: <strong>gpt-4o-mini</strong></div>
130 <div class="info-item">费用: <strong>低 ~ 中(约 $0.15/百万token)</strong></div>
131 <div class="info-item">注册: <a href="https://platform.openai.com/api-keys" target="_blank" style="color:#667eea">platform.openai.com</a></div>
132 </div>
133 <div class="input-row">
134 <input type="password" id="key-openai" placeholder="粘贴你的 OpenAI API Key(以 sk- 开头)">
135 <button class="btn btn-primary" onclick="save('openai')">保存并验证</button>
136 <button class="btn btn-outline" onclick="verify('openai')">仅验证</button>
137 </div>
138 <div class="status" id="status-openai"></div>
139 </div>
141 <!-- DeepSeek -->
142 <div class="card" id="card-deepseek">
143 <h3>
144 <span>🟢 DeepSeek</span>
145 <span class="badge cheap">最实惠</span>
146 </h3>
147 <div class="desc">
148 国产高性价比 AI 服务商,deepseek-chat 模型在中文理解和代码生成方面表现突出。
149 价格极低(约为 OpenAI 的 1/10),适合高频调用和预算敏感的场景。
150 </div>
151 <div class="info-row">
152 <div class="info-item">默认模型: <strong>deepseek-chat</strong></div>
153 <div class="info-item">费用: <strong>极低(约 ¥1/百万token)</strong></div>
154 <div class="info-item">注册: <a href="https://platform.deepseek.com/api_keys" target="_blank" style="color:#667eea">platform.deepseek.com</a></div>
155 </div>
156 <div class="input-row">
157 <input type="password" id="key-deepseek" placeholder="粘贴你的 DeepSeek API Key(以 sk- 开头)">
158 <button class="btn btn-primary" onclick="save('deepseek')">保存并验证</button>
159 <button class="btn btn-outline" onclick="verify('deepseek')">仅验证</button>
160 </div>
161 <div class="status" id="status-deepseek"></div>
162 </div>
164 <!-- Anthropic -->
165 <div class="card" id="card-anthropic">
166 <h3>
167 <span>🟣 Anthropic (Claude)</span>
168 <span class="badge strong">最强推理</span>
169 </h3>
170 <div class="desc">
171 Claude 系列擅长深度推理、长篇分析和安全对齐,创意写作和复杂逻辑任务表现顶级。
172 适合需要深度思考的场景,如研究分析、合规审查、长文生成。
173 </div>
174 <div class="info-row">
175 <div class="info-item">默认模型: <strong>claude-sonnet-4</strong></div>
176 <div class="info-item">费用: <strong>中 ~ 高(约 $3/百万token)</strong></div>
177 <div class="info-item">注册: <a href="https://console.anthropic.com/keys" target="_blank" style="color:#667eea">console.anthropic.com</a></div>
178 </div>
179 <div class="input-row">
180 <input type="password" id="key-anthropic" placeholder="粘贴你的 Anthropic API Key(以 sk-ant- 开头)">
181 <button class="btn btn-primary" onclick="save('anthropic')">保存并验证</button>
182 <button class="btn btn-outline" onclick="verify('anthropic')">仅验证</button>
183 </div>
184 <div class="status" id="status-anthropic"></div>
185 </div>
187 <!-- 整体状态 -->
188 <div id="global-status" class="status-bar">
189 未配置任何服务商。选择一个并填入 API Key 开始使用。
190 </div>
192 <div class="footer">
193 <p>API Key 安全保存在本地 <code>~/.agentos/</code> 目录中,不会上传到任何服务器。</p>
194 <p>配置完成后可在终端运行 <code>agentos "你的任务"</code> 开始使用。</p>
195 </div>
196</div>
198<script>
199// 页面加载时检查当前配置
200async function checkStatus() {{
201 const resp = await fetch('/api/status');
202 const data = await resp.json();
203 for (const p of ['openai', 'deepseek', 'anthropic']) {{
204 if (data[p] && data[p].configured) {{
205 setStatus(p, 'ok', '已配置 ✅');
206 document.getElementById('key-' + p).value = data[p].preview || '';
207 }}
208 }}
209 updateGlobalStatus(data);
210}}
212async function save(provider) {{
213 const key = document.getElementById('key-' + provider).value.trim();
214 if (!key) {{ setStatus(provider, 'err', '请输入 API Key'); return; }}
215 setStatus(provider, 'checking', '正在验证...');
216 const resp = await fetch('/api/save', {{
217 method: 'POST',
218 headers: {{'Content-Type': 'application/json'}},
219 body: JSON.stringify({{provider, key}})
220 }});
221 const data = await resp.json();
222 if (data.ok) {{
223 setStatus(provider, 'ok', '配置成功 ✅ — ' + (data.model || ''));
224 }} else {{
225 setStatus(provider, 'err', '失败: ' + (data.error || '未知错误'));
226 }}
227 updateGlobalStatus(data);
228}}
230async function verify(provider) {{
231 const key = document.getElementById('key-' + provider).value.trim();
232 if (!key) {{ setStatus(provider, 'err', '请先输入 API Key'); return; }}
233 setStatus(provider, 'checking', '正在验证...');
234 const resp = await fetch('/api/verify', {{
235 method: 'POST',
236 headers: {{'Content-Type': 'application/json'}},
237 body: JSON.stringify({{provider, key}})
238 }});
239 const data = await resp.json();
240 if (data.valid) {{
241 setStatus(provider, 'ok', 'Key 有效 ✅');
242 }} else {{
243 setStatus(provider, 'err', 'Key 无效: ' + (data.error || ''));
244 }}
245}}
247function setStatus(provider, cls, msg) {{
248 const el = document.getElementById('status-' + provider);
249 el.className = 'status status-' + cls;
250 el.textContent = msg;
251}}
253async function updateGlobalStatus(data) {{
254 const el = document.getElementById('global-status');
255 const configured = [];
256 if (data.openai && data.openai.configured) configured.push('OpenAI');
257 if (data.deepseek && data.deepseek.configured) configured.push('DeepSeek');
258 if (data.anthropic && data.anthropic.configured) configured.push('Anthropic');
260 if (configured.length > 0) {{
261 el.className = 'status-bar';
262 el.innerHTML = '✅ 已配置: ' + configured.join('、') +
263 ' 。在终端运行 <code>agentos "你的任务"</code> 开始使用。';
264 }} else {{
265 el.className = 'status-bar warning';
266 el.textContent = '未配置任何服务商。选择一个并填入 API Key 完成配置。';
267 }}
268}}
270checkStatus();
271</script>
272</body>
273</html>
274"""
276API_TEMPLATE = {
277 "openai": {
278 "env_var": "OPENAI_API_KEY",
279 "label": "OpenAI",
280 "default_model": "gpt-4o-mini",
281 "website": "https://platform.openai.com/api-keys",
282 "key_prefix": "sk-",
283 },
284 "deepseek": {
285 "env_var": "DEEPSEEK_API_KEY",
286 "label": "DeepSeek",
287 "default_model": "deepseek-chat",
288 "website": "https://platform.deepseek.com/api_keys",
289 "key_prefix": "sk-",
290 },
291 "anthropic": {
292 "env_var": "ANTHROPIC_API_KEY",
293 "label": "Anthropic",
294 "default_model": "claude-sonnet-4",
295 "website": "https://console.anthropic.com/keys",
296 "key_prefix": "sk-ant-",
297 },
298}
301def _get_status_dict() -> dict:
302 """获取所有 Provider 的配置状态。"""
303 status = {}
304 for name, info in API_TEMPLATE.items():
305 key = os.environ.get(info["env_var"], "")
306 if not key and ENV_FILE.exists():
307 for line in ENV_FILE.read_text().splitlines():
308 if line.startswith(info["env_var"] + "="):
309 val = line.split("=", 1)[1].strip()
310 if val and val != "sk-xxx":
311 key = val
312 break
313 preview = ""
314 if key:
315 preview = key[:8] + "..." + key[-4:] if len(key) > 20 else key
316 status[name] = {
317 "configured": bool(key),
318 "preview": preview,
319 }
320 return status
323def _test_connection(provider: str, api_key: str) -> tuple[bool, str]:
324 """测试 API 连接。"""
325 try:
326 import httpx
327 if provider == "openai":
328 resp = httpx.get(
329 "https://api.openai.com/v1/models",
330 headers={"Authorization": f"Bearer {api_key}"},
331 timeout=10,
332 )
333 if resp.status_code == 200:
334 return True, ""
335 elif resp.status_code == 401:
336 return False, "API Key 无效(401 未授权)"
337 elif resp.status_code == 429:
338 return False, "请求过于频繁,请稍后重试"
339 else:
340 return False, f"返回状态码 {resp.status_code},请检查 Key 是否有对应权限"
341 elif provider == "deepseek":
342 resp = httpx.post(
343 "https://api.deepseek.com/chat/completions",
344 headers={
345 "Authorization": f"Bearer {api_key}",
346 "Content-Type": "application/json",
347 },
348 json={
349 "model": "deepseek-chat",
350 "messages": [{"role": "user", "content": "hi"}],
351 "max_tokens": 1,
352 },
353 timeout=10,
354 )
355 if resp.status_code == 200:
356 return True, ""
357 elif resp.status_code == 401:
358 return False, "API Key 无效(401 未授权)"
359 elif resp.status_code == 402:
360 return False, "账户余额不足,请充值"
361 else:
362 return False, f"返回状态码 {resp.status_code},请检查"
363 elif provider == "anthropic":
364 resp = httpx.post(
365 "https://api.anthropic.com/v1/messages",
366 headers={
367 "x-api-key": api_key,
368 "anthropic-version": "2023-06-01",
369 "Content-Type": "application/json",
370 },
371 json={
372 "model": "claude-sonnet-4-20250514",
373 "max_tokens": 1,
374 "messages": [{"role": "user", "content": "hi"}],
375 },
376 timeout=10,
377 )
378 if resp.status_code == 200:
379 return True, ""
380 elif resp.status_code == 401:
381 return False, "API Key 无效(401 未授权)"
382 else:
383 return False, f"返回状态码 {resp.status_code},请检查"
384 except Exception as e:
385 return False, f"网络连接失败: {str(e)}"
388def _save_config(provider: str, api_key: str):
389 """保存配置到 ~/.agentos/。"""
390 CONFIG_DIR.mkdir(parents=True, exist_ok=True)
391 info = API_TEMPLATE[provider]
393 # Update .env
394 env_lines = []
395 if ENV_FILE.exists():
396 env_lines = ENV_FILE.read_text().splitlines()
397 found = False
398 new_env = []
399 for line in env_lines:
400 if line.startswith(info["env_var"] + "="):
401 new_env.append(f"{info['env_var']}={api_key}")
402 found = True
403 else:
404 new_env.append(line)
405 if not found:
406 new_env.append(f"{info['env_var']}={api_key}")
407 ENV_FILE.write_text("\n".join(new_env) + "\n")
409 # Update config.yaml
410 config = {"version": "1.4.1", "active_provider": provider}
411 import yaml
412 if CONFIG_FILE.exists():
413 existing = yaml.safe_load(CONFIG_FILE.read_text())
414 if existing and "providers" in existing:
415 config["providers"] = existing["providers"]
416 if "providers" not in config:
417 config["providers"] = {}
418 config["providers"][provider] = {"env_var": info["env_var"]}
419 with open(CONFIG_FILE, "w") as f:
420 yaml.dump(config, f, default_flow_style=False)
422 # Set env var for current process
423 os.environ[info["env_var"]] = api_key
426class PanelHandler(http.server.BaseHTTPRequestHandler):
427 """HTTP 请求处理。"""
429 def log_message(self, format, *args):
430 pass # 静默
432 def do_GET(self):
433 if self.path == "/" or self.path == "/index.html":
434 html = HTML.format(css=CSS)
435 self._send_html(html)
436 elif self.path == "/api/status":
437 status = _get_status_dict()
438 self._send_json(status)
439 else:
440 self.send_error(404)
442 def do_POST(self):
443 if self.path == "/api/save":
444 body = self._read_body()
445 provider = body.get("provider", "")
446 key = body.get("key", "")
447 if provider not in API_TEMPLATE:
448 self._send_json({"ok": False, "error": "未知服务商"})
449 return
450 ok, err = _test_connection(provider, key)
451 if ok:
452 _save_config(provider, key)
453 info = API_TEMPLATE[provider]
454 self._send_json({
455 "ok": True,
456 "model": info["default_model"],
457 })
458 else:
459 self._send_json({"ok": False, "error": err})
460 elif self.path == "/api/verify":
461 body = self._read_body()
462 provider = body.get("provider", "")
463 key = body.get("key", "")
464 if provider not in API_TEMPLATE:
465 self._send_json({"valid": False, "error": "未知服务商"})
466 return
467 ok, err = _test_connection(provider, key)
468 self._send_json({"valid": ok, "error": err})
469 else:
470 self.send_error(404)
472 def _send_html(self, html: str):
473 self.send_response(200)
474 self.send_header("Content-Type", "text/html; charset=utf-8")
475 self.end_headers()
476 self.wfile.write(html.encode("utf-8"))
478 def _send_json(self, data: dict):
479 self.send_response(200)
480 self.send_header("Content-Type", "application/json; charset=utf-8")
481 self.end_headers()
482 self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
484 def _read_body(self) -> dict:
485 length = int(self.headers.get("Content-Length", 0))
486 raw = self.rfile.read(length).decode("utf-8") if length else "{}"
487 return json.loads(raw)
490def start_panel(port: int = 18480, open_browser: bool = True):
491 """启动配置面板 HTTP 服务。"""
492 server = http.server.HTTPServer(("127.0.0.1", port), PanelHandler)
493 url = f"http://127.0.0.1:{port}"
495 print(f"""
496 ╔══════════════════════════════════════════════╗
497 ║ AgentOS 配置面板已启动 ║
498 ╠══════════════════════════════════════════════╣
499 ║ ║
500 ║ 访问地址: {url} ║
501 ║ ║
502 ║ 按 Ctrl+C 停止服务 ║
503 ╚══════════════════════════════════════════════╝
504""")
506 if open_browser:
507 try:
508 webbrowser.open(url)
509 print(" 已自动打开浏览器。如未打开,请手动访问上方地址。\n")
510 except Exception:
511 print(" 请手动在浏览器中打开上方地址。\n")
513 try:
514 server.serve_forever()
515 except KeyboardInterrupt:
516 print("\n 配置面板已关闭。\n")
517 server.server_close()
520if __name__ == "__main__":
521 port = int(sys.argv[1]) if len(sys.argv) > 1 else 18480
522 start_panel(port)