Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Fetch WorkBuddy Global model catalogs from `/v2/enterprises/personal/models` instead of the console OIDC page, and return catalog failures as 503 JSON so reverse proxies do not replace them with HTML
- After dropping caller system prompts, send WorkBuddy an empty leading system message so Global no longer rejects the request with `11128 first message is not system prompt`
- Show account-type skeletons in the add-account dialog until `/api/providers` returns, instead of flashing the default Qoder Global tile
- Show first-token time on Logs and label token counts as input / output
- Drop auth-type from account cards, keep cards mounted on refresh so quota and runtime meters can animate, and give those meters a very light same-hue gradient

### 中文

- WorkBuddy 国际版模型目录改打 `/v2/enterprises/personal/models`,不再走会 500 HTML 的 console 页面;目录失败改为 503 JSON,避免反代把错误换成 HTML 整页
- 丢弃调用方系统提示词后,仍给 WorkBuddy 补一条空的 system,避免国际版 `11128 first message is not system prompt`
- 添加账号弹窗等 `/api/providers` 返回后再显示类型,加载中用骨架屏,不再先闪默认 Qoder Global
- Logs 请求历史增加首字时间,Tokens 用小字标出输入 / 输出
- 账号卡片去掉认证方式;刷新时卡片保持挂载,额度和状态柱用 GSAP 过渡,色条加很浅的同色渐变

## 0.2.15 - 2026-08-28

### English
Expand Down
4 changes: 2 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@

- 圆角 8px 或更小,除非现有组件已用更大值
- 账号/资源列表卡片优先使用浅边框和平面表面,不强制最小高度,不额外叠加强阴影
- 账号卡片保持操作台密度:单行身份(名称可直接改 + provider/auth/UID),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;并发和优先级可在卡片上改,重启次数仍是只读辅助数字
- 添加账号是两步向导:第一步选类型、名称和可选高级选项,第二步再选登录方式。不要把登录方式再摊回第一步。类型 ≤ 6 用两列 tile,超过则用下拉;未知 provider 直接用后端 descriptor 的 label,不要静默丢掉。登录方式按 `capabilities` 隐藏,只有一种时不要再渲染选择器。
- 账号卡片保持操作台密度:单行身份(名称 + provider/UID,不展示认证方式),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;名称、并发和优先级通过卡片编辑弹窗修改,重启次数仍是只读辅助数字。控制台刷新时卡片保持挂载,让额度和状态柱用 GSAP 过渡,不要整卡换成骨架屏。色条只用很浅的同色渐变,不要换新强调色。
- 添加账号是两步向导:第一步选类型、名称和可选高级选项,第二步再选登录方式。不要把登录方式再摊回第一步。打开弹窗时类型列表先显示骨架屏,等 `/api/providers` 返回后再渲染真实选项,不要先闪默认 Qoder Global。类型 ≤ 6 用两列 tile,超过则用下拉;未知 provider 直接用后端 descriptor 的 label,不要静默丢掉。登录方式按 `capabilities` 隐藏,只有一种时不要再渲染选择器。
- 账号网格用 `lg:grid-cols-2 xl:grid-cols-3`,卡片间距 `gap-2.5`,内边距约 12px
- 主卡片只有在确实需要抬升层级时才用 `shadow-card`
- 边框克制使用;避免边框 + 阴影 + 有色背景同时出现
Expand Down
5 changes: 3 additions & 2 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ WorkBuddy2API 把 **WorkBuddy CN / CodeBuddy**(`copilot.tencent.com` / `www.co
| 凭证 | 加密 `.auth/user` blob + `machine_id` | `accessToken` / `refreshToken` / uid / domain |
| 登录 | 浏览器 device-flow / PAT / `qoder-native-v1` 导入 | `POST /v2/plugin/auth/state`,无 PKCE,服务端发 state |
| 对话入口 | worker 内 `prepareInferRequest` → `agent_chat_generation?Encode=1` | `POST {chatBase}/v2/chat/completions` |
| 模型目录 | 登录后的 Qoder in-process catalog,失败即空 | `GET /console/enterprises/personal/models`,失败回退静态表 |
| 模型目录 | 登录后的 Qoder in-process catalog,失败即空 | CN `GET /console/enterprises/personal/models`;Global `GET /v2/enterprises/personal/models`。参考仓库失败回退静态表;本仓库失败即报错 |
| 账号存储 | SQLite + 派生 runtime HOME | `auths/workbuddy-<uid>.json` + `state.json` |
| 调度 | Go round-robin + pin + inflight + 错误分类 | 剩余积分最高者优先 |
| 产品附加 | 控制台、托管更新、Anthropic 协议规划 | 每日签到、积分查询、token keepalive |
Expand Down Expand Up @@ -91,13 +91,14 @@ WorkBuddy2API 把 **WorkBuddy CN / CodeBuddy**(`copilot.tencent.com` / `www.co
- URL:CN `https://copilot.tencent.com/v2/chat/completions`;global `https://www.workbuddy.ai/v2/chat/completions`
- 上游拒绝非流式,发出前强制 `stream: true`;对本仓库客户端的非流式请求,应在适配器内聚合 SSE
- `tool_choice` 必须是 string。对象形式会 `400 code=11101`
- 国际版要求第一条必须是 `system`(`code=11128 first message is not system prompt`)。账号打开「丢弃系统提示词」时,只剥调用方内容,发出前补一条空 `system`,不要让 user 排到第一位
- 关键头:`Authorization`、`X-User-Id`、`X-Enterprise-Id` 或 `X-No-*`、`X-Product: SaaS`、`X-Domain`、UA `CLI/2.139.0 CodeBuddy/2.139.0`
- Chat 另加官方 CLI 通道头(CN/Global 同名,Origin/host 仍按区域分开):`X-IDE-Type/Name/Version`、`X-Agent-Type/Intent`、`X-Request-ID` / conversation/session IDs、`X-Product-Version`、`X-Private-Data: false`。Refresh 不带这些头,也绝不带 `X-API-Key`
- SSE 已是 OpenAI chunk 形态,含 `delta.content`、`delta.reasoning_content`、按 index 合并的 `tool_calls`

### 模型

`GET {chatBase}/console/enterprises/personal/models`
CN:`GET {chatBase}/console/enterprises/personal/models`。国际站这条 console 路径是 OIDC 页面(未登录 302 到 Keycloak,带 Bearer 500 HTML),必须改打 `GET https://www.workbuddy.ai/v2/enterprises/personal/models`。`GET /api/models` 目录失败用 503,不要 502,避免反代把 JSON 换成 HTML 错误页。

只暴露 CLI agent 的模型 ID(`cli` / `CLI` / `codebuddy` / `workbuddy`),再和 `data.models` 交叉,跳过 `disabled`。上游没有 agents 时,退回全部未禁用模型。字段用 `maxInputTokens` / `maxOutputTokens`,不是 `contextWindow`。按账号拉目录失败时,`GET /api/models?account=` 返回明确错误,不把空列表当成「没有模型」。

Expand Down
99 changes: 65 additions & 34 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Button, Description, Input, Label, ListBox, Modal, Select, TextArea } from '@heroui/react'
import { Button, Description, Input, Label, ListBox, Modal, Select, Skeleton, TextArea } from '@heroui/react'
import { ArrowSquareOut, CaretLeft, CaretRight, CheckCircle, FileCode, Key, ShieldCheck, SpinnerGap, X } from '@phosphor-icons/react'
import { ProviderMark } from '@/components/ProviderMark'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
Expand Down Expand Up @@ -54,16 +54,21 @@ const hintKeys: Record<string, string> = {
'workbuddy-global': 'accountTypeWorkBuddyGlobalHint',
}

const fallbackProviderOptions: ProviderOption[] = [
{
id: 'qoder-global', provider: 'qoder', region: 'global',
descriptor: {
id: 'qoder', label: 'Qoder', runtime: 'child_process', default_region: 'global',
capabilities: { browser_login: true, pat_login: true, import_export: true },
regions: [{ id: 'global', label: 'Global' }],
},
},
]
function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" aria-busy="true" aria-label={ariaLabel}>
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="flex items-start gap-2.5 rounded-lg border border-[var(--app-line)] px-3.5 py-3">
<Skeleton className="mt-0.5 size-[18px] shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-4 w-28 rounded-md" />
<Skeleton className="h-3 w-full rounded-md" />
</div>
</div>
))}
</div>
)
}

async function loadProviderOptions(): Promise<ProviderOption[]> {
const output = await fetchProviders().catch(() => null)
Expand All @@ -74,7 +79,7 @@ async function loadProviderOptions(): Promise<ProviderOption[]> {
options.push({ id: `${descriptor.id}-${region.id}`, provider: descriptor.id, region: region.id, descriptor })
}
}
return options.length ? options : fallbackProviderOptions
return options
}

function optionLabel(option: ProviderOption, t: (key: string) => string) {
Expand All @@ -88,7 +93,8 @@ function optionLabel(option: ProviderOption, t: (key: string) => string) {
return `${option.descriptor.label}${suffix}`.trim()
}

function optionHint(option: ProviderOption, t: (key: string) => string) {
function optionHint(option: ProviderOption | undefined, t: (key: string) => string) {
if (!option) return ''
const key = hintKeys[option.id]
if (!key) return ''
const localized = t(key)
Expand All @@ -111,8 +117,9 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const { t } = useI18n()
const [step, setStep] = useState<Step>('method')
const [tab, setTab] = useState<TabKey>('browser')
const [accountType, setAccountType] = useState<string>('qoder-global')
const [providerOptions, setProviderOptions] = useState<ProviderOption[]>(fallbackProviderOptions)
const [accountType, setAccountType] = useState('')
const [providerOptions, setProviderOptions] = useState<ProviderOption[]>([])
const [typesLoading, setTypesLoading] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [name, setName] = useState('')
const [maxInFlight, setMaxInFlight] = useState('4')
Expand Down Expand Up @@ -140,20 +147,30 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {

useEffect(() => {
if (!isOpen) return
void loadProviderOptions().then((options) => {
setProviderOptions(options)
if (!options.some((option) => option.id === accountType)) {
setAccountType(options[0]?.id || 'qoder-global')
let cancelled = false
setTypesLoading(true)
setProviderOptions([])
setAccountType('')
void loadProviderOptions()
.then((options) => {
if (cancelled) return
setProviderOptions(options)
setAccountType(options[0]?.id || '')
setTab('browser')
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
})
.finally(() => {
if (!cancelled) setTypesLoading(false)
})
return () => {
cancelled = true
}
}, [isOpen])

const activeOption = providerOptions.find((option) => option.id === accountType) || fallbackProviderOptions[0]
const showPatTab = activeOption.descriptor.capabilities?.pat_login !== false
const showImportTab = activeOption.descriptor.capabilities?.import_export !== false
const showDropSystem = activeOption.provider === 'workbuddy'
const activeOption = providerOptions.find((option) => option.id === accountType)
const typesReady = Boolean(activeOption) && !typesLoading
const showPatTab = activeOption?.descriptor.capabilities?.pat_login !== false
const showImportTab = activeOption?.descriptor.capabilities?.import_export !== false
const showDropSystem = activeOption?.provider === 'workbuddy'
const busy = phase === 'busy' || phase === 'polling'
const settingsLocked = Boolean(createdId.current) || busy
const isDone = phase === 'done'
Expand Down Expand Up @@ -188,7 +205,9 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
stopPolling()
setStep('method')
setTab('browser')
setAccountType('qoder-global')
setAccountType('')
setProviderOptions([])
setTypesLoading(false)
setName('')
setMaxInFlight('4')
setPriority('50')
Expand All @@ -209,14 +228,15 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}

function goLogin(next: TabKey) {
if (busy) return
if (busy || !typesReady) return
setTab(next)
setMessage('')
setStep('login')
}

async function ensureAccount(): Promise<string> {
if (createdId.current) return createdId.current
if (!activeOption) throw new Error(t('accountTypeHint'))
const options = accountOptions()
const account = await createAccount(name.trim() || t('account'), activeOption.provider, activeOption.region, options)
const id = account?.id || account?.data?.id
Expand Down Expand Up @@ -290,6 +310,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setMessage(t('wizardBadJson'))
return
}
if (!activeOption) { setMessage(t('accountTypeHint')); return }
if (activeOption.provider === 'qoder' && (typeof bundle.user_blob !== 'string' || typeof bundle.machine_id !== 'string')) {
setMessage(t('wizardBadJson'))
return
Expand Down Expand Up @@ -350,7 +371,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const tabLead = tab === 'browser'
? t('wizardBrowserLead')
: tab === 'pat'
? t(activeOption.region === 'cn' && activeOption.provider === 'qoder' ? 'wizardPatLeadCN' : 'wizardPatLead')
? t(activeOption?.region === 'cn' && activeOption?.provider === 'qoder' ? 'wizardPatLeadCN' : 'wizardPatLead')
: t('wizardImportLead')

return (
Expand All @@ -371,7 +392,17 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
{step === 'method' ? (
<>
<section className="space-y-2.5">
{useTypeSelect ? (
{typesLoading ? (
<>
<span className="text-sm font-medium text-[var(--app-muted)]">{t('accountType')}</span>
<AccountTypeSkeleton ariaLabel={t('accountType')} />
</>
) : !typesReady ? (
<>
<span className="text-sm font-medium text-[var(--app-muted)]">{t('accountType')}</span>
<p className="rounded-lg border border-[var(--app-line)] bg-[var(--app-surface-muted)]/45 px-3.5 py-3 text-xs leading-5 text-[var(--app-faint)]">{t('accountTypeHint')}</p>
</>
) : useTypeSelect ? (
<Select
fullWidth
aria-label={t('accountType')}
Expand Down Expand Up @@ -424,7 +455,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
/>
</>
)}
{hint ? <p className="min-h-5 text-xs leading-5 text-[var(--app-faint)]">{hint}</p> : null}
{typesReady && hint ? <p className="min-h-5 text-xs leading-5 text-[var(--app-faint)]">{hint}</p> : null}
</section>

<section className="mt-5 space-y-2.5">
Expand Down Expand Up @@ -493,17 +524,17 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
</section>

<div className="mt-5">
<Button className="w-full" onPress={() => goLogin(tab)}>
<Button className="w-full" isDisabled={!typesReady} onPress={() => goLogin(tab)}>
{t('wizardContinue')}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3 rounded-lg border border-[var(--app-line)] bg-[var(--app-surface-muted)]/45 px-3 py-2.5">
<span className="shrink-0"><ProviderMark provider={activeOption.provider} size={18} /></span>
<span className="shrink-0"><ProviderMark provider={activeOption?.provider} size={18} /></span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--app-ink)]">{optionLabel(activeOption, t)}</div>
<div className="truncate text-sm font-medium text-[var(--app-ink)]">{activeOption ? optionLabel(activeOption, t) : t('accountType')}</div>
<div className="truncate text-[11px] text-[var(--app-faint)]">{name.trim() || t('account')}</div>
</div>
<Button size="sm" variant="ghost" onPress={() => setStep('method')} isDisabled={busy || Boolean(createdId.current)}>
Expand Down
Loading
Loading