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
7 changes: 7 additions & 0 deletions changelog/unreleased/account-model-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Console: the edit-account modal is wider, and its model restriction picker now has search, a selected/total count, select all and clear for the filtered list, one-line rows, and marks saved models that left the catalog as offline with a remove action.

### 中文

- 控制台:编辑账号弹窗加宽;可用模型选择支持搜索、显示「已选 / 共」、对筛选结果全选和清空,每个模型单行显示,已不在目录中的已选模型标记为「已下线」并可移除。
52 changes: 28 additions & 24 deletions frontend/src/components/account/EditAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'
import { Alert, Button, Checkbox, Chip, Form, Input, Modal, NumberField } from '@heroui/react'
import { Alert, Button, Chip, Form, Input, Modal, NumberField } from '@heroui/react'
import { X } from '@phosphor-icons/react'
import { ProviderMark } from '@/components/ProviderMark'
import { FormRow } from '@/components/ui/FormRow'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
import { ModelPicker } from '@/components/account/ModelPicker'
import { useCollapse } from '@/hooks/useCollapse'
import { fetchModels } from '@/api/overview'
import type { ModelInfo } from '@/api/types'
import type { AccountRow } from '@/lib/account'
Expand Down Expand Up @@ -33,7 +35,8 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
const [aliasDraft, setAliasDraft] = useState({ alias: '', model: '' })
const [restrictModels, setRestrictModels] = useState(Boolean(account?.models_restricted))
const [enabledModels, setEnabledModels] = useState<string[]>(account?.enabled_models || [])
const [catalog, setCatalog] = useState<ModelInfo[]>([])
const { mounted: pickerMounted, outerRef: pickerOuterRef, innerRef: pickerInnerRef } = useCollapse(restrictModels)
const [catalog, setCatalog] = useState<ModelInfo[] | null>(null)
const [inheritCheckinTime, setInheritCheckinTime] = useState(!account?.checkin_time)
const [checkinTime, setCheckinTime] = useState<string | null>(account?.checkin_time || null)
const [error, setError] = useState('')
Expand Down Expand Up @@ -93,7 +96,7 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
<Modal.Root isOpen={Boolean(account)} onOpenChange={(next: boolean) => { if (!next && !busy) onClose() }}>
<Modal.Backdrop variant="blur">
<Modal.Container placement="center" size="lg" scroll="inside">
<Modal.Dialog className="sm:min-w-[32rem]">
<Modal.Dialog className="w-full sm:w-[42rem] sm:max-w-[calc(100vw-2rem)]">
<Modal.Header className="items-start justify-between gap-4 px-6 pt-6">
<div className="min-w-0">
<Modal.Heading className="text-section font-semibold">{title}</Modal.Heading>
Expand Down Expand Up @@ -185,7 +188,7 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
/>
</FormRow>
) : null}
<FormRow label={t('accountModelAliases')} hint={t('accountModelAliasesHint')}>
<FormRow align="start" label={t('accountModelAliases')} hint={t('accountModelAliasesHint')}>
<div className="space-y-2">
{aliases.map((item) => (
<div key={item.alias} className="flex items-center justify-between gap-2">
Expand All @@ -203,27 +206,28 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
</div>
</div>
</FormRow>
<FormRow label={t('enabledModels')} hint={t('enabledModelsHint')}>
<div className="space-y-2">
<CompactSwitch isSelected={restrictModels} isDisabled={busy} ariaLabel={t('enabledModels')} label={t('restrictModels')} onChange={setRestrictModels} />
{restrictModels ? (
<div className="max-h-40 space-y-1 overflow-auto">
{catalog.map((model) => (
<Checkbox
key={model.id}
isSelected={enabledModels.includes(model.id)}
isDisabled={busy}
onChange={(selected) => setEnabledModels((current) => selected ? [...current, model.id] : current.filter((id) => id !== model.id))}
>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
<Checkbox.Content><span className="mono text-body">{model.id}</span></Checkbox.Content>
</Checkbox>
))}
{catalog.length === 0 ? <p className="text-xs text-muted">{t('noAccountModels')}</p> : null}
</div>
) : null}
<section className="space-y-3 pt-1">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-body font-medium text-foreground">{t('enabledModels')}</div>
<p className="mt-0.5 text-xs leading-5 text-muted">{t('enabledModelsHint')}</p>
</div>
<CompactSwitch isSelected={restrictModels} isDisabled={busy} ariaLabel={t('restrictModels')} label={restrictModels ? t('restrictModels') : t('allModels')} onChange={setRestrictModels} />
</div>
</FormRow>
{pickerMounted ? (
<div ref={pickerOuterRef}>
<div ref={pickerInnerRef}>
<ModelPicker
catalog={catalog ? catalog.map((model) => model.id) : null}
selected={enabledModels}
onChange={setEnabledModels}
isDisabled={busy}
t={t}
/>
</div>
</div>
) : null}
</section>
{checkinDefaultTime !== undefined ? (
<FormRow label={t('autoCheckinTime')} hint={t('checkinScheduleHint')}>
<div className="space-y-3">
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/components/account/ModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useMemo, useState } from 'react'
import { Button, Checkbox, SearchField } from '@heroui/react'
import { CheckSquare, Square, X } from '@phosphor-icons/react'

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

type Props = {
/** Model IDs in the account catalog; null while loading. */
catalog: string[] | null
selected: string[]
onChange: (next: string[]) => void
isDisabled?: boolean
t: Translate
}

/**
* Searchable multi-select for an account's enabled models. Selected models are
* listed first, but the order is only recomputed when the catalog loads or the
* search changes, so rows never jump while being clicked.
*/
export function ModelPicker({ catalog, selected, onChange, isDisabled, t }: Props) {
const [query, setQuery] = useState('')
const [snapshot, setSnapshot] = useState<string[]>(selected)
const selectedSet = useMemo(() => new Set(selected), [selected])

const visible = useMemo(() => {
const pinned = new Set(snapshot)
const q = query.trim().toLowerCase()
const items = (catalog || []).filter((id) => !q || id.toLowerCase().includes(q))
return [...items.filter((id) => pinned.has(id)), ...items.filter((id) => !pinned.has(id))]
}, [catalog, query, snapshot])

const offline = useMemo(() => {
if (!catalog) return []
const known = new Set(catalog)
return selected.filter((id) => !known.has(id))
}, [catalog, selected])

const total = catalog?.length ?? 0
const selectedInCatalog = selected.filter((id) => !offline.includes(id)).length

function updateQuery(next: string) {
setQuery(next)
setSnapshot(selected)
}
function toggle(id: string, on: boolean) {
onChange(on ? [...selected, id] : selected.filter((item) => item !== id))
}
function selectAllVisible() {
const next = new Set(selected)
visible.forEach((id) => next.add(id))
onChange([...next])
}
function clearVisible() {
const hide = new Set(visible)
onChange(selected.filter((id) => !hide.has(id)))
}

return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<SearchField aria-label={t('modelPickerSearch')} value={query} onChange={updateQuery} isDisabled={isDisabled} className="min-w-0 flex-1 basis-48">
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder={t('modelPickerSearch')} />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<span className="mono shrink-0 text-xs text-muted">{t('modelPickerCount', { selected: selectedInCatalog, total })}</span>
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="ghost" isDisabled={isDisabled || visible.length === 0} onPress={selectAllVisible}>
<CheckSquare size={14} />{t('modelPickerSelectAll')}
</Button>
<Button size="sm" variant="ghost" isDisabled={isDisabled || selected.length === 0} onPress={clearVisible}>
<Square size={14} />{t('modelPickerClear')}
</Button>
</div>
</div>

<div className="max-h-[300px] overflow-y-auto overscroll-contain rounded-lg bg-surface-secondary p-1">
{catalog === null ? (
<p className="px-2.5 py-2 text-xs text-muted">{t('refreshing')}</p>
) : null}
{offline.map((id) => (
<div key={`offline-${id}`} className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5">
<span className="mono min-w-0 flex-1 truncate text-body text-muted line-through">{id}</span>
<span className="shrink-0 text-caption text-warning">{t('modelPickerOffline')}</span>
<Button isIconOnly size="sm" variant="ghost" isDisabled={isDisabled} aria-label={t('modelPickerRemove', { model: id })} onPress={() => toggle(id, false)}>
<X size={14} />
</Button>
</div>
))}
{visible.map((id) => (
<Checkbox
key={id}
isSelected={selectedSet.has(id)}
isDisabled={isDisabled}
onChange={(on) => toggle(id, on)}
className="flex w-full cursor-pointer rounded-md px-2.5 py-1.5 transition-colors hover:bg-default"
>
<Checkbox.Content className="flex min-w-0 flex-1 items-center gap-2.5">
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
<span className="mono min-w-0 truncate text-body text-foreground">{id}</span>
</Checkbox.Content>
</Checkbox>
))}
{catalog !== null && visible.length === 0 ? (
<p className="px-2.5 py-2 text-xs text-muted">{total === 0 ? t('noAccountModels') : t('modelPickerNoMatch')}</p>
) : null}
</div>
</div>
)
}
8 changes: 5 additions & 3 deletions frontend/src/components/ui/FormRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ type Props = {
/** Label column width. Defaults to 7rem, wide enough for CJK labels + trailing colon. */
labelWidthClass?: string
htmlFor?: string
/** 'start' top-aligns the label for tall, multi-line controls. */
align?: 'center' | 'start'
children: ReactNode
}

Expand All @@ -18,11 +20,11 @@ const defaultLabelWidthClass = 'w-28'
* the rest. Keeps every form in the console (account create/edit, system
* settings, key create) on the same alignment grid.
*/
export function FormRow({ label, hint, labelWidthClass = defaultLabelWidthClass, htmlFor, children }: Props) {
export function FormRow({ label, hint, labelWidthClass = defaultLabelWidthClass, htmlFor, align = 'center', children }: Props) {
return (
<div>
<div className="flex items-center gap-3">
<Label htmlFor={htmlFor} className={`${labelWidthClass} shrink-0 text-body font-medium text-foreground`}>{label}</Label>
<div className={`flex gap-3 ${align === 'start' ? 'items-start' : 'items-center'}`}>
<Label htmlFor={htmlFor} className={`${labelWidthClass} shrink-0 ${align === 'start' ? 'pt-2' : ''} text-body font-medium text-foreground`}>{label}</Label>
<div className="min-w-0 flex-1 [&_[data-slot=input]]:w-full [&_[data-slot=textarea]]:w-full">{children}</div>
</div>
{hint ? <p className="mt-1.5 pl-[7.75rem] text-xs leading-5 text-muted">{hint}</p> : null}
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/hooks/useCollapse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { duration, ease, gsap, motionDuration } from '@/lib/motion'

/**
* Height expand/collapse for a section that mounts on `open` and unmounts
* after collapsing. Only the wrapper height and the inner content's
* opacity/y are tweened; children are never animated individually. Height is
* cleared to `auto` when expanded so later content changes reflow naturally.
*/
export function useCollapse<T extends HTMLElement = HTMLDivElement, I extends HTMLElement = HTMLDivElement>(open: boolean) {
const [mounted, setMounted] = useState(open)
if (open && !mounted) setMounted(true)
const outerRef = useRef<T>(null)
const innerRef = useRef<I>(null)
const skipFirst = useRef(open)

useLayoutEffect(() => {
const outer = outerRef.current
const inner = innerRef.current
if (!outer || !inner) return
if (skipFirst.current) {
// Already open when the modal mounted: no entrance animation.
skipFirst.current = false
return
}
gsap.killTweensOf([outer, inner])
const from = outer.getBoundingClientRect().height
if (open) {
gsap.set(outer, { overflow: 'hidden' })
const to = outer.scrollHeight
gsap.fromTo(outer, { height: from === to ? 0 : from }, {
height: to,
duration: motionDuration(duration.base),
ease: ease.out,
onComplete: () => gsap.set(outer, { clearProps: 'height,overflow' }),
})
gsap.fromTo(inner, { autoAlpha: 0, y: -6 }, { autoAlpha: 1, y: 0, duration: motionDuration(duration.base), ease: ease.out })
} else {
gsap.set(outer, { overflow: 'hidden', height: from })
gsap.to(inner, { autoAlpha: 0, y: -4, duration: motionDuration(duration.fast), ease: ease.inOut })
gsap.to(outer, {
height: 0,
duration: motionDuration(duration.fast + 0.04),
ease: ease.inOut,
onComplete: () => setMounted(false),
})
}
return () => { gsap.killTweensOf([outer, inner]) }
}, [open, mounted])

return { mounted, outerRef, innerRef }
}
18 changes: 17 additions & 1 deletion frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,14 @@ export const messages: Record<Lang, Dict> = {
enabledModels: 'Models',
enabledModelsHint: 'Leave unrestricted to serve the whole catalog. Restrict to choose which models this account may serve.',
restrictModels: 'Only selected models',
allModels: 'All models',
modelPickerSearch: 'Search models',
modelPickerCount: '{selected} / {total} selected',
modelPickerSelectAll: 'Select all',
modelPickerClear: 'Clear',
modelPickerOffline: 'Offline',
modelPickerRemove: 'Remove {model}',
modelPickerNoMatch: 'No matching models.',
maxInflightInvalid: 'Concurrency must be an integer from 1 to 32.',
priorityInvalid: 'Priority must be an integer from 1 to 100.',
wizardNamePh: 'Account name',
Expand Down Expand Up @@ -1338,7 +1346,15 @@ export const messages: Record<Lang, Dict> = {
modelAliasPlaceholder: 'kimi-k3',
enabledModels: '可用模型',
enabledModelsHint: '不限制时使用整个目录。限制后,这个账号只接选中的模型。',
restrictModels: '只使用选中的模型',
restrictModels: '仅选中',
allModels: '全部模型',
modelPickerSearch: '搜索模型',
modelPickerCount: '已选 {selected} / 共 {total}',
modelPickerSelectAll: '全选',
modelPickerClear: '清空',
modelPickerOffline: '已下线',
modelPickerRemove: '移除 {model}',
modelPickerNoMatch: '没有匹配的模型。',
maxInflightInvalid: '并发必须是 1 到 32 之间的整数。',
priorityInvalid: '优先级必须是 1 到 100 之间的整数。',
wizardNamePh: '账号名称',
Expand Down

Large diffs are not rendered by default.

28 changes: 0 additions & 28 deletions internal/webui/static/assets/index-3YaYZVo0.js

This file was deleted.

28 changes: 28 additions & 0 deletions internal/webui/static/assets/index-BUhcZKuq.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
<meta name="twitter:title" content="CLI2API" />
<meta name="twitter:description" content="Qoder CLI → OpenAI-compatible API. Self-hosted gateway." />
<meta name="twitter:image" content="/og-card.svg" />
<script type="module" crossorigin src="/assets/index-3YaYZVo0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ca-WI-xc.css">
<script type="module" crossorigin src="/assets/index-BUhcZKuq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-G2li8oay.css">
</head>
<body>
<div id="root"></div>
Expand Down
Loading