feat(web): migrate launcher to modular web frontend/backend and improve management UX (#1275)

* refactor: remove the legacy picoclaw-launcher

* feat: create initial web frontend and backend structure

* feat(packaging): add desktop entry for PicoClaw Launcher (#1062)

- Add .desktop file with Terminal=true, named "PicoClaw Launcher"
- Install to /usr/share/applications/ for app menu visibility
- Add 512x512 PNG icon to /usr/share/icons/hicolor/

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* `make dev`: If you haven't built it before, you need to run `build` first.

* feat(web): comprehensive web UI and backend refactoring
This commit introduces a major overhaul of both the frontend web UI and the Go backend API, transitioning to a highly modular architecture and integrating new core features.
Backend:
- Refactored monolithic API endpoints into domain-specific modules (config, gateway, log, models, pico, session).
- Cleaned up obsolete files (`server.go`, `status.go`, WebSocket handlers) and outdated tests.
- Implemented Gateway process lifecycle management (start/stop/restart) and real-time log streaming.
Frontend:
- Integrated Shadcn UI components to establish a modern, consistent design system.
- Introduced a new application layout featuring a responsive sidebar (`app-sidebar`) and header.
- Implemented internationalization (i18n) with initial support for English and Chinese.
- Restructured API clients, hooks, and Zustand stores into logical domains.
- Added new management pages for Settings, Logs, Models, Providers, and Credentials.
- Upgraded the Pico chat interface with session history management and dynamic model selection.
Build & Config:
- Updated frontend dependencies, Vite configuration, and lockfiles.
- Refined routing setup and overarching application stylesheets.

* feat(web): enhance model management, sorting, and deletion logic
- Implement model sorting in UI (default > configured > unconfigured)
- Prevent deletion of default models in the frontend
- Update backend to clear default settings when a model is deleted
- Add existence validation when setting a default model via API
- Group models in chat UI by type (API Key, OAuth, Local)
- Conditionally display model selector in chat based on configuration status

* refactor(web): refactor chat page into modular components/hooks and update i18n

- split chat route into dedicated chat components (page, composer, empty state, messages, history, model selector)
- extract model/session logic into use-chat-models and use-session-history hooks
- update chat locale keys in en/zh and add empty-state/history-related translations

* refactor(models): refactor models page into modular components and improve UX

- split /models route into dedicated components (page, provider section, card, add/edit sheets, delete dialog)
- add provider grouping/sorting, provider labels/icons, and a no-default hint in the models page
- add "Set as default model" toggle to add/edit flows with safer defaults
- introduce shared form helpers and new UI primitives (field, label, switch)
- update i18n strings (en/zh) for models and gateway header text usage
- apply minor UI polish (models nav icon, separator client directive)

* fix(web): add SPA index fallback for embedded frontend routes

Serve existing static assets as-is, keep /api/* and missing asset paths returning 404, and add tests for SPA fallback behavior on refresh.

* fix(frontend/chat): normalize message timestamp units to prevent invalid far-future dates

* chore: delete TestSPARouteFallsBackToIndex

* feat: update build for web-based launcher (#1186)

- Makefile: add build-launcher target (builds frontend + Go backend)
- GoReleaser: point picoclaw-launcher build to web/backend, add frontend
  build hook, restore winres hook with updated paths
- Restore icon.ico and winres config from main for Windows builds

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(credentials): add multi-provider OAuth credential management

- add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout
- extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests
- implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout
- add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings

* chore: remove placeholder index.html from dist (#1188)

The .gitkeep is sufficient for go:embed to find the dist directory.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): polish model and credential UX; remove Providers nav

- remove the Providers item from sidebar navigation and locale keys
- simplify chat composer by dropping attach/voice action buttons
- support ReactNode titles in credential cards and add provider brand icons
- refine sheet header/footer styling and device-code footer button hierarchy
- disable “Set default” when a model is unconfigured or already default

* feat(web): Update  config page (#1173)

* feat(web): Update  config page

* fix(web): useEffect resets editorValue whenever config changes

* fix(web): react-hooks/set-state-in-effect error & pnpm lint #1173

* feat(web): add channel management page for web console (#1190)

* feat(web): add channel management page for web console

Add a complete channel management UI that allows users to configure
messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly
from the web console instead of manually editing config.json.

Backend: GET/PUT/PATCH API endpoints for listing, updating, and
toggling channels with secret field masking.

Frontend: Channel cards grid with enable/disable toggles, per-channel
configuration sheets with dedicated forms for major platforms and a
generic fallback for others.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web/channels): move channels to own sidebar group and fix sheet padding

- Channels now has its own navigation group instead of being under Services
- Fix edit sheet form content padding (px-1 -> px-4) to match header/footer
- Fix naked return lint error in extractChannelInfo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): harden channel config updates and resolve frontend lint issues

- validate channel PUT/PATCH updates before saving and return structured validation errors
- require `enabled` in toggle requests to avoid silent false defaults
- support editing `allow_origins` in the generic channel form and parse string/array inputs on backend
- replace channel form `any` usage with `ChannelConfig` (`Record<string, unknown>`) and add safe value helpers
- add i18n strings for allow-origins fields and apply related frontend formatting cleanups

* fix(frontend): prevent false "Invalid JSON" errors in config editor

* feat: add startup readiness checks and propagate start availability to UI

- add gateway precondition validation for default model and credentials
- auto-start gateway on backend boot when conditions are met
- include gateway_start_allowed and gateway_start_reason in status updates
- prevent frontend start actions when gateway cannot be started

* feat(web): revamp channel config UX with catalog-based routing

- replace legacy channel management endpoints with a backend channel catalog API
- switch frontend channel updates to PATCH /api/config and per-channel config pages
- add dynamic channel items in the sidebar with support for expand/collapse
- migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow
- improve channel forms with clearer hints, required/error states, and reusable switch cards
- fix Discord mention-only toggle to read/write group_trigger.mention_only

* refactor(frontend): move shared-form to components and unify default-model switch with SwitchCardField

* fix(frontend): improve model form validation and unify secret placeholder handling

- block duplicate model aliases when adding a model (with localized error messages)
- share masked secret placeholder logic across model and channel forms
- refresh gateway state after setting the default model
- apply minor UI cleanup to provider icon rendering

* feat(web): add visual system config and launcher/autostart controls

- add launcher config model and persistence (`launcher-config.json`) for port/public/CIDR settings
- add system APIs for launch-at-login and launcher parameters
- apply CIDR-based access-control middleware to backend HTTP routes
- split config routing into visual config and raw JSON config pages
- add frontend system API client and visual config sections for runtime/devices/launcher
- expand i18n strings (en/zh) for new config UI
- improve sidebar active matching and session ID generation fallback

* refactor(frontend): remove i18n fallback strings and drop providers route

- Replace `t(key, defaultValue)` calls with key-only translations across UI pages
- Clean up locale files by pruning unused keys and adding missing shared keys
- Remove the obsolete `/providers` page and update generated route tree

* fix(backend): correct gateway status detection on Windows

* fix(repo): keep web backend dist placeholder tracked

---------

Co-authored-by: Guoguo <16666742+imguoguo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dihubopen <dihubcn@gmail.com>
Co-authored-by: Dihubopen <130813726+Dihubopen@users.noreply.github.com>
This commit is contained in:
wenjie
2026-03-09 19:42:03 +08:00
committed by GitHub
parent ead22368bd
commit e55b3b7a8d
164 changed files with 24081 additions and 4227 deletions
@@ -0,0 +1,330 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { addModel, setDefaultModel } from "@/api/models"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
AdvancedSection,
Field,
KeyInput,
SwitchCardField,
} from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface AddForm {
modelName: string
model: string
apiBase: string
apiKey: string
proxy: string
authMethod: string
connectMode: string
workspace: string
rpm: string
maxTokensField: string
requestTimeout: string
thinkingLevel: string
}
const EMPTY_ADD_FORM: AddForm = {
modelName: "",
model: "",
apiBase: "",
apiKey: "",
proxy: "",
authMethod: "",
connectMode: "",
workspace: "",
rpm: "",
maxTokensField: "",
requestTimeout: "",
thinkingLevel: "",
}
interface AddModelSheetProps {
open: boolean
onClose: () => void
onSaved: () => void
existingModelNames: string[]
}
export function AddModelSheet({
open,
onClose,
onSaved,
existingModelNames,
}: AddModelSheetProps) {
const { t } = useTranslation()
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false)
const [fieldErrors, setFieldErrors] = useState<
Partial<Record<keyof AddForm, string>>
>({})
const [serverError, setServerError] = useState("")
const apiKeyPlaceholder = maskedSecretPlaceholder(
form.apiKey,
t("models.field.apiKeyPlaceholder"),
)
useEffect(() => {
if (open) {
setForm(EMPTY_ADD_FORM)
setSetAsDefault(false)
setFieldErrors({})
setServerError("")
}
}, [open])
const validate = (): boolean => {
const errors: Partial<Record<keyof AddForm, string>> = {}
const modelName = form.modelName.trim()
if (!modelName) {
errors.modelName = t("models.add.errorRequired")
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
errors.modelName = t("models.add.errorDuplicateModelName")
}
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
setFieldErrors(errors)
return Object.keys(errors).length === 0
}
const setField =
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((f) => ({ ...f, [key]: e.target.value }))
if (fieldErrors[key]) {
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
}
}
const handleSave = async () => {
if (!validate()) return
setSaving(true)
setServerError("")
try {
const modelName = form.modelName.trim()
const modelId = form.model.trim()
await addModel({
model_name: modelName,
model: modelId,
api_base: form.apiBase.trim() || undefined,
api_key: form.apiKey.trim() || undefined,
proxy: form.proxy.trim() || undefined,
auth_method: form.authMethod.trim() || undefined,
connect_mode: form.connectMode.trim() || undefined,
workspace: form.workspace.trim() || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
max_tokens_field: form.maxTokensField.trim() || undefined,
request_timeout: form.requestTimeout
? Number(form.requestTimeout)
: undefined,
thinking_level: form.thinkingLevel.trim() || undefined,
})
if (setAsDefault) {
await setDefaultModel(modelName)
}
onSaved()
onClose()
} catch (e) {
setServerError(e instanceof Error ? e.message : t("models.add.saveError"))
} finally {
setSaving(false)
}
}
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">{t("models.add.title")}</SheetTitle>
<SheetDescription className="text-xs">
{t("models.add.description")}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.add.modelName")}
hint={t("models.add.modelNameHint")}
>
<Input
value={form.modelName}
onChange={setField("modelName")}
placeholder={t("models.add.modelNamePlaceholder")}
aria-invalid={!!fieldErrors.modelName}
/>
{fieldErrors.modelName && (
<p className="text-destructive text-xs">
{fieldErrors.modelName}
</p>
)}
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.model}
onChange={setField("model")}
placeholder={t("models.add.modelIdPlaceholder")}
className="font-mono text-sm"
aria-invalid={!!fieldErrors.model}
/>
{fieldErrors.model && (
<p className="text-destructive text-xs">{fieldErrors.model}</p>
)}
</Field>
<Field label={t("models.field.apiKey")}>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
<Field label={t("models.field.apiBase")}>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
/>
</Field>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
/>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
>
<Input
value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
</AdvancedSection>
{serverError && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{serverError}
</p>
)}
</div>
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("models.add.confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,74 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, deleteModel } from "@/api/models"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface DeleteModelDialogProps {
model: ModelInfo | null
onClose: () => void
onDeleted: () => void
}
export function DeleteModelDialog({
model,
onClose,
onDeleted,
}: DeleteModelDialogProps) {
const { t } = useTranslation()
const [deleting, setDeleting] = useState(false)
const handleConfirm = async () => {
if (!model) return
if (model.is_default) {
onClose()
return
}
setDeleting(true)
try {
await deleteModel(model.index)
onDeleted()
} catch {
// ignore, user can retry from list
} finally {
setDeleting(false)
onClose()
}
}
return (
<AlertDialog open={model !== null} onOpenChange={(v) => !v && onClose()}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{t("models.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("models.delete.description", { name: model?.model_name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose} disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleConfirm}
disabled={deleting}
>
{deleting && <IconLoader2 className="size-4 animate-spin" />}
{t("models.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,298 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
AdvancedSection,
Field,
KeyInput,
SwitchCardField,
} from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface EditForm {
apiKey: string
apiBase: string
proxy: string
authMethod: string
connectMode: string
workspace: string
rpm: string
maxTokensField: string
requestTimeout: string
thinkingLevel: string
}
interface EditModelSheetProps {
model: ModelInfo | null
open: boolean
onClose: () => void
onSaved: () => void
}
export function EditModelSheet({
model,
open,
onClose,
onSaved,
}: EditModelSheetProps) {
const { t } = useTranslation()
const [form, setForm] = useState<EditForm>({
apiKey: "",
apiBase: "",
proxy: "",
authMethod: "",
connectMode: "",
workspace: "",
rpm: "",
maxTokensField: "",
requestTimeout: "",
thinkingLevel: "",
})
const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
if (model) {
setForm({
apiKey: "",
apiBase: model.api_base ?? "",
proxy: model.proxy ?? "",
authMethod: model.auth_method ?? "",
connectMode: model.connect_mode ?? "",
workspace: model.workspace ?? "",
rpm: model.rpm ? String(model.rpm) : "",
maxTokensField: model.max_tokens_field ?? "",
requestTimeout: model.request_timeout
? String(model.request_timeout)
: "",
thinkingLevel: model.thinking_level ?? "",
})
setSetAsDefault(model.is_default)
setError("")
}
}, [model])
const setField =
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }))
const handleSave = async () => {
if (!model) return
setSaving(true)
setError("")
try {
await updateModel(model.index, {
model_name: model.model_name,
model: model.model,
api_base: form.apiBase || undefined,
api_key: form.apiKey || undefined,
proxy: form.proxy || undefined,
auth_method: form.authMethod || undefined,
connect_mode: form.connectMode || undefined,
workspace: form.workspace || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
max_tokens_field: form.maxTokensField || undefined,
request_timeout: form.requestTimeout
? Number(form.requestTimeout)
: undefined,
thinking_level: form.thinkingLevel || undefined,
})
if (setAsDefault) {
await setDefaultModel(model.model_name)
}
onSaved()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : t("models.edit.saveError"))
} finally {
setSaving(false)
}
}
const isOAuth = model?.auth_method === "oauth"
const apiKeyPlaceholder = model?.configured
? maskedSecretPlaceholder(
model.api_key,
t("models.field.apiKeyPlaceholderSet"),
)
: t("models.field.apiKeyPlaceholder")
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">
{t("models.edit.title", { name: model?.model_name })}
</SheetTitle>
<SheetDescription className="font-mono text-xs">
{model?.model}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
{!isOAuth && (
<Field
label={t("models.field.apiKey")}
hint={
model?.configured ? t("models.edit.apiKeyHint") : undefined
}
>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
)}
<Field
label={t("models.field.apiBase")}
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
disabled={isOAuth}
/>
</Field>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
/>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
>
<Input
value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
</AdvancedSection>
{error && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{error}
</p>
)}
</div>
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("common.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,137 @@
import {
IconEdit,
IconKey,
IconLoader2,
IconStar,
IconStarFilled,
IconTrash,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { ModelInfo } from "@/api/models"
import { Button } from "@/components/ui/button"
interface ModelCardProps {
model: ModelInfo
onEdit: (model: ModelInfo) => void
onSetDefault: (model: ModelInfo) => void
onDelete: (model: ModelInfo) => void
settingDefault: boolean
}
export function ModelCard({
model,
onEdit,
onSetDefault,
onDelete,
settingDefault,
}: ModelCardProps) {
const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth"
const canSetDefault = model.configured && !model.is_default
return (
<div
className={[
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
model.configured
? "border-border/60 bg-card"
: "border-border/50 bg-card/60",
].join(" ")}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className={[
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
model.is_default
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
: model.configured
? "bg-green-500"
: "bg-muted-foreground/25",
].join(" ")}
title={
model.configured
? t("models.status.configured")
: t("models.status.unconfigured")
}
/>
<span className="text-foreground truncate text-sm font-semibold">
{model.model_name}
</span>
{model.is_default && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] leading-none font-medium">
{t("models.badge.default")}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5">
{model.is_default ? (
<span
className="text-primary p-1"
title={t("models.badge.default")}
>
<IconStarFilled className="size-3.5" />
</span>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onSetDefault(model)}
disabled={settingDefault || !canSetDefault}
title={t("models.action.setDefault")}
>
{settingDefault ? (
<IconLoader2 className="size-3.5 animate-spin" />
) : (
<IconStar className="size-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(model)}
title={t("models.action.edit")}
>
<IconEdit className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onDelete(model)}
disabled={model.is_default}
title={t("models.action.delete")}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<IconTrash className="size-3.5" />
</Button>
</div>
</div>
<p className="text-muted-foreground truncate font-mono text-xs leading-snug">
{model.model}
</p>
<div className="flex items-center gap-2">
{isOAuth ? (
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
OAuth
</span>
) : model.configured && model.api_key ? (
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
<IconKey className="size-3" />
{model.api_key}
</span>
) : (
<span className="text-muted-foreground/50 text-[11px]">
{t("models.status.unconfigured")}
</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,213 @@
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { AddModelSheet } from "./add-model-sheet"
import { DeleteModelDialog } from "./delete-model-dialog"
import { EditModelSheet } from "./edit-model-sheet"
import { getProviderKey, getProviderLabel } from "./provider-label"
import { ProviderSection } from "./provider-section"
const PROVIDER_PRIORITY: Record<string, number> = {
volcengine: 0,
openai: 1,
gemini: 2,
anthropic: 3,
zhipu: 4,
deepseek: 5,
openrouter: 6,
qwen: 7,
moonshot: 8,
groq: 9,
"github-copilot": 10,
antigravity: 11,
nvidia: 12,
cerebras: 13,
shengsuanyun: 14,
ollama: 15,
vllm: 16,
mistral: 17,
avian: 18,
}
interface ProviderGroup {
key: string
label: string
models: ModelInfo[]
hasDefault: boolean
configuredCount: number
}
export function ModelsPage() {
const { t } = useTranslation()
const [models, setModels] = useState<ModelInfo[]>([])
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState("")
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
null,
)
const fetchModels = useCallback(async () => {
try {
const data = await getModels()
const sorted = [...data.models].sort((a, b) => {
if (a.is_default && !b.is_default) return -1
if (!a.is_default && b.is_default) return 1
if (a.configured && !b.configured) return -1
if (!a.configured && b.configured) return 1
return a.model_name.localeCompare(b.model_name)
})
setModels(sorted)
setFetchError("")
} catch (e) {
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
fetchModels()
}, [fetchModels])
const handleSetDefault = async (model: ModelInfo) => {
setSettingDefaultIndex(model.index)
try {
await setDefaultModel(model.model_name)
await fetchModels()
} catch {
// ignore
} finally {
setSettingDefaultIndex(null)
}
}
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}
for (const model of models) {
const providerKey = getProviderKey(model.model)
if (!grouped[providerKey]) {
grouped[providerKey] = {
label: getProviderLabel(model.model),
models: [],
}
}
grouped[providerKey].models.push(model)
}
const providerGroups: ProviderGroup[] = Object.entries(grouped)
.map(([key, group]) => {
const configuredCount = group.models.filter(
(model) => model.configured,
).length
return {
key,
label: group.label,
models: group.models,
hasDefault: group.models.some((model) => model.is_default),
configuredCount,
}
})
.sort((a, b) => {
if (a.hasDefault && !b.hasDefault) return -1
if (!a.hasDefault && b.hasDefault) return 1
if (a.configuredCount !== b.configuredCount) {
return b.configuredCount - a.configuredCount
}
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER
const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER
if (aPriority !== bPriority) {
return aPriority - bPriority
}
return a.label.localeCompare(b.label)
})
const defaultModel = models.find((model) => model.is_default)
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.models")}>
<div className="flex items-center gap-3">
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<IconPlus className="size-4" />
{t("models.add.button")}
</Button>
</div>
</PageHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
<div className="pt-2">
{!defaultModel && (
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<span>{t("models.noDefaultHintPrefix")}</span>
<IconStar className="size-3.5 shrink-0" />
<span>{t("models.noDefaultHintSuffix")}</span>
</div>
)}
<p className="text-muted-foreground mt-1 text-sm">
{t("models.description")}
</p>
</div>
{loading && (
<div className="flex items-center justify-center py-20">
<IconLoader2 className="text-muted-foreground size-6 animate-spin" />
</div>
)}
{fetchError && (
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
{fetchError}
</div>
)}
{!loading && !fetchError && (
<div className="pb-8">
{providerGroups.map((providerGroup) => (
<ProviderSection
key={providerGroup.key}
provider={providerGroup.label}
providerKey={providerGroup.key}
models={providerGroup.models}
onEdit={setEditingModel}
onSetDefault={handleSetDefault}
onDelete={setDeletingModel}
settingDefaultIndex={settingDefaultIndex}
/>
))}
</div>
)}
</div>
<EditModelSheet
model={editingModel}
open={editingModel !== null}
onClose={() => setEditingModel(null)}
onSaved={fetchModels}
/>
<AddModelSheet
open={addOpen}
onClose={() => setAddOpen(false)}
onSaved={fetchModels}
existingModelNames={models.map((model) => model.model_name)}
/>
<DeleteModelDialog
model={deletingModel}
onClose={() => setDeletingModel(null)}
onDeleted={fetchModels}
/>
</div>
)
}
@@ -0,0 +1,95 @@
import { useMemo, useState } from "react"
const PROVIDER_ICON_SLUGS: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
gemini: "googlegemini",
deepseek: "deepseek",
qwen: "alibabacloud",
groq: "groq",
openrouter: "openrouter",
nvidia: "nvidia",
cerebras: "cerebras",
volcengine: "bytedance",
"github-copilot": "githubcopilot",
ollama: "ollama",
mistral: "mistralai",
zhipu: "zhipu",
}
const PROVIDER_DOMAINS: Record<string, string> = {
openai: "openai.com",
anthropic: "anthropic.com",
gemini: "gemini.google.com",
deepseek: "deepseek.com",
qwen: "qwenlm.ai",
moonshot: "moonshot.ai",
groq: "groq.com",
openrouter: "openrouter.ai",
nvidia: "nvidia.com",
cerebras: "cerebras.ai",
volcengine: "volcengine.com",
shengsuanyun: "shengsuanyun.com",
antigravity: "antigravity.google",
"github-copilot": "github.com",
ollama: "ollama.com",
mistral: "mistral.ai",
avian: "avian.io",
vllm: "vllm.ai",
zhipu: "zhipuai.cn",
}
interface ProviderIconProps {
providerKey: string
providerLabel: string
}
export function ProviderIcon({
providerKey,
providerLabel,
}: ProviderIconProps) {
const [sourceIndex, setSourceIndex] = useState(0)
const [loadFailed, setLoadFailed] = useState(false)
const initial = providerLabel.trim().charAt(0).toUpperCase() || "?"
const iconUrls = useMemo(() => {
const slug = PROVIDER_ICON_SLUGS[providerKey]
const domain = PROVIDER_DOMAINS[providerKey]
const urls: string[] = []
if (slug) {
urls.push(`https://cdn.simpleicons.org/${slug}`)
}
if (domain) {
urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`)
}
return urls
}, [providerKey])
const iconUrl = iconUrls[sourceIndex]
if (!iconUrl || loadFailed) {
return (
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-black/70">
{initial}
</span>
)
}
return (
<span className="inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-black/10 bg-white p-0.5 dark:border-white/20">
<img
src={iconUrl}
alt={`${providerLabel} logo`}
className="size-full object-contain"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
if (sourceIndex < iconUrls.length - 1) {
setSourceIndex((idx) => idx + 1)
return
}
setLoadFailed(true)
}}
/>
</span>
)
}
@@ -0,0 +1,33 @@
const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
gemini: "Google Gemini",
deepseek: "DeepSeek",
qwen: "Qwen (阿里云)",
moonshot: "Moonshot (月之暗面)",
groq: "Groq",
openrouter: "OpenRouter",
nvidia: "NVIDIA",
cerebras: "Cerebras",
volcengine: "Volcengine (火山引擎)",
shengsuanyun: "ShengsuanYun (神算云)",
antigravity: "Google Code Assist",
"github-copilot": "GitHub Copilot",
ollama: "Ollama (local)",
mistral: "Mistral AI",
avian: "Avian",
vllm: "VLLM (local)",
zhipu: "Zhipu AI (智谱)",
}
export function getProviderKey(model: string): string {
return model.split("/")[0]
}
export function getProviderLabel(model: string): string {
const prefix = getProviderKey(model)
const labels: Record<string, string> = {
...PROVIDER_LABELS,
}
return labels[prefix] ?? prefix
}
@@ -0,0 +1,72 @@
import { IconChevronDown } from "@tabler/icons-react"
import { useState } from "react"
import type { ModelInfo } from "@/api/models"
import { ModelCard } from "./model-card"
import { ProviderIcon } from "./provider-icon"
interface ProviderSectionProps {
provider: string
providerKey: string
models: ModelInfo[]
onEdit: (model: ModelInfo) => void
onSetDefault: (model: ModelInfo) => void
onDelete: (model: ModelInfo) => void
settingDefaultIndex: number | null
}
export function ProviderSection({
provider,
providerKey,
models,
onEdit,
onSetDefault,
onDelete,
settingDefaultIndex,
}: ProviderSectionProps) {
const [open, setOpen] = useState(true)
return (
<section className="my-8">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="mb-3 grid w-full grid-cols-[1fr_auto_1fr_auto] items-center gap-2 px-1 py-1.5 text-left"
aria-expanded={open}
>
<div className="border-border/40 border-t" />
<span className="text-foreground/80 text-center text-xs font-semibold tracking-wide uppercase">
<span className="bg-background inline-flex items-center gap-1.5 px-2">
<ProviderIcon providerKey={providerKey} providerLabel={provider} />
{provider}
</span>
</span>
<div className="border-border/40 border-t" />
<span className="flex justify-end">
<IconChevronDown
className={[
"text-muted-foreground size-4 transition-transform",
open ? "rotate-180" : "",
].join(" ")}
/>
</span>
</button>
{open && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard
key={model.index}
model={model}
onEdit={onEdit}
onSetDefault={onSetDefault}
onDelete={onDelete}
settingDefault={settingDefaultIndex === model.index}
/>
))}
</div>
)}
</section>
)
}