Files
u-claw/portable/config-server/public/index.html
zheng dd8e0f55c3 fix(portable): Gemini keys, Telegram pairing UI, and config durability
Improve first-run and channel setup for non-technical users: detect newer
Gemini key formats, pin Node 22.22.3, add Control Panel Telegram approve
flow, and keep channels/models when config is rewritten. Persist uclaw
wizard state via uclaw-meta.json so restarts skip language/persona prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:45:03 +08:00

1049 lines
48 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"],
.form-group select,
select {
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;
}
.form-group select,
select {
padding-right: 36px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
}
.form-group select:focus,
select:focus { border-color: #ff6b35; }
.form-group select option,
select option { background: #222; color: #fff; }
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; }
/* Key verified: model picker + continue on one row */
.key-actions {
display: flex;
align-items: flex-end;
gap: 12px;
margin-top: 16px;
margin-bottom: 4px;
}
.key-actions .model-picker {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.key-actions .model-picker label {
margin-bottom: 6px;
font-size: 0.85em;
color: #aaa;
}
.key-actions .btn-primary {
flex-shrink: 0;
min-width: 128px;
min-height: 46px;
padding: 12px 22px;
margin-left: auto;
}
.key-actions.has-model .btn-primary { margin-left: 0; }
@media (max-width: 520px) {
.key-actions { flex-direction: column; align-items: stretch; }
.key-actions .btn-primary { margin-left: 0; width: 100%; }
}
/* 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 — open state must scroll (Telegram pairing steps are tall) */
.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.35s ease, padding 0.35s ease; }
.channel-form.open {
max-height: min(72vh, 560px);
overflow-x: hidden;
overflow-y: auto;
padding-top: 12px;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
}
.channel-form.open::-webkit-scrollbar { width: 8px; }
.channel-form.open::-webkit-scrollbar-thumb { background: #444; border-radius: 4px; }
.channel-form.open::-webkit-scrollbar-thumb:hover { background: #555; }
/* Telegram needs the full row when expanded */
.channel-card.channel-expanded { grid-column: 1 / -1; }
.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; }
.tg-steps code { background: #2a2a2a; padding: 1px 5px; border-radius: 4px; color: #ffb84d; font-size: 0.95em; }
/* 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="key-actions" id="keyActions">
<div class="form-group model-picker" id="modelPicker" style="display:none;" data-tier-min="standard">
<label data-i18n="key.which_model">Which model?</label>
<select id="modelChoice"></select>
</div>
<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 class="telegram-pairing-panel" id="telegram-pairing-panel" style="margin-top:12px;padding-top:12px;border-top:1px solid #333;">
<p class="hint" style="color:#ccc;margin-bottom:8px;" data-i18n="tg.steps_title">After saving, connect your Telegram account:</p>
<ol class="hint tg-steps" style="color:#aaa;font-size:0.82em;margin:0 0 12px 18px;line-height:1.55;">
<li data-i18n="tg.step1">Save the bot token above (click “Open dashboard” or save channels).</li>
<li data-i18n="tg.step2">On your phone, open Telegram, find your bot, and send <code>/start</code>.</li>
<li data-i18n="tg.step3">Come back here — a button will appear. Click it once. No command line.</li>
</ol>
<div id="telegram-pairing-status" class="hint" style="margin-bottom:10px;color:#888;"></div>
<button type="button" class="btn btn-primary" id="telegram-approve-btn" style="display:none;width:100%;margin-bottom:4px;" onclick="approveTelegramPairing()" data-i18n="tg.approve">Allow this Telegram account</button>
</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');
const keyActions = document.getElementById('keyActions');
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';
keyActions.classList.remove('has-model');
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';
keyActions.classList.add('has-model');
}
// 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 existingRes = await fetch('/api/config');
var existing = existingRes.ok ? await existingRes.json() : {};
delete existing.agent;
config = Object.assign(existing, config);
if (existing.channels) config.channels = existing.channels;
if (existing.commands) {
config.commands = Object.assign({}, existing.commands, config.commands || {});
}
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');
card.classList.remove('channel-expanded');
if (type === 'telegram') stopTelegramPairingPoll();
} else {
form.classList.add('open');
card.classList.add('selected');
if (type === 'telegram') card.classList.add('channel-expanded');
var firstInput = form.querySelector('input');
if (firstInput) setTimeout(function() { firstInput.focus(); }, 100);
if (type === 'telegram') startTelegramPairingPoll();
}
}
// --- Telegram pairing (Control Panel, no CLI) ---
var telegramPairingTimer = null;
var telegramPendingCode = null;
function stopTelegramPairingPoll() {
if (telegramPairingTimer) {
clearTimeout(telegramPairingTimer);
telegramPairingTimer = null;
}
}
function formatTelegramRequestLabel(req) {
if (req.username) return '@' + req.username;
if (req.firstName) return req.firstName;
return req.id || '';
}
async function pollTelegramPairing() {
var statusEl = document.getElementById('telegram-pairing-status');
var btn = document.getElementById('telegram-approve-btn');
if (!statusEl || !btn) return;
try {
var res = await fetch('/api/telegram/pairing');
var data = res.ok ? await res.json() : { requests: [] };
var requests = data.requests || [];
if (requests.length > 0) {
var req = requests[0];
telegramPendingCode = req.code;
var who = formatTelegramRequestLabel(req);
statusEl.innerHTML = '<span style="color:#ffb84d;">' + t('tg.pending', { who: who || req.id }) + '</span>';
btn.style.display = 'block';
btn.disabled = false;
} else {
telegramPendingCode = null;
btn.style.display = 'none';
btn.disabled = true;
statusEl.textContent = t('tg.waiting');
}
} catch (e) {
statusEl.textContent = t('tg.poll_failed');
}
telegramPairingTimer = setTimeout(pollTelegramPairing, 2000);
}
function startTelegramPairingPoll() {
stopTelegramPairingPoll();
pollTelegramPairing();
}
async function approveTelegramPairing() {
if (!telegramPendingCode) return;
var btn = document.getElementById('telegram-approve-btn');
var statusEl = document.getElementById('telegram-pairing-status');
btn.disabled = true;
statusEl.textContent = t('tg.approving');
try {
var res = await fetch('/api/telegram/pairing/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: telegramPendingCode })
});
var data = await res.json();
if (!res.ok) throw new Error(data.error || String(res.status));
telegramPendingCode = null;
btn.style.display = 'none';
statusEl.innerHTML = '<span style="color:#4caf50;font-weight:600;">' + t('tg.approved') + '</span>';
showToast(t('tg.approved_toast'));
} catch (e) {
statusEl.textContent = t('tg.approve_failed', { reason: e.message });
btn.disabled = false;
showToast(t('tg.approve_failed', { reason: e.message }), true);
}
}
// --- 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'));
if (channels.telegram) startTelegramPairingPoll();
} 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; }
}
}
if (cfg.channels && cfg.channels.telegram && cfg.channels.telegram.botToken) {
var tgInput = document.getElementById('ch-telegram-token');
if (tgInput) tgInput.value = cfg.channels.telegram.botToken;
}
} catch (e) { /* first run: nothing configured yet */ }
}
loadConfig();
checkGateway();
setInterval(checkGateway, 10000);
</script>
</body>
</html>