Files
u-claw/portable/config-server/public/index.html
zheng b076815171
Some checks failed
Tests / test (push) Has been cancelled
feat: 海外化改造(新加坡市场)—— 阶段 0-3
按 U-Claw-海外化改造方案.md 与 范围决策记录.md 实施。这是 fork,不回上游:
海外版删掉的正是上游的中国市场默认值。

阶段 0 地基
- 下载源全部改国际:脚本/CI 61 处 + lockfile 880 条 npmmirror URL 归零
  (lockfile 那 880 条是 npm 的 resolved 字段,脚本层参数化根本绕不过它)
- 移除 install.ps1 里三个第三方 GitHub 加速代理,bundle 改直连 + SHA256 校验
  (原来只检查"文件大于 1MB"就解压运行)
- 技能内容与分发分离:skills/manifest.json 单一来源,install.sh 1170→658 行、
  install.ps1 721→546 行,两者技能内容归零
  实测原来是三份不一致:skills-cn 完整、install.sh 约 40%、install.ps1 约 17%,
  且 7 个通用技能只有 U 盘版有 —— 一键安装的用户一个能用的技能都没有
- Node 版本三种(v22.14/16/22.1)统一,新建 NODE_VERSION 单一来源
- Config 页三份合一。portable/Config.html 用根相对路径调 API 却只从 file:// 打开,
  保存功能已静默失效两个月;现缩为 120 行重定向壳
- 测试接入 CI(此前 node --test 无人运行,所有断言形同虚设)

阶段 1 双语可用
- 浏览器侧 i18n:JSON 为源、生成经典 script(file:// 下 fetch 本地 JSON 被拦)
  语言跟盘走不跟机器走:启动器写 data/.openclaw/locale.js
- 8 处硬编码 lang="zh-CN" 归零,data-i18n 覆盖 213 处,词条 en/zh 各 279 条
- B3 单框 Key:12 张模型卡 → 一个输入框,前缀识别 provider,
  服务端 /api/test-key 发 1-token 请求实测,错误映射成人话
  Key 填错到得知:从"直到对话失败"降到 ≤1 秒
- 区域格式 SG:DD/MM/YYYY、12 小时、S$、Asia/Singapore
  (ICU 在 en-SG 下把 SGD 渲染成裸 $,与美元无法区分,故自行拼 S$)
- README 内容分叉而非翻译,§1.3 证据清单逐条清零

阶段 2 降门槛
- 启动逻辑上移 lib/start.mjs:Windows-Start.bat 220→28 行、
  Mac-Start.command 235→33 行
  修掉 Mac 侧两个 bug:控制台端口硬编码 18788(回落时打开死页)、
  微信插件从未在 Mac 上安装
- U 盘根目录 23 → 3 个可点文件,其余进 advanced/
- 首启向导:语言 → 用途(7 角色,manifest 驱动)→ 密钥,答过不再问
- 三档界面,Simple 档隐藏一切技术名词
- 自动自愈:启动失败先自查自修,修不好导出脱敏诊断包
  (Doctor 从"用户要知道去点的工具"变成后台机制)

阶段 3 技能库
- 19 个英文技能,planned 归零。sg-weather / sg-transport 的端点均实测过
- SkillHub 从 56 张手写第三方卡片改为 manifest 生成:703→125 行,中文归零

其他
- origin.json 收拢所有运行时地址,tests/origin.test.mjs 保证迁移不会漏
- portable/ 下用户可见中文归零(由断言保证)
- 82 项测试

未验证(本机无 Windows / 无 pwsh):
- install.ps1、setup.ps1 约 210 行改动从未经 PowerShell 解析器
- 完整启动路径仅在假 node + 假 openclaw 上冒烟
- 8 个 .bat 的盘根推导仅静态断言
详见 U盘实测清单.md

受阻:
- 隐藏黑窗口 —— 需代码签名证书(.vbs 已被 Windows 弃用,替代方案都要签名)
- 场景卡 —— OpenClaw 上游 Dashboard 无预填 prompt 接口
- 官网 36 条 —— 上游 2026-04-14 拆到私有仓库,无权限
2026-08-17 18:37:49 +08:00

877 lines
41 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="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="key.page_title">U-Claw Settings</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>
<script src="/locale.js"></script>
<script src="/lib/i18n/messages.js"></script>
<script src="/lib/i18n/i18n.js"></script>
<style>
/* Interface tiers. The wizard picks one; Settings offers a switch.
Simple hides everything a non-technical user should never have to meet —
endpoints, model names, config files, the China-only channel group. */
body[data-tier="simple"] [data-tier-min="standard"],
body[data-tier="simple"] [data-tier-min="expert"],
body[data-tier="standard"] [data-tier-min="expert"] { display: none !important; }
.tier-switch { margin-top: 22px; text-align: center; color: #888; font-size: .82em; }
.tier-switch select { margin-left: 6px; background: #222; color: #ddd; border: 1px solid #333; border-radius: 6px; padding: 4px 8px; font: inherit; }
</style>
</head>
<body>
<div class="container">
<h1><span class="lobster">🦞</span> <span data-i18n="key.page_title">U-Claw Settings</span></h1>
<p class="subtitle" data-i18n="key.page_subtitle">Paste an API key and you are done.</p>
<!-- Connection status -->
<div class="status-bar">
<div class="dot" id="statusDot"></div>
<div class="status-text" id="statusText" data-i18n="key.checking">Checking…</div>
</div>
<!-- Steps -->
<div class="steps">
<div class="step active" data-step="1" onclick="goStep(1)"><span class="num">1</span><span data-i18n="key.step_key">Your key</span></div>
<div class="step" data-step="3" onclick="goStep(3)"><span class="num">2</span><span data-i18n="key.step_done">Done</span></div>
</div>
<!-- Step 1: one field -->
<div class="section active" id="step1">
<h2 data-i18n="key.heading">Paste your API key</h2>
<p class="desc" data-i18n="key.desc">U-Claw works out the rest by itself. Your key is saved on this drive only — it is never uploaded anywhere.</p>
<div class="form-group">
<div class="input-wrap">
<input type="password" id="apiKey" autocomplete="off" spellcheck="false"
data-i18n-attr="placeholder:key.placeholder" placeholder="sk-…">
<button class="toggle-pw" onclick="togglePw('apiKey')" data-i18n="key.show">Show</button>
</div>
<div id="keyFeedback" class="hint" aria-live="polite"></div>
</div>
<!-- Appears once the key checks out. Without it, picking a key would
also lock the model, and there would be no way to switch later. -->
<div class="form-group" id="modelPicker" style="display:none;" data-tier-min="standard">
<label data-i18n="key.which_model">Which model?</label>
<select id="modelChoice"></select>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="continueBtn" onclick="saveConfig()" disabled data-i18n="key.continue">Continue →</button>
</div>
<div class="api-info">
<h4 data-i18n="key.no_key_title">Do not have a key yet?</h4>
<p class="desc" data-i18n="key.no_key_desc">Google Gemini is the quickest to get: sign in with a Google account, no card needed, and there is a free tier.</p>
<div class="provider-links">
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener" data-i18n="key.get_gemini">🔑 Get a free Gemini key</a>
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener" data-i18n="key.get_openrouter">🔑 OpenRouter — one key for many models</a>
</div>
</div>
<!-- Everything below is for people who already know they need it. -->
<details id="advanced" style="margin-top:18px;" data-tier-min="standard">
<summary style="cursor:pointer; color:#888; font-size:0.85em;" data-i18n="key.advanced">Advanced — set the endpoint manually</summary>
<div class="form-group" style="margin-top:12px;">
<label data-i18n="key.base_url">API endpoint</label>
<input type="text" id="customBase" placeholder="https://api.example.com/v1">
</div>
<div class="form-group">
<label data-i18n="key.model_name">Model name</label>
<input type="text" id="customModel" placeholder="gpt-4o">
</div>
<p class="hint" data-i18n="key.advanced_hint">Only needed for a self-hosted or unlisted provider. Leave blank to let U-Claw decide.</p>
</details>
</div>
<!-- Step 3: Done -->
<div class="section" id="step3">
<div class="result">
<div class="icon">🦞</div>
<h2 data-i18n="done.title">You are set up</h2>
<p data-i18n="done.subtitle">Your model and key are saved on this drive.</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;" data-i18n="ch.title">📱 Connect a chat app (optional)</h3>
<p style="color: #888; font-size: 0.82em; margin-bottom: 12px;" data-i18n="ch.desc">Click a card to fill it in, or skip this entirely.</p>
<div class="model-grid">
<div class="model-card channel-card" onclick="toggleChannel(this, 'whatsapp')">
<h4>💬 WhatsApp</h4>
<p data-i18n="ch.whatsapp_d">Talk to U-Claw from WhatsApp</p>
<div class="channel-form" id="form-whatsapp" onclick="event.stopPropagation()">
<p class="hint" style="color:#ffb84d;" data-i18n="ch.whatsapp_warning">⚠️ This uses an unofficial protocol. Your WhatsApp account can be banned for it. Do not connect a number you rely on.</p>
<div class="form-group">
<label data-i18n="ch.whatsapp_number">Phone number</label>
<input type="text" id="ch-whatsapp-number" placeholder="+65 8123 4567">
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'telegram')">
<h4>✈️ Telegram</h4>
<p data-i18n="ch.telegram_d">Talk to U-Claw from Telegram</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" rel="noopener" data-i18n="ch.telegram_get">→ Get one from @BotFather</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'slack')">
<h4>💼 Slack</h4>
<p data-i18n="ch.slack_d">Talk to U-Claw from your workspace</p>
<div class="channel-form" id="form-slack" onclick="event.stopPropagation()">
<div class="form-group">
<label>Bot Token</label>
<input type="text" id="ch-slack-token" placeholder="xoxb-...">
<p class="hint"><a href="https://api.slack.com/apps" target="_blank" rel="noopener" data-i18n="ch.slack_get">→ Create an app at api.slack.com</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'discord')">
<h4>🎮 Discord</h4>
<p data-i18n="ch.discord_d">Talk to U-Claw from Discord</p>
<div class="channel-form" id="form-discord" onclick="event.stopPropagation()">
<div class="form-group">
<label>Bot Token</label>
<input type="text" id="ch-discord-token" placeholder="MTIz...">
<p class="hint"><a href="https://discord.com/developers/applications" target="_blank" rel="noopener" data-i18n="ch.discord_get">→ Create an app in the Discord developer portal</a></p>
</div>
</div>
</div>
</div>
<!-- China-only channels. Kept for users in China, folded away by
default so they are not noise for everyone else. -->
<details style="margin-top:14px;" data-tier-min="standard">
<summary style="cursor:pointer; color:#888; font-size:0.85em;" data-i18n="ch.china_group">Chat apps used mainly in China</summary>
<div class="model-grid" style="margin-top:12px;">
<div class="model-card channel-card" onclick="toggleChannel(this, 'qqbot')">
<h4>🐧 QQ Bot</h4>
<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="App ID">
</div>
<div class="form-group">
<label>Client Secret</label>
<input type="text" id="ch-qqbot-secret" placeholder="Client Secret">
<p class="hint"><a href="https://q.qq.com" target="_blank" rel="noopener">→ q.qq.com</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'feishu')">
<h4>📘 Feishu / Lark</h4>
<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="App Secret">
<p class="hint"><a href="https://open.feishu.cn/app" target="_blank" rel="noopener">→ open.feishu.cn</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'wecom')">
<h4>🏢 WeCom</h4>
<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="Secret">
<p class="hint"><a href="https://work.weixin.qq.com" target="_blank" rel="noopener">→ work.weixin.qq.com</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" style="grid-column: 1 / -1; border-color: #07c160;" onclick="startWeChatLogin(event)">
<h4>💬 WeChat</h4>
<p data-i18n="ch.wechat_d">Scan a QR code to link a personal WeChat account</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;" data-i18n="ch.wechat_start">Click this card to show the QR code</p>
</div>
<div id="wechat-status" style="text-align:center;color:#888;font-size:0.85em;"></div>
</div>
</div>
</div>
</details>
</div>
<div style="margin-top: 28px; text-align: center;">
<button class="btn btn-primary" onclick="saveChannelsAndOpen()" style="font-size: 1.15em; padding: 16px 48px;" data-i18n="done.open">🚀 Start chatting</button>
<p style="color: #888; font-size: 0.8em; margin-top: 10px;" data-i18n="done.restart_note">Chat app settings are saved now and take effect after U-Claw restarts.</p>
</div>
<div class="tier-switch">
<label for="tierChoice" data-i18n="tier.label">How much do you want to see?</label>
<select id="tierChoice" onchange="setTier(this.value)">
<option value="simple" data-i18n="tier.simple">Just the essentials</option>
<option value="standard" data-i18n="tier.standard">A bit more control</option>
<option value="expert" data-i18n="tier.expert">Everything</option>
</select>
</div>
<div class="btn-row" style="justify-content: center; margin-top: 16px;">
<button class="btn btn-secondary" onclick="goStep(1)" data-i18n="done.change_key">Change model or key</button>
<button class="btn btn-secondary" onclick="viewConfig()" data-tier-min="expert" data-i18n="done.view_config">View config file</button>
</div>
</div>
</div>
<!-- Skills & info (always visible) -->
<div class="section active skills-section" style="display:block; margin-top: 8px;" data-tier-min="standard">
<h2>📦 <span data-i18n="info.layout_title">What is on the drive</span></h2>
<p class="desc" data-i18n="info.layout_desc">How the folders are organised</p>
<div class="skill-list">
<div class="skill-item">
<div class="info">
<h4>skills/ — <span data-i18n="info.skills">Skills</span></h4>
<p data-i18n="info.skills_d">Drop a SKILL.md in here and it is picked up automatically</p>
</div>
<span class="status">📁</span>
</div>
<div class="skill-item">
<div class="info">
<h4>app/ — <span data-i18n="info.app">Runtime</span></h4>
<p data-i18n="info.app_d">Node.js and the OpenClaw core</p>
</div>
<span class="status">⚙️</span>
</div>
<div class="skill-item">
<div class="info">
<h4>data/ — <span data-i18n="info.data">Your data</span></h4>
<p data-i18n="info.data_d">Your key and chat history. Stays on this drive.</p>
</div>
<span class="status">🔒</span>
</div>
</div>
</div>
<div class="section active skills-section" style="display:block; margin-top: 8px;">
<h2>🐛 <span data-i18n="info.feedback_title">Found a problem?</span></h2>
<p class="desc" data-i18n="info.feedback_desc">U-Claw is open source. Report anything that breaks on GitHub — nothing is collected or uploaded from your machine automatically.</p>
<div class="btn-row">
<a class="btn btn-primary" href="https://gitea.fanghe.it.com/zhenghy/u-claw/issues/new" target="_blank" rel="noopener" data-i18n="info.feedback_link">→ Open an issue on GitHub</a>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// --- State ---
// Filled in by the key check, not by the user. The old flow made people choose a
// provider from twelve cards and then type a Base URL and a model name; every
// key already announces its provider through its prefix, so we derive all three.
let detected = null; // { id, label, baseUrl, model } once a key checks out
const t = (key, vars) => (window.UClawI18n ? window.UClawI18n.t(key, vars) : key);
// --- 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 => {
const step = Number(s.dataset.step);
s.classList.remove('active', 'done');
if (step < n) s.classList.add('done');
if (step === n) s.classList.add('active');
});
}
// --- Key check ---
const keyInput = document.getElementById('apiKey');
const feedback = document.getElementById('keyFeedback');
const continueBtn = document.getElementById('continueBtn');
let checkTimer = null;
let checkToken = 0;
function setFeedback(state, text) {
feedback.className = 'hint ' + state;
feedback.style.color = state === 'ok' ? '#4caf50' : state === 'bad' ? '#ff6b6b' : '#888';
feedback.textContent = text;
}
async function checkKey() {
const apiKey = keyInput.value.trim();
detected = null;
continueBtn.disabled = true;
if (!apiKey) { setFeedback('', ''); return; }
const mine = ++checkToken;
setFeedback('', t('key.checking_key'));
let result;
try {
const res = await fetch('/api/test-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiKey,
baseUrl: document.getElementById('customBase').value.trim(),
model: document.getElementById('customModel').value.trim(),
}),
});
result = await res.json();
} catch (err) {
result = { ok: false, reason: 'key.err_offline' };
}
// A slower earlier check must not overwrite a newer one.
if (mine !== checkToken) return;
if (result.ok) {
detected = { id: result.provider ? result.provider.id : 'custom', baseUrl: result.baseUrl, model: result.model };
const label = result.provider ? result.provider.label : t('key.custom_provider');
setFeedback('ok', t('key.ok', { provider: label, ms: result.latencyMs }));
populateModels(result.provider, result.model);
continueBtn.disabled = false;
return;
}
// Every failure names something to do next, never just a status code.
setFeedback('bad', t(result.reason || 'key.err_unknown'));
if (result.reason === 'key.err_unrecognised') {
document.getElementById('advanced').open = true;
}
}
const modelPicker = document.getElementById('modelPicker');
const modelChoice = document.getElementById('modelChoice');
function populateModels(provider, current) {
var options = (provider && provider.models) ? provider.models.slice() : [];
if (current && options.indexOf(current) === -1) options.unshift(current);
if (options.length < 2) { modelPicker.style.display = 'none'; return; }
modelChoice.innerHTML = '';
options.forEach(function (name) {
var opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (name === current) opt.selected = true;
modelChoice.appendChild(opt);
});
modelPicker.style.display = 'block';
}
// Check as they paste, but wait for typing to settle first.
function scheduleCheck() {
clearTimeout(checkTimer);
checkTimer = setTimeout(checkKey, 800);
}
keyInput.addEventListener('input', scheduleCheck);
keyInput.addEventListener('paste', () => setTimeout(checkKey, 0));
document.getElementById('customBase').addEventListener('input', scheduleCheck);
document.getElementById('customModel').addEventListener('input', scheduleCheck);
// --- Toggle password ---
function togglePw(id) {
var inp = document.getElementById(id);
var btn = inp.parentElement.querySelector('.toggle-pw');
var showing = inp.type !== 'password';
inp.type = showing ? 'password' : 'text';
btn.textContent = t(showing ? 'key.show' : 'key.hide');
}
// --- Build OpenClaw config ---
function buildConfig(provider, base, model, apiKey) {
var baseConfig = {
gateway: { mode: 'local', auth: { mode: 'token', token: 'uclaw' }, remote: { 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();
// The button is only enabled once a key has actually answered, so reaching
// here without `detected` means the check has not finished yet.
if (!apiKey || !detected) { showToast(t('key.err_not_checked'), true); return; }
// The picker wins when shown, so returning here later is a real way to
// switch model without hunting for the config file.
var model = (modelPicker.style.display !== 'none' && modelChoice.value) ? modelChoice.value : detected.model;
var config = buildConfig(detected.id, detected.baseUrl, 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(String(res.status));
showToast(t('key.saved'));
goStep(3);
} catch (e) {
showToast(t('key.err_save'), 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;">' + t('wx.getting_qr') + '</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 = t('wx.scan');
statusDiv.style.color = '#07c160';
// Start polling
wechatPolling = true;
pollWeChatStatus();
} catch (err) {
qrDiv.innerHTML = '<p style="color:#f44336;">' + t('wx.qr_failed') + '</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>';
// 后端已自动装好插件 + 写好 openclaw.json + 存好账号,剩下只需重启 Gateway 加载插件。
// 不再暴露 pluginInstalled 内部状态——它即使为 false配置也已写好下次启动必加载
// 显示「插件未安装」只会和「连接成功」自相矛盾、吓到客户。
statusDiv.innerHTML = '<span style="color:#4caf50;font-weight:600;">' + t('wx.connected') + '</span>' +
'<br><span style="font-size:0.8em;color:#888;">' + t('wx.account') + ' ' + (data.accountId || '') + '</span>' +
'<br><span style="font-size:0.85em;color:#ff9800;font-weight:600;">' + t('wx.restart_needed') + '</span>';
statusDiv.style.color = '#4caf50';
showToast(t('wx.connected_toast'));
return;
}
if (data.status === 'refreshed') {
qrDiv.innerHTML = '<img src="' + data.qrcodeUrl + '" style="width:200px;height:200px;image-rendering:pixelated;border-radius:8px;">';
statusDiv.textContent = t('wx.qr_refreshed');
statusDiv.style.color = '#ff6b35';
} else if (data.status === 'scaned') {
statusDiv.textContent = t('wx.confirm_on_phone');
statusDiv.style.color = '#07c160';
} else if (data.status === 'expired') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#888;">' + t('wx.qr_expired') + ' <a href="#" onclick="startWeChatLogin(event);return false;" style="color:#ff6b35;">' + t('wx.qr_retry') + '</a></p>';
statusDiv.textContent = data.message || '';
statusDiv.style.color = '#f44336';
return;
} else if (data.status === 'error') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#f44336;">' + t('wx.failed') + '</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 val = function (id) {
var el = document.getElementById(id);
return el ? el.value.trim() : '';
};
// OpenClaw's top-level telegram channel only reads botToken; the older
// `token` alias works solely under accounts.<id>. Writing `token` here made
// Telegram silently fail to connect.
var tgToken = val('ch-telegram-token');
if (tgToken) channels.telegram = { enabled: true, botToken: tgToken, dmPolicy: 'pairing' };
var waNumber = val('ch-whatsapp-number');
if (waNumber) channels.whatsapp = { enabled: true, number: waNumber, dmPolicy: 'pairing' };
var slackToken = val('ch-slack-token');
if (slackToken) channels.slack = { enabled: true, botToken: slackToken };
var discordToken = val('ch-discord-token');
if (discordToken) channels.discord = { enabled: true, botToken: discordToken };
var qqId = val('ch-qqbot-appid'), qqSecret = val('ch-qqbot-secret');
if (qqId && qqSecret) channels.qqbot = { enabled: true, appId: qqId, clientSecret: qqSecret };
var fsId = val('ch-feishu-appid'), fsSecret = val('ch-feishu-secret');
if (fsId && fsSecret) channels.feishu = { enabled: true, appId: fsId, appSecret: fsSecret };
var wcBotId = val('ch-wecom-botid'), wcSecret = val('ch-wecom-secret');
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() : {};
// Drop the retired key or OpenClaw errors with "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(t('ch.saved'));
} catch (e) {
showToast(t('ch.err_save'), true);
}
}
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(t('info.read_config_failed') + ' ' + e.message, true);
}
}
// --- 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 = t('status.running', { port: found }) + ' (' + port + ') · <a href="' + gatewayUrl() + '" target="_blank">' + t('status.open_dashboard') + '</a>';
document.getElementById('gatewayUrl').textContent = 'http://127.0.0.1:' + port;
} else {
dot.className = 'dot off';
text.innerHTML = t('status.not_running');
}
}
// --- Load existing config ---
// --- Interface tier ---
const TIERS = ['simple', 'standard', 'expert'];
function applyTier(tier) {
var value = TIERS.indexOf(tier) === -1 ? 'simple' : tier;
document.body.dataset.tier = value;
var picker = document.getElementById('tierChoice');
if (picker) picker.value = value;
}
async function setTier(tier) {
applyTier(tier);
try {
var cfg = await (await fetch('/api/config')).json();
cfg.uclaw = Object.assign({}, cfg.uclaw, { tier: tier });
await fetch('/api/config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg)
});
// Say what changed rather than silently revealing new controls.
showToast(t('tier.changed_' + tier));
} catch (e) { showToast(t('key.err_save'), true); }
}
async function loadConfig() {
try {
var cfg = await (await fetch('/api/config')).json();
applyTier((cfg.uclaw && cfg.uclaw.tier) || 'simple');
var key = (cfg.env && cfg.env.ZAI_API_KEY) || null;
var model = null;
if (!key && cfg.models && cfg.models.providers) {
var name = Object.keys(cfg.models.providers)[0];
if (name) {
key = cfg.models.providers[name].apiKey || null;
var primary = cfg.agents && cfg.agents.defaults && cfg.agents.defaults.model
? cfg.agents.defaults.model.primary : null;
if (primary) model = String(primary).split('/').slice(1).join('/') || null;
}
}
if (!key) return;
// Prefill and re-check, so the page shows what is actually configured and
// the model picker comes back populated. Previously this looked for model
// cards that no longer exist and silently did nothing.
keyInput.value = key;
if (model) document.getElementById('customModel').value = model;
await checkKey();
if (model) {
for (var i = 0; i < modelChoice.options.length; i++) {
if (modelChoice.options[i].value === model) { modelChoice.selectedIndex = i; break; }
}
}
} catch (e) { /* first run: nothing configured yet */ }
}
loadConfig();
checkGateway();
setInterval(checkGateway, 10000);
</script>
</body>
</html>