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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Do not add new `TODO.md`, `NOTES.md`, or extra plan files. Extra design docs are
- Keep architecture: auth / endpoint / executor / translate / api
- Prefer direct HTTP/SSE to Qoder cloud APIs
- Pin qodercli / qoderclicn hooks in `worker/src/compat.mjs`; fail loudly on mismatch. Qoder CN is `provider=qoder` + `region=cn`, not a new family
- Reasoning levels are catalog-driven: map client values through `internal/providers/reasoning.go` (`none`/`low`/`medium`/`high`/`xhigh`/`max`), clamp anything the model does not allow back to an allowed level, and treat the console value as a default only (it never locks a call or caps a higher client value)
- Console UI: React + Tailwind v4 + **HeroUI only** for components
- Follow `docs/DESIGN.md` (taste v1 adapted for this console)
- Keep iterating Qoder login, usage, and account routing. Borrow scheduling ideas from [sub2api](https://github.com/Wei-Shaw/sub2api), not its commercial gateway
Expand All @@ -37,4 +38,5 @@ Do not add new `TODO.md`, `NOTES.md`, or extra plan files. Extra design docs are
- Copy sub2api billing, Redis slots, multi-tenant API keys, or session-hash-for-profit
- Add a new component library, purple AI chrome, centered generic login cards, or emoji in UI copy
- Start Cursor / Anthropic until the current Qoder milestone in `docs/PLAN.md` is done. Qoder CN is that milestone (`provider=qoder` + `region=cn`); do not spawn a full `qoderclicn` per request
- Invent reasoning levels a model does not declare. Catalog effort wins: keep `onlyReasoning` models locked (DeepSeek is `high`), and do not give WorkBuddy a Trae-style Max switch or send a context-window switch on chat
- Change the SQL bytes of a shipped SQLite migration in `internal/accounts/migrations.go`. Tabs, spaces, and comments inside the raw string count. `gofmt` on the Go around it is fine; indenting the SQL is not. Existing databases panic on boot with `checksum mismatch`
19 changes: 10 additions & 9 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,27 @@ type ModelsMemoryEntry = {
const modelsMemoryTTL = 30_000
const modelsMemoryCache = new Map<string, ModelsMemoryEntry>()

function modelsMemoryKey(accountId?: string) {
return accountId || '*'
function modelsMemoryKey(accountId?: string, view?: 'regional') {
return `${accountId || '*'}@${view || 'merged'}`
}

export function fetchModels(accountId?: string, refresh = false) {
export function fetchModels(accountId?: string, refresh = false, view?: 'regional') {
const q = new URLSearchParams()
if (refresh) q.set('refresh', '1')
if (accountId) q.set('account', accountId)
if (view) q.set('view', view)
const query = q.toString()
return api<ModelsResponse>(`/api/models${query ? `?${query}` : ''}`)
}

export function fetchModelsCached(accountId?: string) {
const key = modelsMemoryKey(accountId)
export function fetchModelsCached(accountId?: string, view?: 'regional') {
const key = modelsMemoryKey(accountId, view)
const cached = modelsMemoryCache.get(key)
if (cached && Date.now() - cached.at < modelsMemoryTTL) {
return Promise.resolve(cached.data)
}
if (cached?.pending) return cached.pending
const pending = fetchModels(accountId).then((data) => {
const pending = fetchModels(accountId, false, view).then((data) => {
modelsMemoryCache.set(key, { data, at: Date.now() })
return data
}).finally(() => {
Expand All @@ -88,10 +89,10 @@ export function fetchModelsCached(accountId?: string) {
return pending
}

export function refreshModels(accountId?: string) {
const key = modelsMemoryKey(accountId)
export function refreshModels(accountId?: string, view?: 'regional') {
const key = modelsMemoryKey(accountId, view)
modelsMemoryCache.delete(key)
return fetchModels(accountId, true).then((data) => {
return fetchModels(accountId, true, view).then((data) => {
modelsMemoryCache.set(key, { data, at: Date.now() })
return data
})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export type ModelInfo = {
provider?: string
owned_by?: string
native_model?: string
region?: string
regions?: string[]
stale?: boolean
credits?: string
free?: boolean
context_length?: number
default_context_length?: number
context_custom?: boolean
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/components/ModelDetailsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Chip, Modal } from '@heroui/react'
import { X } from '@phosphor-icons/react'
import type { ModelInfo } from '@/api/types'
import { modelCreditsText, modelIsFree } from '@/lib/format'
import { accountProviderLabel } from '@/lib/provider'

type Translate = (key: string, vars?: Record<string, string | number>) => string

Expand All @@ -22,15 +24,26 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
const options = model.reasoning_options || []
const windowDev = model.catalog_context_length || model.default_context_length || model.context_length
const windowMax = model.catalog_context_length_max
const credits = modelCreditsText(model)
const free = modelIsFree(model)
const provider = String(model.provider || model.owned_by || 'qoder').trim().toLowerCase()
const region = String(model.region || model.regions?.[0] || '').trim().toLowerCase()
const providerLabel = accountProviderLabel(provider, region || undefined, t)
return (
<Modal.Root isOpen onOpenChange={(next: boolean) => { if (!next) onClose() }}>
<Modal.Backdrop variant="blur">
<Modal.Container size="md" scroll="inside">
<Modal.Dialog>
<Modal.Header className="items-start justify-between gap-4 px-5 pt-5">
<div>
<div className="text-base font-semibold tracking-[-0.015em]">{model.display_name || model.id}</div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-base font-semibold tracking-[-0.015em]">{model.display_name || model.id}</div>
{free ? <Chip size="sm" variant="soft" color="success">{t('modelFree')}</Chip> : null}
</div>
<div className="mono mt-1 text-[11px] text-muted">{model.id}</div>
<div className="mt-1 text-[11px] text-muted">
{providerLabel}{credits ? ` · ${credits}` : ''}
</div>
</div>
<Modal.CloseTrigger aria-label={t('close')} className="grid size-8 place-items-center rounded-lg text-muted hover:bg-surface-secondary">
<X size={16} />
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/account/AccountModelsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EmptyPanel } from '@/components/ui/EmptyPanel'
import { SkeletonBlock } from '@/components/ui/PageSkeletons'
import { PageAlert } from '@/components/ui/PageAlert'
import type { AccountRow } from '@/lib/account'
import { modelCreditsText, modelIsFree } from '@/lib/format'
import { accountProviderLabel } from '@/lib/provider'

type Translate = (key: string, vars?: Record<string, string | number>) => string
Expand Down Expand Up @@ -113,12 +114,16 @@ export function AccountModelsModal({ account, t, onClose }: Props) {
{models.map((model) => {
const ownedBy = model.provider || model.owned_by || account?.provider || 'qoder'
const routed = routedModelName(model)
const credits = modelCreditsText(model)
const free = modelIsFree(model)
return (
<li key={model.id} className="flex items-start gap-3 px-3 py-2.5">
<span className="status-dot mt-1.5" data-state={model.stale ? undefined : 'ok'} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{model.display_name || model.id}</span>
{free ? <Chip size="sm" variant="soft" color="success">{t('modelFree')}</Chip> : null}
{credits ? <span className="mono text-[11px] text-muted">{credits}</span> : null}
{model.stale ? <Chip size="sm" variant="soft" color="warning">{t('fallback')}</Chip> : null}
</div>
<div className="mono mt-0.5 truncate text-[11px] text-muted">{model.id}</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export const messages: Record<Lang, Dict> = {
defaultValue: 'Default',
resetDefault: 'Restore default',
fallback: 'fallback',
modelFree: '(free)',
shownTotal: '{shown} shown / {total} total',
noModelsYet: 'No models yet',
noModelsMatch: 'No models match this filter.',
Expand Down Expand Up @@ -1065,6 +1066,7 @@ export const messages: Record<Lang, Dict> = {
defaultValue: '默认',
resetDefault: '恢复默认',
fallback: '回退',
modelFree: '免费',
shownTotal: '显示 {shown} / 共 {total}',
noModelsYet: '暂无模型',
noModelsMatch: '没有匹配的模型。',
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ export function formatCountKind(value: number, kind: 'int' | 'compact' | 'percen
return String(Math.round(value))
}

/** Official WorkBuddy catalog credits text, when present. */
export function modelCreditsText(model: { credits?: string | null }) {
const credits = (model.credits || '').trim()
return credits || ''
}

export function modelIsFree(model: { free?: boolean | null; credits?: string | null }) {
if (model.free) return true
const credits = modelCreditsText(model).toLowerCase()
if (!credits) return false
const match = credits.match(/(\d+(?:\.\d+)?)/)
return Boolean(match && Number(match[1]) === 0)
}

function trimFixed(value: number) {
const digits = Math.abs(value) >= 10 ? 0 : 1
return Number(value.toFixed(digits)).toString()
Expand Down
20 changes: 14 additions & 6 deletions frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useOverview } from '@/hooks/useOverview'
import { fetchAccounts, fetchModels, testChat } from '@/api/overview'
import type { ModelInfo, Overview } from '@/api/types'
import { absUrl } from '@/lib/url'
import { modelCreditsText, modelIsFree } from '@/lib/format'
import { EmptyPanel } from '@/components/ui/EmptyPanel'
import { PageAlert } from '@/components/ui/PageAlert'
import { AccessPageSkeleton } from '@/components/ui/PageSkeletons'
Expand Down Expand Up @@ -302,12 +303,19 @@ export function AccessPage() {
value={selectedModel}
onChange={setModel}
placeholder={t('model')}
options={models.map((item) => ({
id: item.id,
textValue: `${item.display_name || item.id} ${item.id} ${item.owned_by || item.provider || ''}`,
label: item.display_name || item.id,
hint: item.provider || item.owned_by ? `${item.id} · ${item.provider || item.owned_by}` : item.id,
}))}
options={models.map((item) => {
const credits = modelCreditsText(item)
const free = modelIsFree(item)
const title = item.display_name || item.id
const badge = free ? t('modelFree') : credits
const provider = item.provider || item.owned_by || ''
return {
id: item.id,
textValue: `${title} ${item.id} ${provider} ${credits} ${free ? 'free' : ''}`,
label: badge ? `${title} ${badge}` : title,
hint: [item.id, provider, badge].filter(Boolean).join(' · '),
}
})}
/>
) : (
<div className="flex flex-col gap-1">
Expand Down
Loading
Loading