Files
u-claw/portable/config-server/public/index.html
hfshfg 6eb54f8ea3 feat(portable): bug 一键上报 + 自动崩溃上报 + openclaw 升级 2026.6.6
Bug 上报(方便服务器侧排查便携版问题):
- 新增 lib/report-bug.mjs:算设备指纹→sk-uc apiKey,收集 openclaw 版本/
  系统信息/最近日志(gzip+base64),POST 到 api.u-claw.org/recharge/bug/submit。
  静默失败,绝不影响主流程。复用 fingerprint.mjs + xiapan-client.mjs。
- config-server 加 POST /api/report-bug:本地补齐信息后转发,配置中心加
  「🐛 报 Bug」表单(标题/描述/是否附日志)。
- Windows-Start.bat / Mac-Start.command:gateway 端口耗尽或异常退出时
  自动后台上报(Ctrl+C 正常停止不上报)。

openclaw 升级:
- OPENCLAW_VERSION 2026.4.29 → 2026.6.6(X 盘真实 USB 实测 gateway
  启动/ready、Dashboard 200、旧配置兼容、虾盘云 status 均通过)。

便携性改进(借鉴 OpenClaw-Windows-Portable):
- setup.sh / 启动脚本的 npm install 设 npm_config_cache 指向盘内
  app/.npm-cache,避免污染系统缓存,拔盘不留痕。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:46:42 +08:00

828 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>U-Claw 配置中心</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #0a0a0a; color: #e0e0e0; min-height: 100vh; }
.container { max-width: 720px; margin: 0 auto; padding: 28px 20px 60px; }
h1 { text-align: center; font-size: 1.8em; margin-bottom: 4px; }
h1 .lobster { color: #ff6b35; }
.subtitle { text-align: center; color: #888; margin-bottom: 24px; font-size: 0.92em; }
/* Steps */
.steps { display: flex; gap: 0; margin-bottom: 24px; background: #1a1a1a; border-radius: 10px; overflow: hidden; border: 1px solid #333; }
.step { flex: 1; text-align: center; padding: 12px 8px; font-size: 0.85em; color: #666; cursor: pointer; transition: all 0.2s; }
.step.active { color: #ff6b35; background: rgba(255,107,53,0.08); font-weight: 600; }
.step.done { color: #4caf50; }
.step .num { display: inline-block; width: 22px; height: 22px; line-height: 22px; border-radius: 50%; background: #333; color: #888; font-size: 0.8em; margin-right: 4px; }
.step.active .num { background: #ff6b35; color: #fff; }
.step.done .num { background: #4caf50; color: #fff; }
/* Section */
.section { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 24px; margin-bottom: 16px; display: none; }
.section.active { display: block; }
.section h2 { color: #ff6b35; font-size: 1.15em; margin-bottom: 6px; }
.section .desc { color: #888; font-size: 0.88em; margin-bottom: 16px; }
/* Model cards */
.model-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px; }
.model-card { background: #222; border: 2px solid #333; border-radius: 10px; padding: 14px; cursor: pointer; transition: all 0.15s; position: relative; }
.model-card:hover { border-color: #555; transform: translateY(-1px); }
.model-card.selected { border-color: #ff6b35; background: rgba(255,107,53,0.06); }
.model-card h4 { color: #fff; font-size: 0.95em; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.model-card p { color: #888; font-size: 0.78em; line-height: 1.4; }
.tag { font-size: 0.68em; padding: 2px 7px; border-radius: 10px; font-weight: 500; }
.tag.hot { background: rgba(255,107,53,0.2); color: #ff6b35; }
.tag.free { background: rgba(76,175,80,0.2); color: #4caf50; }
.tag.cn { background: rgba(33,150,243,0.2); color: #2196f3; }
.tag.fast { background: rgba(156,39,176,0.2); color: #ce93d8; }
.tag.cheap { background: rgba(255,193,7,0.2); color: #ffd54f; }
.check { position: absolute; top: 8px; right: 10px; color: #ff6b35; font-size: 1.2em; display: none; }
.model-card.selected .check { display: block; }
/* Buy link */
.buy-link { display: inline-flex; align-items: center; gap: 4px; color: #ff6b35; text-decoration: none; font-size: 0.82em; margin-top: 6px; }
.buy-link:hover { text-decoration: underline; }
/* Forms */
.form-group { display: block; margin-bottom: 16px; clear: both; }
.form-group label {
display: block;
width: auto;
float: none;
color: #ccc;
margin-bottom: 8px;
font-size: 0.9em;
}
.form-group input,
input[type="text"],
input[type="password"] {
display: block;
width: 100%;
min-width: 0;
min-height: 46px;
padding: 11px 14px;
background: #222;
border: 1px solid #444;
border-radius: 8px;
color: #fff;
font-size: 0.95em;
line-height: 1.3;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: #ff6b35; }
.input-wrap { position: relative; display: block; width: 100%; min-width: 0; }
.input-wrap input { padding-right: 70px; }
.toggle-pw {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
min-width: 44px;
height: 30px;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 6px;
color: #ccc;
cursor: pointer;
font-size: 0.82em;
line-height: 28px;
text-align: center;
}
.toggle-pw:hover { color: #ccc; }
.hint { color: #888; font-size: 0.8em; margin-top: 6px; line-height: 1.5; }
.hint a { color: #ff6b35; text-decoration: none; }
.hint a:hover { text-decoration: underline; }
/* API Key info box */
.api-info { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 14px; margin-bottom: 16px; }
.api-info h4 { color: #ff6b35; font-size: 0.9em; margin-bottom: 8px; }
.api-info .provider-links { display: flex; flex-direction: column; gap: 6px; }
.api-info a { color: #82aaff; text-decoration: none; font-size: 0.85em; display: flex; align-items: center; gap: 6px; }
.api-info a:hover { color: #ff6b35; }
/* Buttons */
.btn { display: inline-block; padding: 12px 28px; border: none; border-radius: 8px; font-size: 0.95em; cursor: pointer; transition: all 0.15s; font-weight: 500; }
.btn-primary { background: #ff6b35; color: #fff; }
.btn-primary:hover { background: #e55a25; }
.btn-primary:disabled { background: #444; color: #888; cursor: not-allowed; }
.btn-secondary { background: #333; color: #ccc; }
.btn-secondary:hover { background: #444; }
.btn-row { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
/* Result */
.result { text-align: center; padding: 40px 20px; }
.result .icon { font-size: 3em; margin-bottom: 12px; }
.result h2 { color: #4caf50; margin-bottom: 8px; }
.result p { color: #888; font-size: 0.92em; line-height: 1.6; }
.result .url { color: #ff6b35; font-size: 1.1em; font-weight: 600; margin: 12px 0; }
/* Toast */
.toast { position: fixed; top: 20px; right: 20px; background: #4caf50; color: #fff; padding: 12px 20px; border-radius: 8px; font-size: 0.9em; opacity: 0; transform: translateY(-10px); transition: all 0.3s; z-index: 999; }
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { background: #f44336; }
/* Channel form */
.channel-card { transition: all 0.2s; }
.channel-card.selected { border-color: #ff6b35; background: rgba(255,107,53,0.06); }
.channel-form { padding-top: 0; margin-top: 10px; border-top: 1px solid #333; max-height: 0; overflow: hidden; transition: max-height 0.3s ease, padding 0.3s ease; }
.channel-form.open { max-height: 300px; padding-top: 12px; }
.channel-form .form-group { margin-bottom: 10px; }
.channel-form label { font-size: 0.82em; color: #aaa; margin-bottom: 4px; }
.channel-form input { font-size: 0.88em; padding: 9px 12px; }
.channel-form .hint { font-size: 0.75em; margin-top: 4px; }
/* Status bar */
.status-bar {
background: #1a1a1a; border: 1px solid #333; border-radius: 8px;
padding: 10px 16px; margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
}
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.dot.on { background: #4caf50; animation: pulse 2s infinite; }
.dot.off { background: #f44336; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.status-text { flex: 1; font-size: 0.9em; }
.status-text a { color: #ff6b35; text-decoration: none; }
/* Skills section */
.skills-section { margin-top: 8px; }
.skill-list { display: flex; flex-direction: column; gap: 8px; }
.skill-item { background: #222; border: 1px solid #333; border-radius: 8px; padding: 12px; display: flex; align-items: center; justify-content: space-between; }
.skill-item .info h4 { color: #fff; font-size: 0.9em; }
.skill-item .info p { color: #888; font-size: 0.78em; }
.skill-item .status { font-size: 0.8em; color: #4caf50; }
</style>
</head>
<body>
<div class="container">
<h1><span class="lobster">🦞</span> U-Claw 配置中心</h1>
<p class="subtitle">选择模型,填入 API Key一键启动</p>
<!-- Connection status -->
<div class="status-bar">
<div class="dot" id="statusDot"></div>
<div class="status-text" id="statusText">检测中...</div>
</div>
<!-- Steps -->
<div class="steps">
<div class="step active" data-step="1" onclick="goStep(1)"><span class="num">1</span>选模型</div>
<div class="step" data-step="2" onclick="goStep(2)"><span class="num">2</span>填 Key</div>
<div class="step" data-step="3" onclick="goStep(3)"><span class="num">3</span>启动</div>
</div>
<!-- Step 1: Select Model -->
<div class="section active" id="step1">
<h2>选择 AI 模型</h2>
<p class="desc">推荐国内用户用 MiniMax / Kimi / DeepSeek</p>
<div class="model-grid">
<div class="model-card" data-provider="minimax" data-base="https://api.minimax.chat/v1" data-model="MiniMax-Text-01">
<span class="check"></span>
<h4>MiniMax <span class="tag hot">推荐</span><span class="tag cn">国内</span></h4>
<p>MiniMax-Text-01速度快性价比高</p>
<a class="buy-link" href="https://platform.minimaxi.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="kimi" data-base="https://api.moonshot.cn/v1" data-model="moonshot-v1-auto">
<span class="check"></span>
<h4>Kimi (月之暗面) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>moonshot-v1-auto智能选模型</p>
<a class="buy-link" href="https://platform.moonshot.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="deepseek" data-base="https://api.deepseek.com/v1" data-model="deepseek-chat">
<span class="check"></span>
<h4>DeepSeek <span class="tag cn">国内</span><span class="tag cheap">便宜</span></h4>
<p>deepseek-chat超低价格</p>
<a class="buy-link" href="https://platform.deepseek.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="zai" data-base="" data-model="glm-5">
<span class="check"></span>
<h4>智谱 GLM <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
<p>glm-5zai provider免费额度可用</p>
<a class="buy-link" href="https://open.bigmodel.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="qwen" data-base="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-turbo">
<span class="check"></span>
<h4>通义千问 <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
<p>qwen-turbo阿里云出品</p>
<a class="buy-link" href="https://dashscope.console.aliyun.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="doubao" data-base="https://ark.cn-beijing.volces.com/api/v3" data-model="doubao-1.5-pro-32k">
<span class="check"></span>
<h4>豆包 (字节) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>doubao-1.5-pro字节跳动</p>
<a class="buy-link" href="https://console.volcengine.com/ark" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="openai" data-base="https://api.openai.com/v1" data-model="gpt-4o">
<span class="check"></span>
<h4>OpenAI <span class="tag hot"></span></h4>
<p>GPT-4o需翻墙或中转</p>
<a class="buy-link" href="https://platform.openai.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="anthropic" data-base="https://api.anthropic.com/v1" data-model="claude-sonnet-4-20250514">
<span class="check"></span>
<h4>Claude <span class="tag hot"></span></h4>
<p>Claude Sonnet 4需翻墙或中转</p>
<a class="buy-link" href="https://console.anthropic.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="groq" data-base="https://api.groq.com/openai/v1" data-model="llama-3.3-70b-versatile">
<span class="check"></span>
<h4>Groq <span class="tag fast">极快</span><span class="tag free">有免费</span></h4>
<p>Llama 3.3 70B超高速推理</p>
<a class="buy-link" href="https://console.groq.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="siliconflow" data-base="https://api.siliconflow.cn/v1" data-model="Qwen/Qwen2.5-72B-Instruct">
<span class="check"></span>
<h4>硅基流动 <span class="tag cn">国内</span><span class="tag cheap">便宜</span></h4>
<p>多模型聚合,价格低</p>
<a class="buy-link" href="https://cloud.siliconflow.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="custom" data-base="" data-model="" style="grid-column: 1 / -1; border-style: dashed;">
<span class="check"></span>
<h4>🔧 自定义 API <span class="tag">OpenAI 兼容</span></h4>
<p>填写任意 OpenAI 兼容 API 地址OpenRouter、API2D、New API、One API 等)</p>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="nextStep1" disabled>下一步 →</button>
</div>
</div>
<!-- Step 2: API Key -->
<div class="section" id="step2">
<h2>填写 API Key</h2>
<p class="desc" id="step2desc">请填写对应模型的 API Key</p>
<div class="api-info" id="buyInfo">
<h4>📌 如何获取 API Key</h4>
<div class="provider-links" id="providerLinks"></div>
</div>
<div id="customFields" style="display:none;">
<div class="form-group">
<label>API 地址 (Base URL)</label>
<input type="text" id="customBase" placeholder="https://api.example.com/v1">
</div>
<div class="form-group">
<label>模型名称</label>
<input type="text" id="customModel" placeholder="gpt-4o">
</div>
</div>
<div class="form-group">
<label>API Key</label>
<div class="input-wrap">
<input type="password" id="apiKey" placeholder="sk-...">
<button class="toggle-pw" onclick="togglePw('apiKey')">显示</button>
</div>
<p class="hint">你的 Key 仅保存在本地 U 盘,不会上传到任何服务器</p>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(1)">← 返回</button>
<button class="btn btn-primary" onclick="saveConfig()">保存并继续 →</button>
</div>
</div>
<!-- Step 3: Done -->
<div class="section" id="step3">
<div class="result">
<div class="icon">🦞</div>
<h2>配置完成!</h2>
<p>模型和 API Key 已保存到本地</p>
<div class="url" id="gatewayUrl">http://127.0.0.1:18789</div>
<div style="margin-top: 24px; text-align: left;">
<h3 style="color: #ff6b35; font-size: 0.95em; margin-bottom: 12px;">📱 接入聊天平台(可选)</h3>
<p style="color: #888; font-size: 0.82em; margin-bottom: 12px;">点击卡片展开填写,不填可直接跳过:</p>
<div class="model-grid">
<div class="model-card channel-card" onclick="toggleChannel(this, 'telegram')">
<h4>✈️ Telegram</h4>
<p>接入 Telegram Bot</p>
<div class="channel-form" id="form-telegram" onclick="event.stopPropagation()">
<div class="form-group">
<label>Bot Token</label>
<input type="text" id="ch-telegram-token" placeholder="123456:ABC-DEF...">
<p class="hint"><a href="https://t.me/BotFather" target="_blank">→ 从 @BotFather 获取</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'qqbot')">
<h4>🐧 QQ Bot</h4>
<p>接入 QQ 群/私聊机器人</p>
<div class="channel-form" id="form-qqbot" onclick="event.stopPropagation()">
<div class="form-group">
<label>App ID</label>
<input type="text" id="ch-qqbot-appid" placeholder="应用 ID">
</div>
<div class="form-group">
<label>Client Secret</label>
<input type="text" id="ch-qqbot-secret" placeholder="应用密钥">
<p class="hint"><a href="https://q.qq.com" target="_blank">→ 从 QQ 开放平台获取</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'feishu')">
<h4>📘 飞书</h4>
<p>接入飞书机器人</p>
<div class="channel-form" id="form-feishu" onclick="event.stopPropagation()">
<div class="form-group">
<label>App ID</label>
<input type="text" id="ch-feishu-appid" placeholder="cli_xxx...">
</div>
<div class="form-group">
<label>App Secret</label>
<input type="text" id="ch-feishu-secret" placeholder="应用密钥">
<p class="hint"><a href="https://open.feishu.cn/app" target="_blank">→ 从飞书开放平台获取</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'wecom')">
<h4>🏢 企业微信</h4>
<p>接入企业微信机器人</p>
<div class="channel-form" id="form-wecom" onclick="event.stopPropagation()">
<div class="form-group">
<label>Bot ID</label>
<input type="text" id="ch-wecom-botid" placeholder="aib...">
</div>
<div class="form-group">
<label>Secret</label>
<input type="text" id="ch-wecom-secret" placeholder="密钥">
<p class="hint"><a href="https://work.weixin.qq.com" target="_blank">→ 从企业微信管理后台获取</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" style="grid-column: 1 / -1; border-color: #07c160;" onclick="startWeChatLogin(event)">
<h4>💬 微信 <span class="tag" style="background:rgba(7,193,96,0.2);color:#07c160;">个人微信</span></h4>
<p>扫码接入个人微信,用微信聊天控制 AI</p>
<div class="channel-form" id="form-wechat" onclick="event.stopPropagation()" style="max-height:none;overflow:visible;">
<div id="wechat-qr" style="text-align:center;padding:16px;">
<p style="color:#888;font-size:0.85em;">点击此卡片开始扫码</p>
</div>
<div id="wechat-status" style="text-align:center;color:#888;font-size:0.85em;"></div>
</div>
</div>
</div>
</div>
<div style="margin-top: 28px; text-align: center;">
<button class="btn btn-primary" onclick="saveChannelsAndOpen()" style="font-size: 1.15em; padding: 16px 48px;">🚀 打开 Dashboard</button>
<p style="color: #888; font-size: 0.8em; margin-top: 10px;">渠道配置会自动写入Gateway 需重启生效</p>
</div>
<div class="btn-row" style="justify-content: center; margin-top: 16px;">
<button class="btn btn-secondary" onclick="goStep(1)">重新配置模型</button>
<button class="btn btn-secondary" onclick="viewConfig()">查看配置文件</button>
</div>
</div>
</div>
<!-- Skills & info (always visible) -->
<div class="section active skills-section" style="display:block; margin-top: 8px;">
<h2>📦 目录说明</h2>
<p class="desc">U 盘内的文件结构</p>
<div class="skill-list">
<div class="skill-item">
<div class="info">
<h4>skills-cn/ — 中文技能</h4>
<p>放入 .md 技能文件OpenClaw 自动加载</p>
</div>
<span class="status">📁</span>
</div>
<div class="skill-item">
<div class="info">
<h4>app/ — 运行时 + 核心</h4>
<p>Node.js 运行时、OpenClaw 核心代码</p>
</div>
<span class="status">⚙️</span>
</div>
<div class="skill-item">
<div class="info">
<h4>data/ — 配置和数据</h4>
<p>API Key、聊天历史等保存在此仅本地</p>
</div>
<span class="status">🔒</span>
</div>
</div>
</div>
<div class="section active skills-section" style="display:block; margin-top: 8px;">
<h2>🐛 反馈 / 报 Bug</h2>
<p class="desc">遇到问题?一键反馈给我们,会自动附带版本号和最近日志(不含你的 API Key方便我们排查。</p>
<div class="form-group">
<label>问题标题 <span style="color:#888;font-weight:normal">(必填,简短描述)</span></label>
<input type="text" id="bugTitle" placeholder="例如:点击打开 Dashboard 没反应" maxlength="200">
</div>
<div class="form-group">
<label>详细描述 <span style="color:#888;font-weight:normal">(可选)</span></label>
<textarea id="bugDesc" rows="4" placeholder="发生了什么?做了哪些操作?" style="width:100%;padding:12px;border-radius:8px;background:#1a1a1a;border:1px solid #333;color:#eee;font-family:inherit;font-size:0.95em;box-sizing:border-box;resize:vertical"></textarea>
</div>
<div class="form-group" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="bugLogs" checked style="width:auto;margin:0">
<label for="bugLogs" style="margin:0;font-weight:normal">附带最近运行日志(推荐,帮助定位问题)</label>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="bugSubmitBtn" onclick="submitBug()">提交反馈</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// --- State ---
let selectedProvider = null;
let selectedBase = '';
let selectedModel = '';
// --- Model cards ---
document.querySelectorAll('#step1 .model-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.buy-link')) return;
document.querySelectorAll('#step1 .model-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedProvider = card.dataset.provider;
selectedBase = card.dataset.base;
selectedModel = card.dataset.model;
document.getElementById('nextStep1').disabled = false;
});
});
document.getElementById('nextStep1').addEventListener('click', () => goStep(2));
// --- Steps ---
function goStep(n) {
document.querySelectorAll('.section:not(.skills-section)').forEach(s => s.classList.remove('active'));
document.getElementById('step' + n).classList.add('active');
document.querySelectorAll('.steps .step').forEach((s, i) => {
s.classList.remove('active', 'done');
if (i + 1 < n) s.classList.add('done');
if (i + 1 === n) s.classList.add('active');
});
if (n === 2) setupStep2();
}
const PROVIDER_NAMES = {
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
siliconflow: '硅基流动', custom: '自定义'
};
function setupStep2() {
var isCustom = selectedProvider === 'custom';
document.getElementById('customFields').style.display = isCustom ? 'block' : 'none';
var name = PROVIDER_NAMES[selectedProvider] || selectedProvider;
document.getElementById('step2desc').textContent = '请填写 ' + name + ' 的 API Key';
var card = document.querySelector('#step1 .model-card[data-provider="' + selectedProvider + '"]');
var buyLink = card ? card.querySelector('.buy-link') : null;
var linksDiv = document.getElementById('providerLinks');
linksDiv.innerHTML = '';
if (buyLink) {
linksDiv.innerHTML = '<a href="' + buyLink.href + '" target="_blank">🔑 ' + name + ' — 获取 API Key</a>';
}
linksDiv.innerHTML += '<a href="https://platform.minimaxi.com/" target="_blank">🔑 MiniMax — 国内推荐,注册送额度</a>' +
'<a href="https://open.bigmodel.cn/" target="_blank">🔑 智谱 GLM — 有免费额度</a>' +
'<a href="https://cloud.siliconflow.cn/" target="_blank">🔑 硅基流动 — 多模型聚合,便宜</a>';
}
// --- Toggle password ---
function togglePw(id) {
var inp = document.getElementById(id);
var btn = inp.parentElement.querySelector('.toggle-pw');
if (inp.type === 'password') { inp.type = 'text'; btn.textContent = '隐藏'; }
else { inp.type = 'password'; btn.textContent = '显示'; }
}
// --- Build OpenClaw config ---
function buildConfig(provider, base, model, apiKey) {
var baseConfig = {
gateway: { auth: { mode: 'token', token: 'uclaw' } },
commands: { native: 'auto', nativeSkills: 'auto', restart: true, ownerDisplay: 'raw' },
meta: { lastTouchedVersion: '2026.3.13', lastTouchedAt: new Date().toISOString() }
};
// 智谱 GLM uses built-in zai provider
if (provider === 'zai') {
return Object.assign({}, baseConfig, {
env: { ZAI_API_KEY: apiKey },
agents: { defaults: { model: { primary: 'zai/' + model } } }
});
}
var providerName = provider || 'myProvider';
var providers = {};
providers[providerName] = {
baseUrl: base,
apiKey: apiKey,
api: 'openai-completions',
models: [{
id: model,
name: model,
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192
}]
};
return Object.assign({}, baseConfig, {
models: { mode: 'merge', providers: providers },
agents: { defaults: { model: { primary: providerName + '/' + model } } }
});
}
// --- Save model config ---
async function saveConfig() {
var apiKey = document.getElementById('apiKey').value.trim();
if (!apiKey) { showToast('请填写 API Key', true); return; }
var base = selectedBase;
var model = selectedModel;
if (selectedProvider === 'custom') {
base = document.getElementById('customBase').value.trim();
model = document.getElementById('customModel').value.trim();
if (!base || !model) { showToast('请填写 API 地址和模型名称', true); return; }
}
var config = buildConfig(selectedProvider, base, model, apiKey);
try {
var res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) throw new Error('保存失败');
showToast('配置已保存!');
goStep(3);
} catch (e) {
showToast('保存失败: ' + e.message, true);
}
}
// --- Channel toggle ---
function toggleChannel(card, type) {
var form = document.getElementById('form-' + type);
var isOpen = form.classList.contains('open');
if (isOpen) {
form.classList.remove('open');
card.classList.remove('selected');
} else {
form.classList.add('open');
card.classList.add('selected');
var firstInput = form.querySelector('input');
if (firstInput) setTimeout(function() { firstInput.focus(); }, 100);
}
}
// --- WeChat login ---
var wechatSession = null;
var wechatPolling = false;
async function startWeChatLogin(e) {
var form = document.getElementById('form-wechat');
// If form is already open and showing QR, don't restart
if (form.classList.contains('open') && wechatPolling) {
return;
}
// Toggle form open
var card = form.closest('.channel-card');
if (!form.classList.contains('open')) {
form.classList.add('open');
card.classList.add('selected');
}
var qrDiv = document.getElementById('wechat-qr');
var statusDiv = document.getElementById('wechat-status');
qrDiv.innerHTML = '<p style="color:#888;">正在获取二维码...</p>';
statusDiv.textContent = '';
try {
var res = await fetch('/api/wechat/start', { method: 'POST' });
var data = await res.json();
if (data.error) throw new Error(data.error);
wechatSession = data.sessionKey;
qrDiv.innerHTML = '<img src="' + data.qrcodeUrl + '" style="width:200px;height:200px;image-rendering:pixelated;border-radius:8px;">';
statusDiv.textContent = '请用微信扫描上方二维码';
statusDiv.style.color = '#07c160';
// Start polling
wechatPolling = true;
pollWeChatStatus();
} catch (err) {
qrDiv.innerHTML = '<p style="color:#f44336;">获取二维码失败</p>';
statusDiv.textContent = err.message;
statusDiv.style.color = '#f44336';
}
}
async function pollWeChatStatus() {
if (!wechatSession || !wechatPolling) return;
try {
var res = await fetch('/api/wechat/status?session=' + encodeURIComponent(wechatSession));
var data = await res.json();
var qrDiv = document.getElementById('wechat-qr');
var statusDiv = document.getElementById('wechat-status');
if (data.status === 'confirmed') {
wechatPolling = false;
qrDiv.innerHTML = '<div style="font-size:3em;">&#10004;</div>';
statusDiv.innerHTML = '<span style="color:#4caf50;font-weight:600;">微信连接成功!</span><br><span style="font-size:0.8em;">账号: ' +
(data.accountId || '') + (data.pluginInstalled ? '' : '<br>⚠️ 插件未安装,请重启 Gateway') + '<br>需重启 Gateway 生效</span>';
statusDiv.style.color = '#4caf50';
showToast('微信连接成功!');
return;
}
if (data.status === 'refreshed') {
qrDiv.innerHTML = '<img src="' + data.qrcodeUrl + '" style="width:200px;height:200px;image-rendering:pixelated;border-radius:8px;">';
statusDiv.textContent = '二维码已刷新,请重新扫码';
statusDiv.style.color = '#ff6b35';
} else if (data.status === 'scaned') {
statusDiv.textContent = '已扫码,请在手机上确认...';
statusDiv.style.color = '#07c160';
} else if (data.status === 'expired') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#888;">二维码已过期,<a href="#" onclick="startWeChatLogin(event);return false;" style="color:#ff6b35;">重新获取</a></p>';
statusDiv.textContent = data.message || '';
statusDiv.style.color = '#f44336';
return;
} else if (data.status === 'error') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#f44336;">连接失败</p>';
statusDiv.textContent = data.message || 'Unknown error';
statusDiv.style.color = '#f44336';
return;
}
// wait -> continue polling
setTimeout(pollWeChatStatus, 1500);
} catch (err) {
setTimeout(pollWeChatStatus, 3000);
}
}
// --- Save channels and open dashboard ---
async function saveChannelsAndOpen() {
var channels = {};
var tgToken = document.getElementById('ch-telegram-token').value.trim();
if (tgToken) channels.telegram = { enabled: true, token: tgToken, dmPolicy: 'pairing' };
var qqId = document.getElementById('ch-qqbot-appid').value.trim();
var qqSecret = document.getElementById('ch-qqbot-secret').value.trim();
if (qqId && qqSecret) channels.qqbot = { enabled: true, appId: qqId, clientSecret: qqSecret };
var fsId = document.getElementById('ch-feishu-appid').value.trim();
var fsSecret = document.getElementById('ch-feishu-secret').value.trim();
if (fsId && fsSecret) channels.feishu = { enabled: true, appId: fsId, appSecret: fsSecret };
var wcBotId = document.getElementById('ch-wecom-botid').value.trim();
var wcSecret = document.getElementById('ch-wecom-secret').value.trim();
if (wcBotId && wcSecret) channels.wecom = { enabled: true, botId: wcBotId, secret: wcSecret };
if (Object.keys(channels).length > 0) {
try {
var res = await fetch('/api/config');
var existing = res.ok ? await res.json() : {};
// 清除旧版废弃键,防止 OpenClaw 报 "agent.* was moved" 错误
delete existing.agent;
existing.channels = Object.assign(existing.channels || {}, channels);
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existing)
});
showToast('渠道配置已保存');
} catch(e) {}
}
window.open(gatewayUrl(), '_blank');
}
// --- View config ---
async function viewConfig() {
try {
var res = await fetch('/api/config');
var config = await res.json();
var text = JSON.stringify(config, null, 2);
var w = window.open('', '_blank');
w.document.write('<pre style="background:#111;color:#eee;padding:20px;font-size:14px;">' +
text.replace(/</g, '&lt;') + '</pre>');
} catch (e) {
showToast('读取配置失败: ' + e.message, true);
}
}
// --- Bug report ---
async function submitBug() {
var title = document.getElementById('bugTitle').value.trim();
if (title.length < 3) { showToast('请填写问题标题(至少 3 个字)', true); return; }
var desc = document.getElementById('bugDesc').value.trim();
var includeLogs = document.getElementById('bugLogs').checked;
var btn = document.getElementById('bugSubmitBtn');
btn.disabled = true; btn.textContent = '提交中…';
try {
var res = await fetch('/api/report-bug', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title, description: desc, includeLogs: includeLogs })
});
var data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.reason || data.error || '提交失败');
showToast('反馈已提交,编号 #' + data.id + ',谢谢!');
document.getElementById('bugTitle').value = '';
document.getElementById('bugDesc').value = '';
} catch (e) {
showToast('提交失败:' + e.message, true);
} finally {
btn.disabled = false; btn.textContent = '提交反馈';
}
}
// --- Toast ---
function showToast(msg, isError) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isError ? ' error' : '');
setTimeout(function() { t.className = 'toast'; }, 3000);
}
// --- Find active gateway port (18789-18799) ---
var _gatewayPort = null;
async function findGatewayPort() {
for (var p = 18789; p <= 18799; p++) {
try {
await fetch('http://127.0.0.1:' + p + '/', { mode: 'no-cors' });
_gatewayPort = p;
return p;
} catch(e) {}
}
_gatewayPort = null;
return null;
}
function gatewayUrl() {
var p = _gatewayPort || 18789;
return 'http://127.0.0.1:' + p + '/#token=uclaw';
}
// --- Check gateway status ---
async function checkGateway() {
var dot = document.getElementById('statusDot');
var text = document.getElementById('statusText');
var port = await findGatewayPort();
if (port) {
dot.className = 'dot on';
text.innerHTML = 'Gateway 运行中 (端口 ' + port + ') · <a href="' + gatewayUrl() + '" target="_blank">打开 Dashboard →</a>';
document.getElementById('gatewayUrl').textContent = 'http://127.0.0.1:' + port;
} else {
dot.className = 'dot off';
text.innerHTML = 'Gateway 未启动 · 请先运行 Windows-Start.bat 或 Mac-Start.command';
}
}
// --- Load existing config ---
async function loadConfig() {
try {
var res = await fetch('/api/config');
var cfg = await res.json();
if (cfg.env && cfg.env.ZAI_API_KEY) {
var card = document.querySelector('#step1 .model-card[data-provider="zai"]');
if (card) card.click();
document.getElementById('apiKey').value = cfg.env.ZAI_API_KEY;
return;
}
if (cfg.models && cfg.models.providers) {
var providerName = Object.keys(cfg.models.providers)[0];
if (providerName) {
var p = cfg.models.providers[providerName];
var card = document.querySelector('#step1 .model-card[data-provider="' + providerName + '"]');
if (card) card.click();
if (p.apiKey) document.getElementById('apiKey').value = p.apiKey;
}
}
} catch(e) {}
}
loadConfig();
checkGateway();
setInterval(checkGateway, 10000);
</script>
</body>
</html>