diff --git a/README.md b/README.md index a1a0069..6551ef9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > 把本机已经登录的消费级 AI 客户端,接成 OpenAI 兼容接口,给 Codex、OpenCode、Cherry Studio、NextChat 等用。默认打开 Work Buddy / CodeBuddy、QClaw、千问办公(QwenWork)、TraeWork 四个通道;管理页下拉选其中一个。一次请求只走一个通道。 -当前版本 **2.1.2**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 +当前版本 **2.1.3**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 ## 这是什么? diff --git a/README_EN.md b/README_EN.md index 89282fc..2dbc2b9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ > Local consumer AI clients → one OpenAI-compatible API for Codex, OpenCode, Cherry Studio, NextChat, and similar agents. Work Buddy / CodeBuddy, QClaw, QwenWork, and TraeWork are on by default; pick one in the UI dropdown. Each request stays on one channel. -Release **2.1.2**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. +Release **2.1.3**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. ## What is this? diff --git a/catalog.py b/catalog.py index 2f63201..f795612 100644 --- a/catalog.py +++ b/catalog.py @@ -15,10 +15,15 @@ CATALOG_SETTING = "channel_catalogs" REFRESH_SETTING = "channel_catalog_refresh" +EXTRAS_SETTING = "channel_catalog_extras" Fetcher = Callable[[dict], Awaitable[list[dict]]] +class CatalogError(ValueError): + """Invalid channel or model id for a catalog write.""" + + def _load_map(key: str) -> dict: try: value = db.get_setting(key, {}) or {} @@ -72,11 +77,155 @@ def normalize_models(rows: Any) -> list[dict]: return models +def extras_for(channel: str) -> list[dict]: + items = _load_map(EXTRAS_SETTING).get(channel) + if isinstance(items, list) and items: + return normalize_models(items) + return [] + + +def save_extras(channel: str, models: list[dict]) -> None: + extras = _load_map(EXTRAS_SETTING) + extras[channel] = normalize_models(models) + db.set_setting(EXTRAS_SETTING, extras) + + +def _merge_models(base: list[dict], extra: list[dict]) -> list[dict]: + return normalize_models(list(base) + list(extra)) + + def models_for(channel: str, fallback: list[dict]) -> list[dict]: stored = stored_catalog(channel) - if stored: - return stored - return list(fallback) + base = stored if stored else list(fallback) + return _merge_models(base, extras_for(channel)) + + +def _with_manual(channel: str, models: list[dict]) -> list[dict]: + extra_ids = {str(item.get("id")) for item in extras_for(channel)} + annotated = [] + for item in models: + row = dict(item) + row["manual"] = str(row.get("id") or "") in extra_ids + annotated.append(row) + return annotated + + +def _normalize_model_id(channel: str, model_id: str) -> str: + mid = str(model_id or "").strip() + prefix = f"{channel}/" + if mid.startswith(prefix): + mid = mid[len(prefix) :].strip() + return mid + + +def _require_enabled_channel(channel: str) -> str: + import providers + + value = str(channel or "").strip() + if not value or not providers.is_channel_enabled(value): + raise CatalogError("unknown or disabled channel") + return value + + +def current_models(channel: str) -> list[dict]: + import providers + + provider = providers.get_provider(channel) + if provider is not None: + return list(provider.list_models()) + if channel == "workbuddy": + return workbuddy_fallback_models() + return extras_for(channel) + + +def upsert_model(channel: str, model_id: str, name: str = "") -> dict: + channel = _require_enabled_channel(channel) + mid = _normalize_model_id(channel, model_id) + if not mid: + raise CatalogError("model id is required") + label = str(name or "").strip() or mid + current = current_models(channel) + extra_ids = {str(item.get("id")) for item in extras_for(channel)} + current_ids = {str(item.get("id")) for item in current if isinstance(item, dict)} + if mid in current_ids and (channel == "workbuddy" or mid not in extra_ids): + if channel == "workbuddy": + models = [] + for item in current: + row = dict(item) if isinstance(item, dict) else {"id": str(item), "name": str(item)} + if str(row.get("id")) == mid: + row["name"] = label + models.append(row) + db.set_setting("models", models) + return { + "channel": channel, + "id": mid, + "name": label, + "count": len(models), + "models": models, + "updated": True, + } + raise CatalogError("model already exists in this channel") + if channel == "workbuddy": + models = [dict(item) if isinstance(item, dict) else {"id": str(item), "name": str(item)} for item in current] + models.append({"id": mid, "name": label}) + db.set_setting("models", models) + return { + "channel": channel, + "id": mid, + "name": label, + "count": len(models), + "models": models, + "updated": False, + } + extras = extras_for(channel) + found = False + for item in extras: + if item.get("id") == mid: + item["name"] = label + found = True + break + if not found: + extras.append({"id": mid, "name": label}) + save_extras(channel, extras) + models = current_models(channel) + return { + "channel": channel, + "id": mid, + "name": label, + "count": len(models), + "models": _with_manual(channel, models), + "updated": found, + } + + +def remove_model(channel: str, model_id: str) -> dict: + channel = _require_enabled_channel(channel) + mid = _normalize_model_id(channel, model_id) + if not mid: + raise CatalogError("model id is required") + if channel == "workbuddy": + current = workbuddy_fallback_models() + models = [ + item + for item in current + if str((item.get("id") if isinstance(item, dict) else item) or "") != mid + ] + if len(models) == len(current): + raise CatalogError("model not found") + db.set_setting("models", models) + return {"channel": channel, "id": mid, "count": len(models), "models": models} + extras = extras_for(channel) + kept = [item for item in extras if item.get("id") != mid] + if len(kept) == len(extras): + raise CatalogError("not a manually added model") + save_extras(channel, kept) + models = current_models(channel) + return { + "channel": channel, + "id": mid, + "count": len(models), + "models": _with_manual(channel, models), + } def workbuddy_fallback_models() -> list[dict]: @@ -92,17 +241,14 @@ def workbuddy_fallback_models() -> list[dict]: def _fallback_models(channel: str, provider) -> list[dict]: - if channel == "workbuddy": - return workbuddy_fallback_models() if provider is not None: - stored = stored_catalog(channel) - if stored: - return stored try: return list(provider.list_models()) except Exception: pass - return [] + if channel == "workbuddy": + return workbuddy_fallback_models() + return extras_for(channel) def _status_row( @@ -119,7 +265,7 @@ def _status_row( "mode": mode, "message": message, "count": len(models), - "models": models, + "models": _with_manual(channel, models), "updated_at": int(time.time()), } @@ -197,7 +343,7 @@ async def refresh_one(channel: str) -> dict: return _status_row( channel, mode="live", - models=fetched, + models=_merge_models(fetched, extras_for(channel)), message="", display_name=display_name, ) @@ -245,7 +391,7 @@ def catalog_snapshot() -> dict: "mode": meta.get("mode") or ("fallback" if channel not in LIVE_FETCHERS else "static"), "message": meta.get("message") or "", "count": len(models), - "models": models, + "models": _with_manual(channel, models), "updated_at": meta.get("updated_at"), } ) diff --git a/docs/releases/v2.1.3.md b/docs/releases/v2.1.3.md new file mode 100644 index 0000000..d04bb9d --- /dev/null +++ b/docs/releases/v2.1.3.md @@ -0,0 +1,27 @@ +# Buddy2api v2.1.3 + +发布日期:2026-08-30 + +管理页补齐两个小操作:手动添加模型时先选通道;账号测试时自己选模型。 + +## 按通道添加模型 + +- 「添加模型」弹出通道、模型 ID、显示名称。名称只写入所选通道,不会进其它通道的 `/v1/models`。 +- WorkBuddy 仍写后台模型表;QClaw / QwenWork / TraeWork 记为该通道的手动项,列表里带「手动」标记,可单独删除。 +- 一键读取供应模型不会冲掉手动添加的条目。 +- 模型仍按通道隔离:不属于当前 API Key 绑定通道时继续 400/403。 + +## 账号测试选模型 + +- 账号页「测试」不再直接发 `auto`。先打开窗口,从该账号所在通道的目录里选模型,也可改提示词。 +- 默认仍是 `auto`(通道默认路由);同一通道会记住上次选过的模型。 + +## 升级说明 + +- 无数据库迁移;手动模型写在 settings 里。 +- Docker 用户需要重新构建镜像并重启服务。 +- 思考强度策略未改。 + +## 验证 + +- 完整测试集:`245 passed`。 diff --git a/providers/traework/chat.py b/providers/traework/chat.py index a28f296..5516dfe 100644 --- a/providers/traework/chat.py +++ b/providers/traework/chat.py @@ -375,9 +375,11 @@ async def test_chat(account: dict, model: str = "qwen-3.7-plus", prompt: str = " "duration_ms": int((time.time() - t0) * 1000), "message": str(exc)[:400], } + chosen = translate_model(model or "auto") return { "ok": True, "status_code": 200, "duration_ms": int((time.time() - t0) * 1000), + "model": chosen, "message": text[:400], } diff --git a/server.py b/server.py index 86af03d..e93b76c 100644 --- a/server.py +++ b/server.py @@ -1024,6 +1024,35 @@ async def admin_update_models( return {"status": "ok"} +@app.post("/admin/models/catalogs") +async def admin_upsert_catalog_model( + request: Request, + authorization: str | None = Header(default=None), +): + _check_admin(authorization) + data = await _read_json_object(request) + channel = str(data.get("channel") or "").strip() + model_id = str(data.get("id") or data.get("model") or "").strip() + name = str(data.get("name") or "").strip() + try: + return catalog.upsert_model(channel, model_id, name) + except catalog.CatalogError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.delete("/admin/models/catalogs") +async def admin_remove_catalog_model( + channel: str, + model_id: str, + authorization: str | None = Header(default=None), +): + _check_admin(authorization) + try: + return catalog.remove_model(channel, model_id) + except catalog.CatalogError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # --- Codex 一键配置 --- @app.post("/admin/codex/setup") diff --git a/tests/test_models_refresh.py b/tests/test_models_refresh.py index 9f83ffb..ec48f32 100644 --- a/tests/test_models_refresh.py +++ b/tests/test_models_refresh.py @@ -153,6 +153,19 @@ def test_admin_models_page_has_one_click_control(): assert "一键读取供应模型" in html assert "/admin/models/refresh" in html assert "syncSources" in html + assert "请选择通道" in html + assert "addForm.channel" in html + assert "/admin/models/catalogs" in html + assert "submitAdd" in html + + +def test_account_test_ui_lets_user_pick_model(): + html = (Path(__file__).resolve().parents[1] / "web" / "index.html").read_text(encoding="utf-8") + assert "openTest" in html + assert "runTest" in html + assert "test.model" in html + assert "开始测试" in html + assert "{model:'auto',prompt:'ping'}" not in html def test_supplier_catalog_refresh_keeps_channels_distinct(isolated_db, all_channels, monkeypatch): @@ -267,3 +280,75 @@ def attempt_chat(payload, key): json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) + + +def test_manual_add_goes_to_selected_channel(isolated_db, all_channels): + import catalog + + custom_qw = "qwork-user-added" + custom_wb = "wb-user-added" + custom_qc = "qclaw-user-added" + + qwenwork = providers.get_provider("qwenwork") + workbuddy = providers.get_provider("workbuddy") + qclaw = providers.get_provider("qclaw") + + catalog.upsert_model("qwenwork", custom_qw, "Qwen extra") + catalog.upsert_model("workbuddy", custom_wb, "WB extra") + catalog.upsert_model("qclaw", "qclaw/" + custom_qc, "QC extra") + + assert qwenwork.accepts_model(custom_qw) + assert not workbuddy.accepts_model(custom_qw) + assert not qclaw.accepts_model(custom_qw) + assert workbuddy.accepts_model(custom_wb) + assert not qwenwork.accepts_model(custom_wb) + assert qclaw.accepts_model(custom_qc) + assert not workbuddy.accepts_model(custom_qc) + + by_id = {item["id"]: item for item in server.collect_v1_models()} + assert by_id["qwenwork/" + custom_qw]["channel"] == "qwenwork" + assert custom_qw not in by_id + assert custom_wb in by_id + assert by_id[custom_wb]["channel"] == "workbuddy" + assert by_id["qclaw/" + custom_qc]["channel"] == "qclaw" + assert custom_qc not in by_id + + snap = {item["channel"]: item for item in catalog.catalog_snapshot()["sources"]} + qw_manual = [item for item in snap["qwenwork"]["models"] if item.get("id") == custom_qw] + assert qw_manual and qw_manual[0].get("manual") is True + + catalog.remove_model("qwenwork", custom_qw) + assert not qwenwork.accepts_model(custom_qw) + + +def test_manual_extra_survives_live_refresh(isolated_db, all_channels, monkeypatch): + import catalog + + extra = "qclaw-hand-added" + catalog.upsert_model("qclaw", extra, "Hand added") + assert extra not in _ids(QCLAW_STATIC) + assert providers.get_provider("qclaw").accepts_model(extra) + + _seed_live_accounts() + _install_supplier_http(monkeypatch) + monkeypatch.setattr(server, "ALLOW_NO_ADMIN_AUTH", True) + result = asyncio.run(server.admin_refresh_models()) + sources = _by_channel(result) + + assert sources["qclaw"]["mode"] == "live" + assert extra in _ids(sources["qclaw"]["models"]) + assert QCLAW_NEW_ID in _ids(sources["qclaw"]["models"]) + assert providers.get_provider("qclaw").accepts_model(extra) + assert providers.get_provider("qclaw").accepts_model(QCLAW_NEW_ID) + assert extra not in _ids(providers.get_provider("workbuddy").list_models()) + + +def test_manual_add_rejects_unknown_channel(isolated_db, all_channels): + import catalog + + with pytest.raises(catalog.CatalogError): + catalog.upsert_model("not-a-channel", "foo") + with pytest.raises(catalog.CatalogError): + catalog.upsert_model("qwenwork", "") + with pytest.raises(catalog.CatalogError): + catalog.upsert_model("qwenwork", "qwork-advanced") diff --git a/version.py b/version.py index b777579..4260069 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "2.1.2" +VERSION = "2.1.3" diff --git a/web/index.html b/web/index.html index 5c56bd8..d041f6f 100644 --- a/web/index.html +++ b/web/index.html @@ -388,7 +388,7 @@ template:`
-
B2
Buddy 2 API
Local model gateway · v2.1.2
+
B2
Buddy 2 API
Local model gateway · v2.1.3
@@ -601,7 +601,7 @@
` }).component('accs',{props:['token','toast'],setup(p){ - const l=ref([]),ld=ref(true),sa=ref(false),ai=ref(''),nm=ref(''),disc=ref(null),dl=ref(false),scanning=ref(false),adding=ref(false),authPath=ref(''),test=ref(null),claim=ref(null),pkg=ref(null),tl=ref(0),claimingAll=ref(false),officialRefreshing=ref(false),checkins=ref({}),checkinSummary=ref(null),checkinLoading=ref(false),busy=ref({}),discChannel=ref('workbuddy'),channels=ref([{id:'workbuddy',display_name:'WorkBuddy'},{id:'qclaw',display_name:'QClaw'},{id:'qwenwork',display_name:'QwenWork / 千问办公'},{id:'traework',display_name:'TraeWork'}]); + const l=ref([]),ld=ref(true),sa=ref(false),ai=ref(''),nm=ref(''),disc=ref(null),dl=ref(false),scanning=ref(false),adding=ref(false),authPath=ref(''),test=ref(null),claim=ref(null),pkg=ref(null),tl=ref(0),claimingAll=ref(false),officialRefreshing=ref(false),checkins=ref({}),checkinSummary=ref(null),checkinLoading=ref(false),busy=ref({}),discChannel=ref('workbuddy'),channels=ref([{id:'workbuddy',display_name:'WorkBuddy'},{id:'qclaw',display_name:'QClaw'},{id:'qwenwork',display_name:'QwenWork / 千问办公'},{id:'traework',display_name:'TraeWork'}]),catalogs=ref({}); const filters=reactive({q:'',status:'all',claim:'all',sort:'priority'}); function hydrate(a){return {...a,_weight:a.weight||1,_priority:a.priority||0,_creditSnapshot:a.credit_snapshot||a.credit_limit||0,_baseWeight:a.weight||1,_basePriority:a.priority||0,_baseCreditSnapshot:a.credit_snapshot||a.credit_limit||0}} function dirty(a){return Number(a._weight||1)!==Number(a._baseWeight||1)||Number(a._priority||0)!==Number(a._basePriority||0)||Number(a._creditSnapshot||0)!==Number(a._baseCreditSnapshot||0)} @@ -623,7 +623,14 @@ async function ref2(a){await withBusy(a,'refresh',async()=>{try{await api.post('/admin/accounts/'+a.id+'/refresh',{},p.token);p.toast('刷新成功');await load()}catch(e){p.toast(apiErr(e,'刷新失败'),'err')}})} async function saveMeta(a){await withBusy(a,'save',async()=>{try{const creditSnapshot=Math.max(0,Number(a._creditSnapshot)||0);const body={weight:parseInt(a._weight)||1,priority:parseInt(a._priority)||0};if(Number(a._creditSnapshot||0)!==Number(a._baseCreditSnapshot||0))body.credit_limit=creditSnapshot;await api.put('/admin/accounts/'+a.id,body,p.token);a._baseWeight=parseInt(a._weight)||1;a._basePriority=parseInt(a._priority)||0;a._baseCreditSnapshot=creditSnapshot;p.toast(body.credit_limit!==undefined?'已保存余额快照':'已保存');await load()}catch(e){p.toast(apiErr(e,'保存失败'),'err')}})} async function toggle(a){await withBusy(a,'toggle',async()=>{try{await api.put('/admin/accounts/'+a.id,{status:a.status==='active'?'inactive':'active'},p.token);p.toast(a.status==='active'?'已禁用':'已启用');await load()}catch(e){p.toast(apiErr(e,'操作失败'),'err')}})} - async function testOne(a){tl.value=a.id;test.value=null;try{const r=await api.post('/admin/accounts/'+a.id+'/test',{model:'auto',prompt:'ping'},p.token);test.value={account:a.nickname||a.name,result:r};p.toast(r.ok?'测试成功':'测试失败',r.ok?'ok':'err');await load()}catch(e){p.toast(apiErr(e,'测试失败'),'err');test.value={account:a.nickname||a.name,result:{ok:false,status_code:0,message:e.message}}}tl.value=0} + async function loadCatalogs(){try{const snap=await api.get('/admin/models/catalogs',p.token);const map={};(snap.sources||[]).forEach(s=>{map[s.channel]=s.models||[]});catalogs.value=map}catch(e){}} + function modelsFor(channel){const rows=catalogs.value[channel]||[];const out=[];const seen=new Set();function add(id,name){if(!id||seen.has(id))return;seen.add(id);out.push({id,name:name||id})}add('auto','auto(通道默认)');rows.forEach(x=>add(x.id,x.name||x.id));return out} + const testModels=computed(()=>test.value?modelsFor(test.value.channel):[]); + function lastModel(channel){try{return localStorage.getItem('cb_gw_test_model_'+channel)||''}catch(e){return ''}} + function rememberModel(channel,model){try{localStorage.setItem('cb_gw_test_model_'+channel,model)}catch(e){}} + async function openTest(a){const channel=a.provider||'workbuddy';if(!Object.keys(catalogs.value).length)await loadCatalogs();const opts=modelsFor(channel);const saved=lastModel(channel);const model=(saved&&opts.some(x=>x.id===saved))?saved:'auto';test.value={id:a.id,account:a.nickname||a.name,channel,model,prompt:channel==='traework'?'请回复:pong':'ping',result:null}} + function closeTest(){if(tl.value)return;test.value=null} + async function runTest(){if(!test.value||tl.value)return;const a=test.value;const model=(a.model||'auto').trim()||'auto';const prompt=(a.prompt||'').trim()||(a.channel==='traework'?'请回复:pong':'ping');rememberModel(a.channel,model);tl.value=a.id;try{const r=await api.post('/admin/accounts/'+a.id+'/test',{model,prompt},p.token);test.value={...a,model,prompt,result:r};p.toast(r.ok?'测试成功':'测试失败',r.ok?'ok':'err');await load()}catch(e){test.value={...a,model,prompt,result:{ok:false,status_code:0,message:apiErr(e,'测试失败')}};p.toast(apiErr(e,'测试失败'),'err')}tl.value=0} async function claimOne(a){await withBusy(a,'claim',async()=>{try{const r=await api.post('/admin/accounts/'+a.id+'/checkin',{},p.token);checkins.value={...checkins.value,[a.id]:r};claim.value={title:'领取结果',results:[r]};p.toast(r.claimed?'领取成功':(r.already_claimed?'今日已领':'领取失败'),r.ok?'ok':'err');await load();const fresh=l.value.find(x=>Number(x.id)===Number(a.id));if(fresh)await refreshResource(fresh,true,true);await loadCheckins(true)}catch(e){p.toast(apiErr(e,'领取失败'),'err')}})} async function claimAll(){if(claimingAll.value)return;claimingAll.value=true;try{const r=await api.post('/admin/accounts/checkin-all',{},p.token);claim.value={title:'一键领取结果',summary:r,results:r.results||[]};p.toast('领取 '+r.claimed+' · 已领 '+r.already_claimed+' · 失败 '+r.failed,r.failed?'err':'ok');await load(true);await loadCheckins(true)}catch(e){p.toast(apiErr(e,'一键领取失败'),'err')}claimingAll.value=false} async function del(a){if(!confirm('删除账号 '+(a.nickname||a.name||a.id)+' ?'))return;await api.del('/admin/accounts/'+a.id,p.token);p.toast('已删除');await load();await discover(authPath.value)} @@ -667,7 +674,7 @@ function expireText(x){if(x.expired)return '已过期';if(x.days_to_expire===null||x.days_to_expire===undefined)return x.expire_time||'长期';if(x.days_to_expire<0)return '已过期';if(x.days_to_expire<=7)return x.days_to_expire+' 天内';return shortTime(x.expire_time)} function pkgBadge(x){if(x.expired)return 'err';if(Number(x.days_to_expire)>=0&&Number(x.days_to_expire)<=7)return 'inactive';return 'ok'} function clearPath(){authPath.value='';discover('')} - onMounted(()=>{loadChannels();load(true);discover()});return{l,visibleAccounts,filters,ld,sa,ai,nm,disc,dl,scanning,adding,authPath,test,claim,pkg,tl,claimingAll,officialRefreshing,checkins,checkinSummary,checkinLoading,busyKey,dirty,load,loadCheckins,discover,scan,scanCustom,add,ref2,saveMeta,toggle,testOne,claimOne,claimAll,refreshAllResources,openPackages,refreshPackages,del,fmt,size,credit,tok,creditPct,claimText,officialBalance,officialMeta,cacheAge,officialWarn,checkinClass,checkinText,tokenLife,shortTime,expireText,pkgBadge,clearPath,I,discChannel,channels} + onMounted(()=>{loadChannels();loadCatalogs();load(true);discover()});return{l,visibleAccounts,filters,ld,sa,ai,nm,disc,dl,scanning,adding,authPath,test,testModels,claim,pkg,tl,claimingAll,officialRefreshing,checkins,checkinSummary,checkinLoading,busyKey,dirty,load,loadCheckins,discover,scan,scanCustom,add,ref2,saveMeta,toggle,openTest,closeTest,runTest,claimOne,claimAll,refreshAllResources,openPackages,refreshPackages,del,fmt,size,credit,tok,creditPct,claimText,officialBalance,officialMeta,cacheAge,officialWarn,checkinClass,checkinText,tokenLife,shortTime,expireText,pkgBadge,clearPath,I,discChannel,channels} },template:`

账号管理

粘性主账号 · 失败自动切换

@@ -724,13 +731,19 @@

自定义路径

可领 {{checkinSummary.available}} · 已领 {{checkinSummary.already_claimed}}{{visibleAccounts.length}}/{{l.length}}个 · {{l.filter(a=>a.status==='active').length}}活跃
账号列表官方额度来自 Work Buddy 资源接口;每日领取的 150 按官方返回的约 1 个月到期时间展示
当前 {{visibleAccounts.length}} 条
- +
账号通道UID状态今日领取权重优先级官方余额即将到期本地估算本地快照Token 有效期请求Token累计已用
{{a.nickname||a.name}} 未保存{{a.provider||'workbuddy'}}{{a.uid?.slice(0,8)}}…{{a.status}}{{checkinText(a)}}
旧缓存
{{credit(officialBalance(a))}}{{busyKey(a.id,'resource')?'读取中':(a.official_resource?.unsupported?'无积分':(a.official_resource&&!a.official_resource.ok?'失败':'未刷新'))}}
{{officialMeta(a)}}
缓存 {{cacheAge(a)}}
{{credit(a.official_resource?.expiring_30d_total)}}{{shortTime(a.official_resource.next_expire_time)}}
最近到期 {{credit(a.official_resource?.next_expire_amount)}} · {{a.official_resource?.next_expire_days??'-'}} 天
{{credit(a.credit_remaining)}}未设置{{a.credit_snapshot>0?credit(a.credit_used_pct)+'%':'备用'}}
{{a.credit_snapshot>0?'快照后已用 '+credit(a.credit_since_snapshot):'官方失败时可手动校准'}}
{{tokenLife(a)}}{{a.total_requests}}{{tok(a.total_tokens)}}{{credit(a.total_credits)}}
{{a.nickname||a.name}} 未保存{{a.provider||'workbuddy'}}{{a.uid?.slice(0,8)}}…{{a.status}}{{checkinText(a)}}
旧缓存
{{credit(officialBalance(a))}}{{busyKey(a.id,'resource')?'读取中':(a.official_resource?.unsupported?'无积分':(a.official_resource&&!a.official_resource.ok?'失败':'未刷新'))}}
{{officialMeta(a)}}
缓存 {{cacheAge(a)}}
{{credit(a.official_resource?.expiring_30d_total)}}{{shortTime(a.official_resource.next_expire_time)}}
最近到期 {{credit(a.official_resource?.next_expire_amount)}} · {{a.official_resource?.next_expire_days??'-'}} 天
{{credit(a.credit_remaining)}}未设置{{a.credit_snapshot>0?credit(a.credit_used_pct)+'%':'备用'}}
{{a.credit_snapshot>0?'快照后已用 '+credit(a.credit_since_snapshot):'官方失败时可手动校准'}}
{{tokenLife(a)}}{{a.total_requests}}{{tok(a.total_tokens)}}{{credit(a.total_credits)}}
没有匹配的账号
🔌

暂无账号 · 先使用上方本机登录检测导入

-
+
` }).component('keys',{props:['token','toast'],setup(p){ @@ -813,13 +826,45 @@

自定义路径

` }).component('mdls',{props:['token','toast'],setup(p){ - const m=ref([]),al=ref({}),ld=ref(true),sources=ref([]),syncing=ref(false); + const m=ref([]),al=ref({}),ld=ref(true),sources=ref([]),syncing=ref(false),adding=ref(false),addOpen=ref(false); + const FALLBACK_CH=[{id:'workbuddy',display_name:'WorkBuddy'},{id:'qclaw',display_name:'QClaw'},{id:'qwenwork',display_name:'QwenWork / 千问办公'},{id:'traework',display_name:'TraeWork'}]; + const addForm=reactive({channel:'workbuddy',id:'',name:''}); const BUILTIN=['gpt-4o','gpt-4o-mini','gpt-4-turbo','gpt-4','gpt-3.5-turbo','claude-3.5-sonnet','claude-3-haiku','deepseek-chat','deepseek-coder','moonshot-v1-128k','moonshot-v1-32k']; const newKey=ref(''),newVal=ref(''); + const channels=computed(()=>sources.value.length?sources.value.map(s=>({id:s.channel,display_name:s.display_name||s.channel})):FALLBACK_CH); async function load(){ld.value=true;try{m.value=await api.get('/admin/models',p.token);al.value=await api.get('/admin/aliases',p.token);try{const snap=await api.get('/admin/models/catalogs',p.token);sources.value=snap.sources||[]}catch(e){sources.value=[]}}catch(e){p.toast('失败','err')}ld.value=false} + async function refreshCatalogs(){try{const snap=await api.get('/admin/models/catalogs',p.token);sources.value=snap.sources||[]}catch(e){}} async function save(){try{await api.put('/admin/models',m.value,p.token);await api.put('/admin/aliases',al.value,p.token);p.toast('已保存')}catch(e){p.toast('失败','err')}} async function syncSources(){if(syncing.value)return;syncing.value=true;try{const r=await api.post('/admin/models/refresh',{},p.token);sources.value=r.sources||[];p.toast('已读取各通道供应模型');try{m.value=await api.get('/admin/models',p.token)}catch(e){}}catch(e){p.toast(apiErr(e,'读取供应模型失败'),'err')}syncing.value=false} - function add(){m.value.push({id:'',name:''})}function rm(i){m.value.splice(i,1)} + function openAdd(){addForm.channel=addForm.channel||'workbuddy';addForm.id='';addForm.name='';addOpen.value=true} + function closeAdd(){addOpen.value=false} + async function submitAdd(){ + const id=addForm.id.trim(),name=addForm.name.trim(),channel=addForm.channel; + if(!channel){p.toast('请选择通道','err');return} + if(!id){p.toast('请输入模型 ID','err');return} + if(adding.value)return;adding.value=true; + try{ + const label=(channels.value.find(c=>c.id===channel)||{}).display_name||channel; + const bare=id.startsWith(channel+'/')?id.slice(channel.length+1):id; + if(channel==='workbuddy'){ + if(m.value.some(x=>x.id===bare)){p.toast('该模型已在 WorkBuddy 列表中','err');adding.value=false;return} + const models=m.value.concat([{id:bare,name:name||bare}]); + await api.put('/admin/models',models,p.token); + m.value=models; + p.toast('已添加 '+label); + }else{ + const r=await api.post('/admin/models/catalogs',{channel,id:bare,name},p.token); + p.toast((r.updated?'已更新 ':'已添加 ')+label); + } + addOpen.value=false; + await refreshCatalogs(); + }catch(e){p.toast(apiErr(e,'添加失败'),'err')} + adding.value=false + } + function rm(i){m.value.splice(i,1)} + async function rmExtra(channel,id){ + try{await api.del('/admin/models/catalogs?channel='+encodeURIComponent(channel)+'&model_id='+encodeURIComponent(id),p.token);p.toast('已删除');await refreshCatalogs()}catch(e){p.toast(apiErr(e,'删除失败'),'err')} + } function isBuiltin(k){return BUILTIN.includes(k)} function addAlias(){ const k=newKey.value.trim(),v=newVal.value.trim(); @@ -832,22 +877,27 @@

自定义路径

function modeText(s){return s.mode==='live'?'在线读取':(s.mode==='fallback'?'回退本地':'未读取')} function modeClass(s){return s.mode==='live'?'ok':(s.mode==='fallback'?'warn':'')} const otherSources=computed(()=>sources.value.filter(s=>s.channel!=='workbuddy')); - onMounted(load);return{m,al,ld,sources,otherSources,syncing,load,save,syncSources,add,rm,newKey,newVal,addAlias,rmAlias,isBuiltin,modeText,modeClass,I} + onMounted(load);return{m,al,ld,sources,otherSources,syncing,adding,addOpen,addForm,channels,load,save,syncSources,openAdd,closeAdd,submitAdd,rm,rmExtra,newKey,newVal,addAlias,rmAlias,isBuiltin,modeText,modeClass,I} },template:`
-

模型配置

/v1/models 按通道列出;一键读取各来源供应模型,独有模型(如 TraeWork 豆包)只挂在该通道

-
+

模型配置

/v1/models 按通道列出;一键读取各来源供应模型,手动添加时先选通道,名称只挂在该通道

+
各通道供应模型在线读取或回退本地/后台列表,互不合并
通道方式数量说明
{{s.display_name||s.channel}} {{s.channel}}{{modeText(s)}}{{s.count||0}}{{s.message||'—'}}
{{s.display_name||s.channel}}仅 {{s.channel}}/id ,客户端选错通道会直接拒绝
- - - +
ID名称
{{s.channel}}/{{x.id}}{{x.name||x.id}}
暂无
+ +
ID名称
{{s.channel}}/{{x.id}} 手动{{x.name||x.id}}
暂无
+