feat: 海外化改造(新加坡市场)—— 阶段 0-3
Some checks failed
Tests / test (push) Has been cancelled

按 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 拆到私有仓库,无权限
This commit is contained in:
2026-08-17 18:37:49 +08:00
parent 9a23aec097
commit b076815171
123 changed files with 13396 additions and 14723 deletions

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>U-Claw 配置中心</title>
<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; }
@@ -155,262 +155,250 @@ input:focus { border-color: #ff6b35; }
.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> U-Claw 配置中心</h1>
<p class="subtitle">选择模型,填入 API Key,一键启动</p>
<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">检测中...</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>选模型</div>
<div class="step" data-step="2" onclick="goStep(2)"><span class="num">2</span>填 Key</div>
<div class="step" data-step="3" onclick="goStep(3)"><span class="num">3</span>启动</div>
<div 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: Select Model -->
<!-- Step 1: one field -->
<div class="section active" id="step1">
<h2>选择 AI 模型</h2>
<p class="desc">最省心:用「虾盘云」一个 Key 调用国内外全部大模型(中转站)。也可用下面各家自己的官方 Key。</p>
<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="model-grid">
<div class="model-card" data-provider="uclaw-cloud" data-base="https://api.u-claw.org/v1" data-model="deepseek-v4-flash">
<span class="check"></span>
<h4>虾盘云 <span class="tag hot">首选</span><span class="tag">中转站</span></h4>
<p>一个 Key 用 DeepSeek / Claude / GPT / 通义 等国内外全部大模型,无需翻墙</p>
<a class="buy-link" href="https://u-claw.org/cloud.html" target="_blank" data-action-label="注册 / 充值 / 获取 API Key">→ 注册 / 充值 / 获取 API Key</a>
</div>
<div class="model-card" data-provider="minimax" data-base="https://api.minimax.chat/v1" data-model="MiniMax-Text-01">
<span class="check"></span>
<h4>MiniMax <span class="tag hot">推荐</span><span class="tag cn">国内</span></h4>
<p>MiniMax-Text-01速度快性价比高</p>
<a class="buy-link" href="https://platform.minimaxi.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="kimi" data-base="https://api.moonshot.cn/v1" data-model="moonshot-v1-auto">
<span class="check"></span>
<h4>Kimi (月之暗面) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>moonshot-v1-auto智能选模型</p>
<a class="buy-link" href="https://platform.moonshot.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="deepseek" data-base="https://api.deepseek.com/v1" data-model="deepseek-v4-flash">
<span class="check"></span>
<h4>DeepSeek <span class="tag cn">国内</span><span class="tag cheap">便宜</span></h4>
<p>deepseek-v4-flash超低价格</p>
<a class="buy-link" href="https://platform.deepseek.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="zai" data-base="" data-model="glm-5">
<span class="check"></span>
<h4>智谱 GLM <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
<p>glm-5zai provider免费额度可用</p>
<a class="buy-link" href="https://open.bigmodel.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="qwen" data-base="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-turbo">
<span class="check"></span>
<h4>通义千问 <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
<p>qwen-turbo阿里云出品</p>
<a class="buy-link" href="https://dashscope.console.aliyun.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="doubao" data-base="https://ark.cn-beijing.volces.com/api/v3" data-model="doubao-1.5-pro-32k">
<span class="check"></span>
<h4>豆包 (字节) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>doubao-1.5-pro字节跳动</p>
<a class="buy-link" href="https://console.volcengine.com/ark" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="openai" data-base="https://api.openai.com/v1" data-model="gpt-4o">
<span class="check"></span>
<h4>OpenAI <span class="tag hot"></span></h4>
<p>GPT-4o需翻墙或中转</p>
<a class="buy-link" href="https://platform.openai.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="anthropic" data-base="https://api.anthropic.com/v1" data-model="claude-sonnet-4-20250514">
<span class="check"></span>
<h4>Claude <span class="tag hot"></span></h4>
<p>Claude Sonnet 4需翻墙或中转</p>
<a class="buy-link" href="https://console.anthropic.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="groq" data-base="https://api.groq.com/openai/v1" data-model="llama-3.3-70b-versatile">
<span class="check"></span>
<h4>Groq <span class="tag fast">极快</span><span class="tag free">有免费</span></h4>
<p>Llama 3.3 70B超高速推理</p>
<a class="buy-link" href="https://console.groq.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="siliconflow" data-base="https://api.siliconflow.cn/v1" data-model="Qwen/Qwen2.5-72B-Instruct">
<span class="check"></span>
<h4>硅基流动 <span class="tag cn">国内</span><span class="tag cheap">便宜</span></h4>
<p>多模型聚合,价格低</p>
<a class="buy-link" href="https://cloud.siliconflow.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="custom" data-base="" data-model="" style="grid-column: 1 / -1; border-style: dashed;">
<span class="check"></span>
<h4>🔧 自定义 API <span class="tag">OpenAI 兼容</span></h4>
<p>填写任意 OpenAI 兼容 API 地址OpenRouter、API2D、New API、One API 等)</p>
<div 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="nextStep1" disabled>下一步</button>
</div>
</div>
<!-- Step 2: API Key -->
<div class="section" id="step2">
<h2>填写 API Key</h2>
<p class="desc" id="step2desc">请填写对应模型的 API Key</p>
<div class="api-info" id="buyInfo">
<h4>📌 获取 / 充值 API Key</h4>
<div class="provider-links" id="providerLinks"></div>
<button class="btn btn-primary" id="continueBtn" onclick="saveConfig()" disabled data-i18n="key.continue">Continue</button>
</div>
<div id="customFields" style="display:none;">
<div class="form-group">
<label>API 地址 (Base URL)</label>
<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>模型名称</label>
<label data-i18n="key.model_name">Model name</label>
<input type="text" id="customModel" placeholder="gpt-4o">
</div>
</div>
<div class="form-group">
<label>API Key</label>
<div class="input-wrap">
<input type="password" id="apiKey" placeholder="sk-...">
<button class="toggle-pw" onclick="togglePw('apiKey')">显示</button>
</div>
<p class="hint">你的 Key 仅保存在本地 U 盘,不会上传到任何服务器</p>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(1)">← 返回</button>
<button class="btn btn-primary" onclick="saveConfig()">保存并继续 →</button>
</div>
<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>配置完成!</h2>
<p>模型和 API Key 已保存到本地</p>
<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;">📱 接入聊天平台(可选)</h3>
<p style="color: #888; font-size: 0.82em; margin-bottom: 12px;">点击卡片展开填写,不填可直接跳过:</p>
<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>接入 Telegram Bot</p>
<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">→ 从 @BotFather 获取</a></p>
<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, 'qqbot')">
<h4>🐧 QQ Bot</h4>
<p>接入 QQ 群/私聊机器人</p>
<div class="channel-form" id="form-qqbot" onclick="event.stopPropagation()">
<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>App ID</label>
<input type="text" id="ch-qqbot-appid" placeholder="应用 ID">
</div>
<div class="form-group">
<label>Client Secret</label>
<input type="text" id="ch-qqbot-secret" placeholder="应用密钥">
<p class="hint"><a href="https://q.qq.com" target="_blank">→ 从 QQ 开放平台获取</a></p>
<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, 'feishu')">
<h4>📘 飞书</h4>
<p>接入飞书机器人</p>
<div class="channel-form" id="form-feishu" onclick="event.stopPropagation()">
<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>App ID</label>
<input type="text" id="ch-feishu-appid" placeholder="cli_xxx...">
<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 class="form-group">
<label>App Secret</label>
<input type="text" id="ch-feishu-secret" placeholder="应用密钥">
<p class="hint"><a href="https://open.feishu.cn/app" target="_blank">→ 从飞书开放平台获取</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" onclick="toggleChannel(this, 'wecom')">
<h4>🏢 企业微信</h4>
<p>接入企业微信机器人</p>
<div class="channel-form" id="form-wecom" onclick="event.stopPropagation()">
<div class="form-group">
<label>Bot ID</label>
<input type="text" id="ch-wecom-botid" placeholder="aib...">
</div>
<div class="form-group">
<label>Secret</label>
<input type="text" id="ch-wecom-secret" placeholder="密钥">
<p class="hint"><a href="https://work.weixin.qq.com" target="_blank">→ 从企业微信管理后台获取</a></p>
</div>
</div>
</div>
<div class="model-card channel-card" style="grid-column: 1 / -1; border-color: #07c160;" onclick="startWeChatLogin(event)">
<h4>💬 微信 <span class="tag" style="background:rgba(7,193,96,0.2);color:#07c160;">个人微信</span></h4>
<p>扫码接入个人微信,用微信聊天控制 AI</p>
<div class="channel-form" id="form-wechat" onclick="event.stopPropagation()" style="max-height:none;overflow:visible;">
<div id="wechat-qr" style="text-align:center;padding:16px;">
<p style="color:#888;font-size:0.85em;">点击此卡片开始扫码</p>
</div>
<div id="wechat-status" style="text-align:center;color:#888;font-size:0.85em;"></div>
</div>
</div>
</div>
<!-- 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;">🚀 打开 Dashboard</button>
<p style="color: #888; font-size: 0.8em; margin-top: 10px;">渠道配置会自动写入Gateway 需重启生效</p>
<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)">重新配置模型</button>
<button class="btn btn-secondary" onclick="viewConfig()">查看配置文件</button>
<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;">
<h2>📦 目录说明</h2>
<p class="desc">U 盘内的文件结构</p>
<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-cn/ — 中文技能</h4>
<p>放入 .md 技能文件OpenClaw 自动加载</p>
<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/ — 运行时 + 核心</h4>
<p>Node.js 运行时、OpenClaw 核心代码</p>
<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/ — 配置和数据</h4>
<p>API Key、聊天历史等保存在此仅本地</p>
<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>
@@ -418,10 +406,10 @@ input:focus { border-color: #ff6b35; }
</div>
<div class="section active skills-section" style="display:block; margin-top: 8px;">
<h2>🐛 反馈 / 报 Bug</h2>
<p class="desc">U-Claw 是开源项目。遇到问题欢迎到 GitHub 提 Issue —— 我们不会自动收集或上传你的任何数据。</p>
<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://github.com/dongsheng123132/u-claw/issues/new" target="_blank">→ 在 GitHub 提交 Issue</a>
<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>
@@ -430,70 +418,116 @@ input:focus { border-color: #ff6b35; }
<script>
// --- State ---
let selectedProvider = null;
let selectedBase = '';
let selectedModel = '';
// 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
// --- Model cards ---
document.querySelectorAll('#step1 .model-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.buy-link')) return;
document.querySelectorAll('#step1 .model-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedProvider = card.dataset.provider;
selectedBase = card.dataset.base;
selectedModel = card.dataset.model;
document.getElementById('nextStep1').disabled = false;
});
});
document.getElementById('nextStep1').addEventListener('click', () => goStep(2));
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, i) => {
document.querySelectorAll('.steps .step').forEach(s => {
const step = Number(s.dataset.step);
s.classList.remove('active', 'done');
if (i + 1 < n) s.classList.add('done');
if (i + 1 === n) s.classList.add('active');
if (step < n) s.classList.add('done');
if (step === n) s.classList.add('active');
});
if (n === 2) setupStep2();
}
const PROVIDER_NAMES = {
'uclaw-cloud': '虾盘云', minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
siliconflow: '硅基流动', custom: '自定义'
};
// --- Key check ---
const keyInput = document.getElementById('apiKey');
const feedback = document.getElementById('keyFeedback');
const continueBtn = document.getElementById('continueBtn');
let checkTimer = null;
let checkToken = 0;
function setupStep2() {
var isCustom = selectedProvider === 'custom';
document.getElementById('customFields').style.display = isCustom ? 'block' : 'none';
function setFeedback(state, text) {
feedback.className = 'hint ' + state;
feedback.style.color = state === 'ok' ? '#4caf50' : state === 'bad' ? '#ff6b6b' : '#888';
feedback.textContent = text;
}
var name = PROVIDER_NAMES[selectedProvider] || selectedProvider;
document.getElementById('step2desc').textContent = '请填写 ' + name + ' 的 API Key';
async function checkKey() {
const apiKey = keyInput.value.trim();
detected = null;
continueBtn.disabled = true;
if (!apiKey) { setFeedback('', ''); return; }
var card = document.querySelector('#step1 .model-card[data-provider="' + selectedProvider + '"]');
var buyLink = card ? card.querySelector('.buy-link') : null;
var linksDiv = document.getElementById('providerLinks');
linksDiv.innerHTML = '';
if (buyLink) {
var action = buyLink.dataset.actionLabel || '获取 API Key';
linksDiv.innerHTML = '<a href="' + buyLink.href + '" target="_blank">🔑 ' + name + ' — ' + action + '</a>';
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;
}
linksDiv.innerHTML += '<a href="https://platform.minimaxi.com/" target="_blank">🔑 MiniMax — 国内推荐,注册送额度</a>' +
'<a href="https://open.bigmodel.cn/" target="_blank">🔑 智谱 GLM — 有免费额度</a>' +
'<a href="https://cloud.siliconflow.cn/" target="_blank">🔑 硅基流动 — 多模型聚合,便宜</a>';
}
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');
if (inp.type === 'password') { inp.type = 'text'; btn.textContent = '隐藏'; }
else { inp.type = 'password'; btn.textContent = '显示'; }
var showing = inp.type !== 'password';
inp.type = showing ? 'password' : 'text';
btn.textContent = t(showing ? 'key.show' : 'key.hide');
}
// --- Build OpenClaw config ---
@@ -537,18 +571,14 @@ function buildConfig(provider, base, model, apiKey) {
// --- Save model config ---
async function saveConfig() {
var apiKey = document.getElementById('apiKey').value.trim();
if (!apiKey) { showToast('请填写 API Key', true); return; }
// 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; }
var base = selectedBase;
var model = selectedModel;
if (selectedProvider === 'custom') {
base = document.getElementById('customBase').value.trim();
model = document.getElementById('customModel').value.trim();
if (!base || !model) { showToast('请填写 API 地址和模型名称', true); return; }
}
var config = buildConfig(selectedProvider, base, model, apiKey);
// 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', {
@@ -556,11 +586,11 @@ async function saveConfig() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) throw new Error('保存失败');
showToast('配置已保存!');
if (!res.ok) throw new Error(String(res.status));
showToast(t('key.saved'));
goStep(3);
} catch (e) {
showToast('保存失败: ' + e.message, true);
showToast(t('key.err_save'), true);
}
}
@@ -599,7 +629,7 @@ async function startWeChatLogin(e) {
var qrDiv = document.getElementById('wechat-qr');
var statusDiv = document.getElementById('wechat-status');
qrDiv.innerHTML = '<p style="color:#888;">正在获取二维码...</p>';
qrDiv.innerHTML = '<p style="color:#888;">' + t('wx.getting_qr') + '</p>';
statusDiv.textContent = '';
try {
@@ -609,14 +639,14 @@ async function startWeChatLogin(e) {
wechatSession = data.sessionKey;
qrDiv.innerHTML = '<img src="' + data.qrcodeUrl + '" style="width:200px;height:200px;image-rendering:pixelated;border-radius:8px;">';
statusDiv.textContent = '请用微信扫描上方二维码';
statusDiv.textContent = t('wx.scan');
statusDiv.style.color = '#07c160';
// Start polling
wechatPolling = true;
pollWeChatStatus();
} catch (err) {
qrDiv.innerHTML = '<p style="color:#f44336;">获取二维码失败</p>';
qrDiv.innerHTML = '<p style="color:#f44336;">' + t('wx.qr_failed') + '</p>';
statusDiv.textContent = err.message;
statusDiv.style.color = '#f44336';
}
@@ -638,30 +668,30 @@ async function pollWeChatStatus() {
// 后端已自动装好插件 + 写好 openclaw.json + 存好账号,剩下只需重启 Gateway 加载插件。
// 不再暴露 pluginInstalled 内部状态——它即使为 false配置也已写好下次启动必加载
// 显示「插件未安装」只会和「连接成功」自相矛盾、吓到客户。
statusDiv.innerHTML = '<span style="color:#4caf50;font-weight:600;">✅ 微信连接成功!</span>' +
'<br><span style="font-size:0.8em;color:#888;">账号 ' + (data.accountId || '') + ' 已绑定</span>' +
'<br><span style="font-size:0.85em;color:#ff9800;font-weight:600;">最后一步:重启 U-Claw关闭后重新打开即可在微信里使用</span>';
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('微信连接成功!重启 U-Claw 后生效');
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 = '二维码已刷新,请重新扫码';
statusDiv.textContent = t('wx.qr_refreshed');
statusDiv.style.color = '#ff6b35';
} else if (data.status === 'scaned') {
statusDiv.textContent = '已扫码,请在手机上确认...';
statusDiv.textContent = t('wx.confirm_on_phone');
statusDiv.style.color = '#07c160';
} else if (data.status === 'expired') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#888;">二维码已过期,<a href="#" onclick="startWeChatLogin(event);return false;" style="color:#ff6b35;">重新获取</a></p>';
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;">连接失败</p>';
qrDiv.innerHTML = '<p style="color:#f44336;">' + t('wx.failed') + '</p>';
statusDiv.textContent = data.message || 'Unknown error';
statusDiv.style.color = '#f44336';
return;
@@ -676,29 +706,40 @@ async function pollWeChatStatus() {
// --- Save channels and open dashboard ---
async function saveChannelsAndOpen() {
var channels = {};
var val = function (id) {
var el = document.getElementById(id);
return el ? el.value.trim() : '';
};
var tgToken = document.getElementById('ch-telegram-token').value.trim();
// OpenClaw 顶层 telegram 渠道只认 botToken旧 token 别名仅在 accounts.<id> 里有效)。
// 之前写成 token 会被静默忽略 → Telegram 接不上。
// 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 qqId = document.getElementById('ch-qqbot-appid').value.trim();
var qqSecret = document.getElementById('ch-qqbot-secret').value.trim();
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 = document.getElementById('ch-feishu-appid').value.trim();
var fsSecret = document.getElementById('ch-feishu-secret').value.trim();
var fsId = val('ch-feishu-appid'), fsSecret = val('ch-feishu-secret');
if (fsId && fsSecret) channels.feishu = { enabled: true, appId: fsId, appSecret: fsSecret };
var wcBotId = document.getElementById('ch-wecom-botid').value.trim();
var wcSecret = document.getElementById('ch-wecom-secret').value.trim();
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() : {};
// 清除旧版废弃键,防止 OpenClaw 报 "agent.* was moved" 错误
// 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', {
@@ -706,8 +747,10 @@ async function saveChannelsAndOpen() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existing)
});
showToast('渠道配置已保存');
} catch(e) {}
showToast(t('ch.saved'));
} catch (e) {
showToast(t('ch.err_save'), true);
}
}
window.open(gatewayUrl(), '_blank');
@@ -723,7 +766,7 @@ async function viewConfig() {
w.document.write('<pre style="background:#111;color:#eee;padding:20px;font-size:14px;">' +
text.replace(/</g, '&lt;') + '</pre>');
} catch (e) {
showToast('读取配置失败: ' + e.message, true);
showToast(t('info.read_config_failed') + ' ' + e.message, true);
}
}
@@ -761,37 +804,68 @@ async function checkGateway() {
var port = await findGatewayPort();
if (port) {
dot.className = 'dot on';
text.innerHTML = 'Gateway 运行中 (端口 ' + port + ') · <a href="' + gatewayUrl() + '" target="_blank">打开 Dashboard →</a>';
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 = 'Gateway 未启动 · 请先运行 Windows-Start.bat 或 Mac-Start.command';
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 res = await fetch('/api/config');
var cfg = await res.json();
var cfg = await (await fetch('/api/config')).json();
applyTier((cfg.uclaw && cfg.uclaw.tier) || 'simple');
if (cfg.env && cfg.env.ZAI_API_KEY) {
var card = document.querySelector('#step1 .model-card[data-provider="zai"]');
if (card) card.click();
document.getElementById('apiKey').value = cfg.env.ZAI_API_KEY;
return;
}
if (cfg.models && cfg.models.providers) {
var providerName = Object.keys(cfg.models.providers)[0];
if (providerName) {
var p = cfg.models.providers[providerName];
var card = document.querySelector('#step1 .model-card[data-provider="' + providerName + '"]');
if (card) card.click();
if (p.apiKey) document.getElementById('apiKey').value = p.apiKey;
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;
}
}
} catch(e) {}
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();

View File

@@ -404,6 +404,133 @@ const server = http.createServer((req, res) => {
return;
}
// API: what the first-run wizard offers, straight from the manifest.
if (req.url === '/api/personas' && req.method === 'GET') {
try {
const manifestPath = path.join(__dirname, '..', 'skills', 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const shipping = manifest.skills.filter((s) => s.status === 'shipping');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(manifest.personas.map((p) => ({
...p,
skills: shipping.filter((s) => (s.personas || []).includes(p.id)).map((s) => s.id),
}))));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: install the skills a persona needs. Runs the same installer the
// release build and the one-line installers use, so there is one code path.
if (req.url === '/api/install-skills' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => { body += chunk; if (body.length > 4096) req.destroy(); });
req.on('end', async () => {
const reply = (payload, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
let persona = 'general';
let locale = 'en';
try {
const parsed = JSON.parse(body || '{}');
if (parsed.persona) persona = String(parsed.persona);
if (parsed.locale) locale = String(parsed.locale);
} catch { /* fall back to the defaults */ }
const target = path.join(__dirname, '..', 'app', 'core', 'node_modules', 'openclaw', 'skills');
// On the drive the installer sits in lib/ next to us; in a source checkout
// it lives at the repo root. Same two-place lookup the setup scripts use.
const installer = [
path.join(__dirname, '..', 'lib', 'install-skills.mjs'),
path.join(__dirname, '..', '..', 'lib', 'install-skills.mjs'),
].find((candidate) => fs.existsSync(candidate));
if (!installer) return reply({ ok: false, reason: 'installer-missing' });
try {
fs.mkdirSync(target, { recursive: true });
const { execFile } = require('child_process');
await new Promise((resolve, reject) => {
execFile(process.execPath,
[installer, '--target', target, '--locale', locale, '--persona', persona, '--json'],
{ timeout: 30000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)));
});
reply({ ok: true, persona });
} catch (err) {
// Skills are a bonus, not a gate: the wizard continues either way.
reply({ ok: false, reason: err.message });
}
});
return;
}
// API: Identify an API key and check that it actually works.
//
// Runs server-side on purpose: the browser cannot call most model endpoints
// cross-origin, and doing it here means the key never leaves this machine by
// any path the page controls. Sends the smallest possible completion (1 token)
// and maps the outcome to a message key the page translates -- users get
// "that key was not accepted, here is what to do", not "401 Unauthorized".
if (req.url === '/api/test-key' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
// A key is short; anything large is not a key.
if (body.length > 8192) req.destroy();
});
req.on('end', async () => {
const reply = (payload, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
let parsed;
try { parsed = JSON.parse(body || '{}'); } catch { return reply({ ok: false, reason: 'key.err_unknown' }, 400); }
const apiKey = String(parsed.apiKey || '').trim();
if (!apiKey) return reply({ ok: false, reason: 'key.err_empty' });
const { detectProvider, classifyFailure } = await import('../lib/provider-detect.mjs');
const detected = detectProvider(apiKey);
const baseUrl = String(parsed.baseUrl || detected?.baseUrl || '').replace(/\/+$/, '');
const model = String(parsed.model || detected?.model || '');
if (!baseUrl || !model) {
return reply({ ok: false, provider: detected, reason: 'key.err_unrecognised' });
}
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
// Anthropic's endpoint wants its own header; sending both is harmless.
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
});
if (!response.ok) {
return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
}
return reply({ ok: true, provider: detected, baseUrl, model, latencyMs: Date.now() - startedAt });
} catch (err) {
return reply({ ok: false, provider: detected, reason: classifyFailure({ code: err?.cause?.code || err?.code || err?.name }) });
} finally {
clearTimeout(timer);
}
});
return;
}
// API: Get config
if (req.url === '/api/config' && req.method === 'GET') {
try {
@@ -519,6 +646,25 @@ const server = http.createServer((req, res) => {
return;
}
// Serve the shared i18n runtime and the drive's locale from outside public/.
// Keeping one copy matters: the pages opened from file:// load these same
// files by relative path, and a second copy under public/ would drift.
if (req.url.startsWith('/lib/i18n/') || req.url === '/locale.js') {
const target = req.url === '/locale.js'
? path.join(__dirname, '../data/.openclaw/locale.js')
: path.join(__dirname, '..', 'lib', 'i18n', path.basename(req.url));
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(fs.readFileSync(target));
} else {
// locale.js is written by the launcher; before the first run it is absent
// and the page falls back to the browser language. Not an error.
res.writeHead(204);
res.end();
}
return;
}
// Serve static files
const filePath = req.url === '/'
? path.join(__dirname, 'public/index.html')