fix(portable): Gemini keys, Telegram pairing UI, and config durability

Improve first-run and channel setup for non-technical users: detect newer
Gemini key formats, pin Node 22.22.3, add Control Panel Telegram approve
flow, and keep channels/models when config is rewritten. Persist uclaw
wizard state via uclaw-meta.json so restarts skip language/persona prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 18:45:03 +08:00
parent 0be8c3e3fe
commit dd8e0f55c3
35 changed files with 2673 additions and 34 deletions

View File

@@ -46,10 +46,22 @@ function localeFromSystem() {
}
}
function localeFromMeta(configPath) {
if (!configPath) return null;
try {
const metaPath = join(dirname(configPath), 'uclaw-meta.json');
if (!existsSync(metaPath)) return null;
return normalise(JSON.parse(readFileSync(metaPath, 'utf8'))?.locale);
} catch {
return null;
}
}
export function resolveLocale({ override, configPath } = {}) {
return (
normalise(override) ||
localeFromConfig(configPath) ||
localeFromMeta(configPath) ||
localeFromSystem() ||
FALLBACK
);

View File

@@ -176,6 +176,18 @@ window.UCLAW_I18N_MESSAGES = {
"ch.wechat_start": "Click this card to show the QR code",
"ch.saved": "Chat app settings saved.",
"ch.err_save": "Could not save the chat app settings. Check the drive is still plugged in.",
"tg.steps_title": "After saving, connect your Telegram account:",
"tg.step1": "Save the bot token above (click “Open dashboard” or save channels).",
"tg.step2": "On your phone, open Telegram, find your bot, and send /start.",
"tg.step3": "Come back here — a button will appear. Click it once. No command line.",
"tg.waiting": "Waiting for you to send /start to your bot on Telegram…",
"tg.pending": "Someone wants to connect: {who}. Click below to allow.",
"tg.approve": "Allow this Telegram account",
"tg.approving": "Approving…",
"tg.approved": "✅ Telegram connected — go chat in Telegram.",
"tg.approved_toast": "Telegram account approved.",
"tg.approve_failed": "Could not approve: {reason}",
"tg.poll_failed": "Could not check pairing status. Is U-Claw still running?",
"info.layout_title": "What is on the drive",
"info.layout_desc": "How the folders are organised",
"info.skills": "Skills",
@@ -459,6 +471,18 @@ window.UCLAW_I18N_MESSAGES = {
"ch.wechat_start": "点这张卡片开始扫码",
"ch.saved": "聊天软件设置已保存。",
"ch.err_save": "聊天软件设置没能保存。确认 U 盘还插着。",
"tg.steps_title": "保存 Bot Token 后,连接你的 Telegram",
"tg.step1": "先保存上面的 Bot Token点「打开对话界面」或保存聊天软件设置。",
"tg.step2": "在手机上打开 Telegram找到你的机器人发送 /start。",
"tg.step3": "回到这个页面 —— 会出现一个按钮,点一下就行,不用敲命令。",
"tg.waiting": "等你去 Telegram 给机器人发 /start…",
"tg.pending": "有人要连接:{who}。点下面按钮允许。",
"tg.approve": "允许这个 Telegram 账号",
"tg.approving": "正在批准…",
"tg.approved": "✅ Telegram 已连通 —— 去 Telegram 里聊吧。",
"tg.approved_toast": "Telegram 账号已批准。",
"tg.approve_failed": "批准失败:{reason}",
"tg.poll_failed": "无法检查配对状态。U-Claw 还在运行吗?",
"info.layout_title": "U 盘里有什么",
"info.layout_desc": "文件夹是怎么组织的",
"info.skills": "技能",

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
// Installs skills from skills/manifest.json into a target directory.
//
// This is the only place skill content is handled. install.sh and install.ps1
// call it and contain no skill text of their own, which is what keeps the two
// platforms provably identical (see tests/skills-manifest.test.mjs) and keeps
// non-ASCII bytes out of .bat launchers (see tests/windows-launchers.test.mjs).
//
// node lib/install-skills.mjs --target <dir> [--locale en] [--persona general]
// [--source <dir>] [--ref <git-ref>]
// [--list] [--dry-run] [--json]
import { mkdirSync, readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// Where to fetch from lives in origin.json so a move to our own host is one
// edit. Falling back to literals keeps this working when the file is absent —
// the remote installers download this script on its own, without the repo.
const ORIGIN = (() => {
for (const dir of [join(scriptDirOf(), '..'), scriptDirOf()]) {
try { return JSON.parse(readFileSync(join(dir, 'origin.json'), 'utf8')); } catch { /* try next */ }
}
return null;
})();
// A template rather than a base URL: Gitea and GitHub lay raw paths out
// differently, and hardcoding either shape makes the other impossible.
const RAW_TEMPLATE = ORIGIN?.urls?.rawTemplate
?? 'https://gitea.fanghe.it.com/zhenghy/u-claw/raw/branch/{ref}/{path}';
const DEFAULT_REF = ORIGIN?.repo?.ref ?? 'main';
function scriptDirOf() { return dirname(fileURLToPath(import.meta.url)); }
const scriptDir = scriptDirOf();
function parseArgs(argv) {
const opts = { locale: 'en', personas: [], list: false, dryRun: false, json: false, ref: DEFAULT_REF };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => {
const value = argv[++i];
if (value === undefined) fail(`${arg} needs a value`);
return value;
};
switch (arg) {
case '--target': opts.target = next(); break;
case '--source': opts.source = next(); break;
case '--locale': opts.locale = next(); break;
case '--persona': opts.personas.push(next()); break;
case '--ref': opts.ref = next(); break;
case '--list': opts.list = true; break;
case '--dry-run': opts.dryRun = true; break;
case '--json': opts.json = true; break;
case '--help': case '-h': usage(); process.exit(0); break;
default: fail(`unknown argument: ${arg}`);
}
}
return opts;
}
function usage() {
process.stdout.write(
'Usage: install-skills.mjs --target <dir> [--locale en] [--persona <id>]...\n' +
' [--source <dir>] [--ref <git-ref>] [--list] [--dry-run] [--json]\n'
);
}
function fail(message) {
process.stderr.write(`install-skills: ${message}\n`);
process.exit(1);
}
// Content lives in the repo checkout or on the USB. The one-line installers have
// neither, so they fall through to fetching from GitHub at a pinned ref.
function resolveLocalSource(explicit) {
const candidates = [explicit, join(scriptDir, '..', 'skills'), join(scriptDir, 'skills')];
for (const dir of candidates) {
if (dir && existsSync(join(dir, 'manifest.json'))) return resolve(dir);
}
return null;
}
async function fetchText(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${response.status} ${response.statusText} for ${url}`);
return response.text();
}
function rawUrl(ref, path) {
return RAW_TEMPLATE.replace('{ref}', ref).replace('{path}', `skills/${path}`);
}
function selectSkills(manifest, { locale, personas }) {
return manifest.skills
.filter((skill) => skill.status === 'shipping')
.filter((skill) => (skill.locales ?? []).includes(locale))
// No --persona means install everything available for the locale.
.filter((skill) => personas.length === 0 || personas.some((p) => (skill.personas ?? []).includes(p)))
.sort((a, b) => a.id.localeCompare(b.id));
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (!opts.target && !opts.list) fail('--target is required (or use --list)');
const localSource = resolveLocalSource(opts.source);
const remote = localSource === null;
let manifest;
try {
manifest = JSON.parse(
remote
? await fetchText(rawUrl(opts.ref, 'manifest.json'))
: readFileSync(join(localSource, 'manifest.json'), 'utf8')
);
} catch (error) {
fail(`could not read the skill manifest: ${error.message}`);
}
const selected = selectSkills(manifest, opts);
if (opts.list) {
process.stdout.write(
opts.json
? `${JSON.stringify(selected.map((s) => s.id))}\n`
: `${selected.map((s) => s.id).join('\n')}\n`
);
return;
}
let installed = 0;
let skipped = 0;
const failures = [];
for (const skill of selected) {
const destDir = join(opts.target, skill.id);
const destFile = join(destDir, 'SKILL.md');
if (existsSync(destFile)) { skipped++; continue; }
if (opts.dryRun) { installed++; continue; }
try {
mkdirSync(destDir, { recursive: true });
if (remote) {
writeFileSync(destFile, await fetchText(rawUrl(opts.ref, `${opts.locale}/${skill.id}/SKILL.md`)), 'utf8');
} else {
const src = join(localSource, opts.locale, skill.id, 'SKILL.md');
if (!existsSync(src)) throw new Error(`missing ${src}`);
copyFileSync(src, destFile);
}
installed++;
} catch (error) {
// One bad skill should not abort the install — report at the end instead.
failures.push(`${skill.id}: ${error.message}`);
}
}
if (opts.json) {
process.stdout.write(`${JSON.stringify({ installed, skipped, failed: failures })}\n`);
} else {
process.stdout.write(`skills installed: ${installed}, already present: ${skipped}\n`);
for (const failure of failures) process.stderr.write(` failed: ${failure}\n`);
}
if (failures.length > 0 && installed === 0) process.exit(1);
}
main().catch((error) => fail(error.message));

View File

@@ -169,6 +169,18 @@
"ch.wechat_start": "Click this card to show the QR code",
"ch.saved": "Chat app settings saved.",
"ch.err_save": "Could not save the chat app settings. Check the drive is still plugged in.",
"tg.steps_title": "After saving, connect your Telegram account:",
"tg.step1": "Save the bot token above (click “Open dashboard” or save channels).",
"tg.step2": "On your phone, open Telegram, find your bot, and send /start.",
"tg.step3": "Come back here — a button will appear. Click it once. No command line.",
"tg.waiting": "Waiting for you to send /start to your bot on Telegram…",
"tg.pending": "Someone wants to connect: {who}. Click below to allow.",
"tg.approve": "Allow this Telegram account",
"tg.approving": "Approving…",
"tg.approved": "✅ Telegram connected — go chat in Telegram.",
"tg.approved_toast": "Telegram account approved.",
"tg.approve_failed": "Could not approve: {reason}",
"tg.poll_failed": "Could not check pairing status. Is U-Claw still running?",
"info.layout_title": "What is on the drive",
"info.layout_desc": "How the folders are organised",
"info.skills": "Skills",

View File

@@ -169,6 +169,18 @@
"ch.wechat_start": "点这张卡片开始扫码",
"ch.saved": "聊天软件设置已保存。",
"ch.err_save": "聊天软件设置没能保存。确认 U 盘还插着。",
"tg.steps_title": "保存 Bot Token 后,连接你的 Telegram",
"tg.step1": "先保存上面的 Bot Token点「打开对话界面」或保存聊天软件设置。",
"tg.step2": "在手机上打开 Telegram找到你的机器人发送 /start。",
"tg.step3": "回到这个页面 —— 会出现一个按钮,点一下就行,不用敲命令。",
"tg.waiting": "等你去 Telegram 给机器人发 /start…",
"tg.pending": "有人要连接:{who}。点下面按钮允许。",
"tg.approve": "允许这个 Telegram 账号",
"tg.approving": "正在批准…",
"tg.approved": "✅ Telegram 已连通 —— 去 Telegram 里聊吧。",
"tg.approved_toast": "Telegram 账号已批准。",
"tg.approve_failed": "批准失败:{reason}",
"tg.poll_failed": "无法检查配对状态。U-Claw 还在运行吗?",
"info.layout_title": "U 盘里有什么",
"info.layout_desc": "文件夹是怎么组织的",
"info.skills": "技能",

View File

@@ -27,10 +27,12 @@ const PROVIDERS = [
{
id: 'google',
label: 'Google Gemini',
prefixes: ['AIza'],
// Google AI Studio now issues Auth keys (AQ.Ab…) alongside legacy Standard keys (AIzaSy…).
prefixes: ['AQ.', 'AIza'],
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
model: 'gemini-2.5-flash',
models: ['gemini-2.5-flash', 'gemini-2.5-pro'],
// New AI Studio accounts often cannot use 2.5-flash yet (404). Try 2.0 first.
model: 'gemini-2.0-flash',
models: ['gemini-2.0-flash', 'gemini-flash-latest', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'],
},
{
id: 'groq',
@@ -75,7 +77,7 @@ export function detectProvider(rawKey) {
export function classifyFailure({ status, code } = {}) {
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') return 'key.err_offline';
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ABORT_ERR') return 'key.err_timeout';
if (status === 401 || status === 403) return 'key.err_rejected';
if (status === 400 || status === 401 || status === 403) return 'key.err_rejected';
if (status === 429) return 'key.err_quota';
if (status === 404) return 'key.err_model';
if (typeof status === 'number' && status >= 500) return 'key.err_provider_down';
@@ -85,3 +87,32 @@ export function classifyFailure({ status, code } = {}) {
export function listProviders() {
return PROVIDERS.map(({ id, label, baseUrl, model, models }) => ({ id, label, baseUrl, model, models }));
}
// Google rotates model availability per account. When the first choice 404s, try the rest.
export function modelsToTry(provider, requestedModel) {
const primary = String(requestedModel || provider?.model || '').trim();
if (provider?.id === 'google') {
return [...new Set([primary, provider.model, ...(provider.models || [])].filter(Boolean))];
}
return primary ? [primary] : [];
}
// AQ auth keys only work on the OpenAI-compatible surface. Ask Google which models
// this key can actually see instead of guessing from a static list.
export async function discoverGoogleOpenAiModels(baseUrl, apiKey) {
const root = String(baseUrl || '').replace(/\/+$/, '');
if (!root || !apiKey) return [];
try {
const res = await fetch(`${root}/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) return [];
const body = await res.json();
const rows = body.data || body.models || [];
return rows
.map((m) => String(m.id || m.name || '').replace(/^models\//, ''))
.filter(Boolean);
} catch {
return [];
}
}

View File

@@ -256,10 +256,30 @@ function applyNoProxy(t, nodeBin) {
// the language follow the machine instead of the drive, which is exactly the
// behaviour we do not want. A generated <script> is the one channel that works
// from both file:// and http://.
function readUclawMeta() {
try {
const metaPath = join(paths.state, 'uclaw-meta.json');
if (!existsSync(metaPath)) return null;
return JSON.parse(readFileSync(metaPath, 'utf8'));
} catch {
return null;
}
}
function localeFromLocaleJs() {
try {
const content = readFileSync(join(paths.state, 'locale.js'), 'utf8');
const match = content.match(/UCLAW_LOCALE\s*=\s*(["'])([^"']+)\1/);
return match ? match[2] : null;
} catch {
return null;
}
}
// True until the drive records a language of its own. Asking once and never
// again is the point: the answer lives on the drive, not in a browser profile.
function localeChosenOnDrive() {
return Boolean(driveSetting('locale'));
return Boolean(driveSetting('locale') || localeFromLocaleJs());
}
// The wizard runs once. Its answer decides which skills are installed and how
@@ -271,10 +291,13 @@ function personaChosenOnDrive() {
function driveSetting(key) {
try {
return JSON.parse(readFileSync(paths.config, 'utf8'))?.uclaw?.[key];
const fromConfig = JSON.parse(readFileSync(paths.config, 'utf8'))?.uclaw?.[key];
if (fromConfig !== undefined) return fromConfig;
} catch {
return undefined;
/* try sidecar */
}
const fromMeta = readUclawMeta()?.[key];
return fromMeta !== undefined ? fromMeta : undefined;
}
function writeLocaleForPages(locale) {