All checks were successful
Tests / test (push) Successful in 1m8s
Sync install scripts and CI container image with NODE_VERSION, regenerate Electron Config.html, and skip gitignored portable/app in origin scan. Co-authored-by: Cursor <cursoragent@cursor.com>
1049 lines
48 KiB
HTML
1049 lines
48 KiB
HTML
<!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;">✔</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, '<') + '</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>
|