feat(portable): 虾盘云插上即用 — 自动开户送试用额度 + Config 首选卡片

解决「插上 U 盘选虾盘云模型却 Invalid token」:之前客户端只生成
sk-uc-指纹 key 写进配置,但后台从没注册该 token。

- xiapan-client.mjs 加 provisionApiKey():把设备指纹送到虾盘云后台
  (POST api.u-claw.org/internal/token/provision) 自动开一个带试用额度
  的 token,返回真正可用的 key。
- bootstrap-xiapan.mjs 改:首启(配置无 cloud key 时)调 provision 拿真
  key 写配置;已有 key 则幂等跳过;provision 失败回退指纹 key 不阻塞启动。
  后端按 deviceId 幂等,刷不出额度。
- Config.html:模型网格加「虾盘云」首选卡片(一个 key 用全部模型,无需
  申请,选中直接启动跳过填 Key);更新过时型号(MiniMax-M2/kimi-k2/
  qwen-plus/doubao-seed-1.6/gpt-5.4/claude-opus-4-6/deepseek-v4);
  banner 文案改为「已含试用额度」。

注:开户/送额度的商业逻辑在闭源服务端(token-key-service),开源仓只放
调用机制。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-06-15 16:01:35 +08:00
parent 2d0f33d5a0
commit b1468d455c
3 changed files with 139 additions and 56 deletions

View File

@@ -8,7 +8,7 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { getFingerprint } from './fingerprint.mjs';
import { buildApiKey } from './xiapan-client.mjs';
import { buildApiKey, provisionApiKey } from './xiapan-client.mjs';
const PROVIDER_ID = 'uclaw-cloud';
@@ -95,41 +95,42 @@ export async function bootstrapXiapan({ configPath, appRoot, log = console } = {
return { ok: false, reason: 'fingerprint-failed' };
}
const apiKey = buildApiKey(fingerprintInfo.fingerprint);
const config = readJsonSafe(configPath) || { gateway: { mode: 'local', auth: { token: 'uclaw' } } };
const providers = ensureModelsContainer(config);
const existing = providers[PROVIDER_ID];
if (existing && typeof existing === 'object') {
if (existing.apiKey && existing.apiKey !== apiKey) {
log.info?.(
`[bootstrap-xiapan] uclaw-cloud apiKey already configured (different fingerprint). `
+ `Current source=${fingerprintInfo.source}. Use Config UI to rebind if needed.`,
);
// Still ensure agents.defaults exists so the runtime knows to use uclaw-cloud
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) writeJson(configPath, config);
return {
ok: true,
action: 'kept',
source: fingerprintInfo.source,
apiKey: existing.apiKey,
};
// 幂等:配置里已有 uclaw-cloud apiKey → 保持不动,不再打网络 provision。
// (后端 provision 本身也按 deviceId 幂等,但客户端先短路可避免每次启动都请求。)
if (existing && typeof existing === 'object' && existing.apiKey) {
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) {
writeJson(configPath, config);
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
}
if (existing.apiKey === apiKey) {
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) {
writeJson(configPath, config);
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
}
return {
ok: true,
action: changedDefaults ? 'agents-defaults-added' : 'noop',
source: fingerprintInfo.source,
apiKey,
};
return {
ok: true,
action: changedDefaults ? 'agents-defaults-added' : 'noop',
source: fingerprintInfo.source,
apiKey: existing.apiKey,
};
}
// 首启(配置里还没有 cloud key静默开户。把指纹送到虾盘云后台自动开一个带
// 试用额度的 token。成功 → 用后端真正注册过的 key可直接聊天解决 Invalid token
// 失败(离线/后端不可达)→ 回退到指纹派生 key至少配置不空、不阻塞启动。
let apiKey = buildApiKey(fingerprintInfo.fingerprint);
let provisioned = false;
try {
const prov = await provisionApiKey(fingerprintInfo.fingerprint, { source: 'uclaw-portable' });
if (prov.ok && prov.apiKey) {
apiKey = prov.apiKey;
provisioned = true;
log.info?.(`[bootstrap-xiapan] Provisioned cloud token (reused=${prov.reused})`);
} else {
log.info?.(`[bootstrap-xiapan] Provision skipped (${prov.reason}); using fingerprint key`);
}
} catch (err) {
log.info?.(`[bootstrap-xiapan] Provision failed (${err.message}); using fingerprint key`);
}
providers[PROVIDER_ID] = {
@@ -144,11 +145,13 @@ export async function bootstrapXiapan({ configPath, appRoot, log = console } = {
writeJson(configPath, config);
log.info?.(
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, key=${apiKey.slice(0, 12)}…)`,
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, `
+ `provisioned=${provisioned}, key=${apiKey.slice(0, 12)}…)`,
);
return {
ok: true,
action: existing ? 'updated' : 'created',
action: 'created',
provisioned,
source: fingerprintInfo.source,
apiKey,
};

View File

@@ -89,3 +89,30 @@ export function getRechargeUrl(apiKey) {
// Page already has #recharge anchor; preserve it
return `${url.toString()}#recharge`;
}
// 首启静默开户:把设备指纹送到虾盘云后台,自动创建一个 token 并附带试用额度。
// 后端按 deviceId 幂等(同一台机器永远拿同一个 token刷不出额度不重启容器。
// 返回 { ok, apiKey, reused } —— apiKey 是后端真正注册过的 key可直接聊天
// 失败时返回 { ok:false, reason },调用方应回退到指纹派生 key至少配置不空
export async function provisionApiKey(fingerprint, { source = 'uclaw-portable' } = {}) {
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
return { ok: false, reason: 'bad-fingerprint' };
}
const base = getApiBase(); // https://api.u-claw.org/v1
const root = base.replace(/\/v1$/, ''); // https://api.u-claw.org
const url = `${root}/internal/token/provision`;
try {
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: fingerprint.toLowerCase(), source }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success || !data.data || !data.data.apiKey) {
return { ok: false, reason: data.message || `http-${res.status}` };
}
return { ok: true, apiKey: data.data.apiKey, reused: !!data.reused };
} catch (err) {
return { ok: false, reason: err.message };
}
}