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