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
+193
View File
@@ -0,0 +1,193 @@
import {
IconBook,
IconLanguage,
IconLoader2,
IconMenu2,
IconMoon,
IconPlayerPlay,
IconPower,
IconSun,
} from "@tabler/icons-react"
import { Link } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
export function AppHeader() {
const { i18n, t } = useTranslation()
const { theme, toggleTheme } = useTheme()
const {
state: gwState,
loading: gwLoading,
canStart,
start,
stop,
} = useGateway()
const isRunning = gwState === "running"
const isStarting = gwState === "starting"
const isStopped = gwState === "stopped" || gwState === "unknown"
const showNotConnectedHint =
canStart && (gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => {
if (gwLoading || (!isRunning && !canStart)) return
if (isRunning) {
setShowStopDialog(true)
} else {
start()
}
}
const confirmStop = () => {
setShowStopDialog(false)
stop()
}
return (
<header className="bg-background/95 supports-backdrop-filter:bg-background/60 border-b-border/50 sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between border-b px-4 backdrop-blur">
<div className="flex items-center gap-2">
<SidebarTrigger className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-9 w-9 items-center justify-center rounded-lg sm:hidden [&>svg]:size-5">
<IconMenu2 />
</SidebarTrigger>
<div className="hidden w-36 shrink-0 items-center sm:flex">
<Link to="/">
<img className="w-full" src="/logo_with_text.png" alt="Logo" />
</Link>
</div>
</div>
{/* Center prominent connection status */}
<div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex">
{showNotConnectedHint && (
<div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md">
<span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full">
<span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span>
</span>
{t("chat.notConnected")}
</div>
)}
</div>
<AlertDialog open={showStopDialog} onOpenChange={setShowStopDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("header.gateway.stopDialog.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("header.gateway.stopDialog.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmStop}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("header.gateway.stopDialog.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{/* Gateway Start/Stop */}
<Button
variant={isStarting ? "secondary" : "default"}
size="sm"
className={`h-8 gap-2 px-3 ${
isRunning
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
: isStopped
? "bg-green-500 text-white hover:bg-green-600"
: ""
}`}
onClick={handleGatewayToggle}
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
>
{gwLoading || isStarting ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : isRunning ? (
<IconPower className="h-4 w-4 opacity-80" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isRunning
? t("header.gateway.action.stop")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
<Separator
className="mx-4 my-2 hidden md:block"
orientation="vertical"
/>
{/* Docs Link */}
<Button variant="ghost" size="icon" className="size-8" asChild>
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
<IconBook className="size-4.5" />
</a>
</Button>
{/* Language Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconLanguage className="size-4.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
</div>
</header>
)
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react"
import { Toaster } from "sonner"
import { AppHeader } from "@/components/app-header"
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { TooltipProvider } from "@/components/ui/tooltip"
export function AppLayout({ children }: { children: ReactNode }) {
return (
<TooltipProvider>
<SidebarProvider className="flex h-dvh flex-col overflow-hidden">
<AppHeader />
<div className="flex flex-1 overflow-hidden">
<AppSidebar />
<div className="flex w-full flex-col overflow-hidden">
<main className="flex min-h-0 w-full max-w-full flex-1 flex-col overflow-hidden">
{children}
</main>
</div>
</div>
<Toaster position="bottom-center" />
</SidebarProvider>
</TooltipProvider>
)
}
+215
View File
@@ -0,0 +1,215 @@
import { IconChevronRight } from "@tabler/icons-react"
import {
IconAtom,
IconChevronsDown,
IconChevronsUp,
IconKey,
IconListDetails,
IconMessageCircle,
IconSettings,
} from "@tabler/icons-react"
import { Link, useRouterState } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
interface NavItem {
title: string
url: string
icon: React.ComponentType<{ className?: string }>
translateTitle?: boolean
}
interface NavGroup {
label: string
defaultOpen: boolean
items: NavItem[]
isChannelsGroup?: boolean
}
const baseNavGroups: Omit<NavGroup, "items">[] = [
{
label: "navigation.chat",
defaultOpen: true,
},
{
label: "navigation.model_group",
defaultOpen: true,
},
{
label: "navigation.services",
defaultOpen: true,
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const routerState = useRouterState()
const { t } = useTranslation()
const currentPath = routerState.location.pathname
const {
channelItems,
hasMoreChannels,
showAllChannels,
toggleShowAllChannels,
} = useSidebarChannels({ t })
const navGroups: NavGroup[] = React.useMemo(() => {
return [
{
...baseNavGroups[0],
items: [
{
title: "navigation.chat",
url: "/",
icon: IconMessageCircle,
translateTitle: true,
},
],
},
{
...baseNavGroups[1],
items: [
{
title: "navigation.models",
url: "/models",
icon: IconAtom,
translateTitle: true,
},
{
title: "navigation.credentials",
url: "/credentials",
icon: IconKey,
translateTitle: true,
},
],
},
{
label: "navigation.channels_group",
defaultOpen: true,
items: channelItems.map((item) => ({
title: item.title,
url: item.url,
icon: item.icon,
translateTitle: false,
})),
isChannelsGroup: true,
},
{
...baseNavGroups[2],
items: [
{
title: "navigation.config",
url: "/config",
icon: IconSettings,
translateTitle: true,
},
{
title: "navigation.logs",
url: "/logs",
icon: IconListDetails,
translateTitle: true,
},
],
},
]
}, [channelItems])
return (
<Sidebar
{...props}
className="bg-background border-r-border/20 border-r pt-3"
>
<SidebarContent className="bg-background">
{navGroups.map((group) => (
<Collapsible
key={group.label}
defaultOpen={group.defaultOpen}
className="group/collapsible mb-1"
>
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="hover:bg-muted/60 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 transition-colors">
<span>{t(group.label)}</span>
<IconChevronRight className="size-3.5 opacity-50 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pt-1">
<SidebarMenu>
{group.items.map((item) => {
const isActive =
currentPath === item.url ||
(item.url !== "/" &&
currentPath.startsWith(`${item.url}/`))
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isActive}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
>
<Link to={item.url}>
<item.icon
className={`size-4 ${isActive ? "opacity-100" : "opacity-60"}`}
/>
<span
className={
isActive ? "opacity-100" : "opacity-80"
}
>
{item.translateTitle === false
? item.title
: t(item.title)}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
{group.isChannelsGroup && hasMoreChannels && (
<SidebarMenuItem key="channels-more-toggle">
<SidebarMenuButton
onClick={toggleShowAllChannels}
className="text-muted-foreground hover:bg-muted/60 h-9 px-3"
>
{showAllChannels ? (
<IconChevronsUp className="size-4 opacity-60" />
) : (
<IconChevronsDown className="size-4 opacity-60" />
)}
<span className="opacity-80">
{showAllChannels
? t("navigation.show_less_channels")
: t("navigation.show_more_channels")}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}
@@ -0,0 +1,539 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useAtomValue } from "jotai"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
type ChannelConfig,
type SupportedChannel,
getAppConfig,
getChannelsCatalog,
patchAppConfig,
} from "@/api/channels"
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { gatewayAtom } from "@/store/gateway"
interface ChannelConfigPageProps {
channelName: string
}
const SECRET_FIELD_MAP: Record<string, string> = {
token: "_token",
app_secret: "_app_secret",
client_secret: "_client_secret",
corp_secret: "_corp_secret",
channel_secret: "_channel_secret",
channel_access_token: "_channel_access_token",
access_token: "_access_token",
bot_token: "_bot_token",
app_token: "_app_token",
encoding_aes_key: "_encoding_aes_key",
encrypt_key: "_encrypt_key",
verification_token: "_verification_token",
password: "_password",
nickserv_password: "_nickserv_password",
sasl_password: "_sasl_password",
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asBool(value: unknown): boolean {
return value === true
}
function buildEditConfig(config: ChannelConfig): ChannelConfig {
const edit: ChannelConfig = { ...config }
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
if (secretKey in config) {
edit[SECRET_FIELD_MAP[secretKey]] = ""
}
}
return edit
}
function normalizeConfig(
channel: SupportedChannel,
rawConfig: ChannelConfig,
): ChannelConfig {
const config = { ...rawConfig }
if (channel.name === "whatsapp_native") {
config.use_native = true
}
if (channel.name === "whatsapp") {
config.use_native = false
}
return config
}
function buildSavePayload(
channel: SupportedChannel,
editConfig: ChannelConfig,
enabled: boolean,
): ChannelConfig {
const payload: ChannelConfig = { enabled }
for (const [key, value] of Object.entries(editConfig)) {
if (key.startsWith("_")) continue
if (key === "enabled") continue
if (key in SECRET_FIELD_MAP) {
const editKey = SECRET_FIELD_MAP[key]
const incoming = asString(editConfig[editKey])
payload[key] = incoming !== "" ? incoming : value
continue
}
payload[key] = value
}
if (channel.name === "whatsapp_native") {
payload.use_native = true
}
if (channel.name === "whatsapp") {
payload.use_native = false
}
return payload
}
function isConfigured(
channel: SupportedChannel,
config: ChannelConfig,
): boolean {
switch (channel.name) {
case "telegram":
return asString(config.token) !== ""
case "discord":
return asString(config.token) !== ""
case "slack":
return asString(config.bot_token) !== ""
case "feishu":
return (
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
)
case "dingtalk":
return (
asString(config.client_id) !== "" &&
asString(config.client_secret) !== ""
)
case "line":
return asString(config.channel_access_token) !== ""
case "qq":
return (
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
)
case "onebot":
return asString(config.ws_url) !== ""
case "wecom":
return asString(config.token) !== ""
case "wecom_app":
return (
asString(config.corp_id) !== "" && asString(config.corp_secret) !== ""
)
case "wecom_aibot":
return asString(config.token) !== ""
case "whatsapp":
return asString(config.bridge_url) !== ""
case "whatsapp_native":
return asBool(config.use_native)
case "pico":
return asString(config.token) !== ""
case "maixcam":
return asString(config.host) !== ""
case "matrix":
return (
asString(config.homeserver) !== "" &&
asString(config.user_id) !== "" &&
asString(config.access_token) !== ""
)
case "irc":
return asString(config.server) !== ""
default:
return false
}
}
function getRequiredFieldKeys(channelName: string): string[] {
switch (channelName) {
case "telegram":
return ["token"]
case "discord":
return ["token"]
case "slack":
return ["bot_token"]
case "feishu":
return ["app_id", "app_secret"]
case "dingtalk":
return ["client_id", "client_secret"]
case "line":
return ["channel_secret", "channel_access_token"]
case "qq":
return ["app_id", "app_secret"]
case "onebot":
return ["ws_url"]
case "wecom":
return ["token"]
case "wecom_app":
return ["corp_id", "corp_secret"]
case "wecom_aibot":
return ["token"]
case "whatsapp":
return ["bridge_url"]
case "pico":
return ["token"]
case "maixcam":
return ["host"]
case "matrix":
return ["homeserver", "user_id", "access_token"]
case "irc":
return ["server"]
default:
return []
}
}
function isMissingRequiredValue(value: unknown): boolean {
if (value === null || value === undefined) {
return true
}
if (typeof value === "string") {
return value.trim() === ""
}
if (Array.isArray(value)) {
return value.length === 0
}
return false
}
function getChannelDocSlug(channelName: string): string {
return channelName.replaceAll("_", "-")
}
const CHANNELS_WITHOUT_DOCS = new Set([
"pico",
"wecom",
"matrix",
"irc",
"whatsapp",
"whatsapp_native",
])
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const { t, i18n } = useTranslation()
const gateway = useAtomValue(gatewayAtom)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [fetchError, setFetchError] = useState("")
const [serverError, setServerError] = useState("")
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
const [channel, setChannel] = useState<SupportedChannel | null>(null)
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [enabled, setEnabled] = useState(false)
const loadData = useCallback(async () => {
setLoading(true)
try {
const [catalog, appConfig] = await Promise.all([
getChannelsCatalog(),
getAppConfig(),
])
const matched =
catalog.channels.find((item) => item.name === channelName) ?? null
if (!matched) {
setChannel(null)
setFetchError(
t("channels.page.notFound", {
name: channelName,
}),
)
return
}
const channelsConfig = asRecord(asRecord(appConfig).channels)
const raw = asRecord(channelsConfig[matched.config_key])
const normalized = normalizeConfig(matched, raw)
setChannel(matched)
setBaseConfig(normalized)
setEditConfig(buildEditConfig(normalized))
setEnabled(asBool(normalized.enabled))
setFetchError("")
setServerError("")
setFieldErrors({})
} catch (e) {
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
} finally {
setLoading(false)
}
}, [channelName, t])
useEffect(() => {
loadData()
}, [loadData])
const previousGatewayStatusRef = useRef(gateway.status)
useEffect(() => {
const previousStatus = previousGatewayStatusRef.current
if (previousStatus !== "running" && gateway.status === "running") {
void loadData()
}
previousGatewayStatusRef.current = gateway.status
}, [gateway.status, loadData])
const savePayload = useMemo(() => {
if (!channel) return null
return buildSavePayload(channel, editConfig, enabled)
}, [channel, editConfig, enabled])
const configured = useMemo(() => {
if (!channel || !savePayload) return false
return isConfigured(channel, savePayload)
}, [channel, savePayload])
const docsUrl = useMemo(() => {
if (!channel) return ""
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
const language = (
i18n.resolvedLanguage ??
i18n.language ??
""
).toLowerCase()
const base = language.startsWith("zh")
? "https://docs.picoclaw.io/zh-Hans/docs/channels"
: "https://docs.picoclaw.io/docs/channels"
return `${base}/${getChannelDocSlug(channel.name)}`
}, [channel, i18n.language, i18n.resolvedLanguage])
const channelDisplayName = useMemo(() => {
if (!channel) return channelName
return getChannelDisplayName(channel, t)
}, [channel, channelName, t])
const hiddenKeys = useMemo(() => {
if (!channel) return []
if (channel.name === "whatsapp") {
return ["use_native"]
}
if (channel.name === "whatsapp_native") {
return ["use_native", "bridge_url"]
}
return []
}, [channel])
const requiredKeys = useMemo(
() => getRequiredFieldKeys(channelName),
[channelName],
)
const handleChange = useCallback((key: string, value: unknown) => {
const normalizedKey = key.startsWith("_") ? key.slice(1) : key
setEditConfig((prev) => ({ ...prev, [key]: value }))
setFieldErrors((prev) => {
if (!(key in prev) && !(normalizedKey in prev)) {
return prev
}
const next = { ...prev }
delete next[key]
delete next[normalizedKey]
return next
})
}, [])
const handleReset = () => {
setEditConfig(buildEditConfig(baseConfig))
setEnabled(asBool(baseConfig.enabled))
setServerError("")
setFieldErrors({})
}
const handleSave = async () => {
if (!channel || !savePayload) return
const missingRequiredFields = requiredKeys.filter((key) =>
isMissingRequiredValue(savePayload[key]),
)
if (missingRequiredFields.length > 0) {
const requiredFieldError = t("channels.validation.requiredField")
const nextFieldErrors: Record<string, string> = {}
for (const key of missingRequiredFields) {
nextFieldErrors[key] = requiredFieldError
}
setFieldErrors(nextFieldErrors)
setServerError("")
return
}
setSaving(true)
setServerError("")
setFieldErrors({})
try {
await patchAppConfig({
channels: {
[channel.config_key]: savePayload,
},
})
toast.success(t("channels.page.saveSuccess"))
await loadData()
} catch (e) {
const message =
e instanceof Error ? e.message : t("channels.page.saveError")
setServerError(message)
toast.error(message)
} finally {
setSaving(false)
}
}
const renderForm = () => {
if (!channel) return null
const isEdit = configured
switch (channel.name) {
case "telegram":
return (
<TelegramForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
case "discord":
return (
<DiscordForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
case "slack":
return (
<SlackForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
case "feishu":
return (
<FeishuForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
default:
return (
<GenericForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
hiddenKeys={hiddenKeys}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
/>
)
}
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={channelDisplayName}
titleExtra={
channel ? (
<div className="flex items-center gap-1.5">
{enabled ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
{t("channels.page.enabled")}
</span>
) : configured ? (
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
{t("channels.status.configured")}
</span>
) : null}
</div>
) : undefined
}
/>
<div className="flex min-h-0 flex-1 justify-center overflow-y-auto px-4 pb-8 sm:px-6">
{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>
) : (
<div className="w-full max-w-250 space-y-5 pt-2">
<div className="flex items-center gap-2 text-sm">
<p className="font-medium">
{t("channels.edit", {
name: channelDisplayName,
})}
</p>
{channel && docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
>
{t("channels.page.docLink")}
</a>
)}
</div>
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{renderForm()}
{serverError && (
<p className="text-destructive text-sm">{serverError}</p>
)}
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
<Button variant="outline" onClick={handleReset} disabled={saving}>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,23 @@
import type { TFunction } from "i18next"
import type { SupportedChannel } from "@/api/channels"
export function getChannelDisplayName(
channel: Pick<SupportedChannel, "name" | "display_name">,
t: TFunction,
): string {
const key = `channels.name.${channel.name}`
const translated = t(key)
if (translated !== key) {
return translated
}
if (channel.display_name && channel.display_name.trim() !== "") {
return channel.display_name
}
return channel.name
.split("_")
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ")
}
@@ -0,0 +1,109 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
interface DiscordFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
fieldErrors?: Record<string, string>
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function asBool(value: unknown): boolean {
return value === true
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
export function DiscordForm({
config,
onChange,
isEdit,
fieldErrors = {},
}: DiscordFormProps) {
const { t } = useTranslation()
const groupTriggerConfig = asRecord(config.group_trigger)
const tokenExtraHint =
isEdit && asString(config.token)
? ` ${t("channels.field.secretHintSet")}`
: ""
return (
<div className="space-y-5">
<Field
label={t("channels.field.token")}
required
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
error={fieldErrors.token}
>
<KeyInput
value={asString(config._token)}
onChange={(v) => onChange("_token", v)}
placeholder={maskedSecretPlaceholder(
config.token,
t("channels.field.tokenPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.proxy")}
hint={t("channels.form.desc.proxy")}
>
<Input
value={asString(config.proxy)}
onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
>
<Input
value={asStringArray(config.allow_from).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<SwitchCardField
label={t("channels.field.mentionOnly")}
hint={t("channels.form.desc.mentionOnly")}
checked={asBool(groupTriggerConfig.mention_only)}
onCheckedChange={(checked) => {
onChange("group_trigger", {
...groupTriggerConfig,
mention_only: checked,
})
}}
ariaLabel={t("channels.field.mentionOnly")}
/>
</div>
)
}
@@ -0,0 +1,121 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
interface FeishuFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
fieldErrors?: Record<string, string>
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function FeishuForm({
config,
onChange,
isEdit,
fieldErrors = {},
}: FeishuFormProps) {
const { t } = useTranslation()
const appSecretExtraHint =
isEdit && asString(config.app_secret)
? ` ${t("channels.field.secretHintSet")}`
: ""
const verificationExtraHint =
isEdit && asString(config.verification_token)
? ` ${t("channels.field.secretHintSet")}`
: ""
const encryptExtraHint =
isEdit && asString(config.encrypt_key)
? ` ${t("channels.field.secretHintSet")}`
: ""
return (
<div className="space-y-5">
<Field
label={t("channels.field.appId")}
required
hint={t("channels.form.desc.appId")}
error={fieldErrors.app_id}
>
<Input
value={asString(config.app_id)}
onChange={(e) => onChange("app_id", e.target.value)}
placeholder="cli_xxxx"
/>
</Field>
<Field
label={t("channels.field.appSecret")}
required
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
error={fieldErrors.app_secret}
>
<KeyInput
value={asString(config._app_secret)}
onChange={(v) => onChange("_app_secret", v)}
placeholder={maskedSecretPlaceholder(
config.app_secret,
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.verificationToken")}
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
>
<KeyInput
value={asString(config._verification_token)}
onChange={(v) => onChange("_verification_token", v)}
placeholder={maskedSecretPlaceholder(
config.verification_token,
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.encryptKey")}
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
>
<KeyInput
value={asString(config._encrypt_key)}
onChange={(v) => onChange("_encrypt_key", v)}
placeholder={maskedSecretPlaceholder(
config.encrypt_key,
t("channels.field.secretPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
>
<Input
value={asStringArray(config.allow_from).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</div>
)
}
@@ -0,0 +1,377 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
interface GenericFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
hiddenKeys?: string[]
requiredKeys?: string[]
fieldErrors?: Record<string, string>
}
// Secret field names that should use masked input.
const SECRET_FIELDS = new Set([
"token",
"app_secret",
"client_secret",
"corp_secret",
"channel_secret",
"channel_access_token",
"access_token",
"bot_token",
"app_token",
"encoding_aes_key",
"encrypt_key",
"verification_token",
"password",
"nickserv_password",
"sasl_password",
])
// Fields to skip in the generic form (handled by enabled toggle or internal).
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
// Fields that are objects/nested — show as JSON or skip.
const OBJECT_FIELDS = new Set([
"group_trigger",
"typing",
"placeholder",
"allow_token_query",
"allow_from",
"allow_origins",
])
function formatLabel(key: string): string {
return key
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
function formatSentenceFieldName(key: string): string {
const label = formatLabel(key)
return label.charAt(0).toLowerCase() + label.slice(1)
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function asBool(value: unknown): boolean {
return value === true
}
export function GenericForm({
config,
onChange,
isEdit,
hiddenKeys = [],
requiredKeys = [],
fieldErrors = {},
}: GenericFormProps) {
const { t } = useTranslation()
const hiddenFieldSet = new Set(hiddenKeys)
const requiredFieldSet = new Set(requiredKeys)
const groupTriggerConfig = asRecord(config.group_trigger)
const typingConfig = asRecord(config.typing)
const placeholderConfig = asRecord(config.placeholder)
const placeholderEnabled = asBool(placeholderConfig.enabled)
const fields = Object.keys(config).filter(
(k) =>
!k.startsWith("_") &&
!SKIP_FIELDS.has(k) &&
!OBJECT_FIELDS.has(k) &&
!hiddenFieldSet.has(k),
)
const buildHint = (key: string): string => {
const descriptions: Record<string, string> = {
ws_url: t("channels.form.desc.wsUrl"),
reconnect_interval: t("channels.form.desc.reconnectInterval"),
bridge_url: t("channels.form.desc.bridgeUrl"),
session_store_path: t("channels.form.desc.sessionStorePath"),
use_native: t("channels.form.desc.useNative"),
host: t("channels.form.desc.host"),
port: t("channels.form.desc.port"),
homeserver: t("channels.form.desc.homeserver"),
user_id: t("channels.form.desc.userId"),
device_id: t("channels.form.desc.deviceId"),
join_on_invite: t("channels.form.desc.joinOnInvite"),
app_id: t("channels.form.desc.appId"),
client_id: t("channels.form.desc.clientId"),
corp_id: t("channels.form.desc.corpId"),
agent_id: t("channels.form.desc.agentId"),
webhook_url: t("channels.form.desc.webhookUrl"),
webhook_host: t("channels.form.desc.webhookHost"),
webhook_port: t("channels.form.desc.webhookPort"),
webhook_path: t("channels.form.desc.webhookPath"),
reply_timeout: t("channels.form.desc.replyTimeout"),
max_steps: t("channels.form.desc.maxSteps"),
welcome_message: t("channels.form.desc.welcomeMessage"),
allow_token_query: t("channels.form.desc.allowTokenQuery"),
ping_interval: t("channels.form.desc.pingInterval"),
read_timeout: t("channels.form.desc.readTimeout"),
write_timeout: t("channels.form.desc.writeTimeout"),
max_connections: t("channels.form.desc.maxConnections"),
server: t("channels.form.desc.server"),
tls: t("channels.form.desc.tls"),
nick: t("channels.form.desc.nick"),
user: t("channels.form.desc.user"),
real_name: t("channels.form.desc.realName"),
channels: t("channels.form.desc.channels"),
request_caps: t("channels.form.desc.requestCaps"),
}
return (
descriptions[key] ??
t("channels.form.desc.genericField", {
field: formatSentenceFieldName(key),
})
)
}
return (
<div className="space-y-5">
{fields.map((key) => {
const isRequired = requiredFieldSet.has(key)
if (SECRET_FIELDS.has(key)) {
const editKey = `_${key}`
const extraHint =
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
return (
<Field
key={key}
label={formatLabel(key)}
required={isRequired}
hint={`${buildHint(key)}${extraHint}`}
error={fieldErrors[key]}
>
<KeyInput
value={asString(config[editKey])}
onChange={(v) => onChange(editKey, v)}
placeholder={maskedSecretPlaceholder(config[key])}
/>
</Field>
)
}
const value = config[key]
if (typeof value === "boolean") {
return (
<SwitchCardField
key={key}
label={formatLabel(key)}
hint={buildHint(key)}
error={fieldErrors[key]}
checked={value}
onCheckedChange={(checked) => onChange(key, checked)}
ariaLabel={formatLabel(key)}
/>
)
}
if (Array.isArray(value)) {
return (
<Field
key={key}
label={formatLabel(key)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[key]}
>
<Input
value={asStringArray(value).join(", ")}
onChange={(e) =>
onChange(
key,
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
/>
</Field>
)
}
return (
<Field
key={key}
label={formatLabel(key)}
required={isRequired}
hint={buildHint(key)}
error={fieldErrors[key]}
>
<Input
value={String(value ?? "")}
onChange={(e) => {
// Attempt to preserve number types
const v = e.target.value
if (typeof config[key] === "number") {
onChange(key, v === "" ? 0 : Number(v))
} else {
onChange(key, v)
}
}}
/>
</Field>
)
})}
{/* Allow From field */}
{config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
>
<Input
value={asStringArray(config.allow_from).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
)}
{config.allow_origins !== undefined &&
!hiddenFieldSet.has("allow_origins") && (
<Field
label={t("channels.field.allowOrigins")}
hint={t("channels.form.desc.allowOrigins")}
>
<Input
value={asStringArray(config.allow_origins).join(", ")}
onChange={(e) =>
onChange(
"allow_origins",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowOriginsPlaceholder")}
/>
</Field>
)}
{config.allow_token_query !== undefined &&
!hiddenFieldSet.has("allow_token_query") && (
<SwitchCardField
label={formatLabel("allow_token_query")}
hint={buildHint("allow_token_query")}
checked={asBool(config.allow_token_query)}
onCheckedChange={(checked) =>
onChange("allow_token_query", checked)
}
ariaLabel={formatLabel("allow_token_query")}
/>
)}
{config.group_trigger !== undefined &&
!hiddenFieldSet.has("group_trigger") && (
<>
<SwitchCardField
label={t("channels.field.groupTriggerMentionOnly")}
hint={t("channels.form.desc.groupTriggerMentionOnly")}
checked={asBool(groupTriggerConfig.mention_only)}
onCheckedChange={(checked) =>
onChange("group_trigger", {
...groupTriggerConfig,
mention_only: checked,
})
}
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
/>
<Field
label={t("channels.field.groupTriggerPrefixes")}
hint={t("channels.form.desc.groupTriggerPrefixes")}
>
<Input
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
onChange={(e) =>
onChange("group_trigger", {
...groupTriggerConfig,
prefixes: e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
})
}
placeholder={t("channels.field.groupTriggerPrefixes")}
/>
</Field>
</>
)}
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
<SwitchCardField
label={t("channels.field.typingEnabled")}
hint={t("channels.form.desc.typingEnabled")}
checked={asBool(typingConfig.enabled)}
onCheckedChange={(checked) =>
onChange("typing", { ...typingConfig, enabled: checked })
}
ariaLabel={t("channels.field.typingEnabled")}
/>
)}
{config.placeholder !== undefined &&
!hiddenFieldSet.has("placeholder") && (
<SwitchCardField
label={t("channels.field.placeholderEnabled")}
hint={t("channels.form.desc.placeholderEnabled")}
checked={placeholderEnabled}
onCheckedChange={(checked) =>
onChange("placeholder", {
...placeholderConfig,
enabled: checked,
})
}
ariaLabel={t("channels.field.placeholderEnabled")}
>
{placeholderEnabled && (
<div className="space-y-1">
<Input
value={asString(placeholderConfig.text)}
onChange={(e) =>
onChange("placeholder", {
...placeholderConfig,
text: e.target.value,
})
}
placeholder={t("channels.field.placeholderText")}
aria-label={t("channels.field.placeholderText")}
/>
</div>
)}
</SwitchCardField>
)}
</div>
)
}
@@ -0,0 +1,86 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
interface SlackFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
fieldErrors?: Record<string, string>
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function SlackForm({
config,
onChange,
isEdit,
fieldErrors = {},
}: SlackFormProps) {
const { t } = useTranslation()
const botTokenExtraHint =
isEdit && asString(config.bot_token)
? ` ${t("channels.field.secretHintSet")}`
: ""
const appTokenExtraHint =
isEdit && asString(config.app_token)
? ` ${t("channels.field.secretHintSet")}`
: ""
return (
<div className="space-y-5">
<Field
label={t("channels.field.botToken")}
required
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
error={fieldErrors.bot_token}
>
<KeyInput
value={asString(config._bot_token)}
onChange={(v) => onChange("_bot_token", v)}
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
/>
</Field>
<Field
label={t("channels.field.appToken")}
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
>
<KeyInput
value={asString(config._app_token)}
onChange={(v) => onChange("_app_token", v)}
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
>
<Input
value={asStringArray(config.allow_from).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
</div>
)
}
@@ -0,0 +1,147 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
interface TelegramFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
isEdit: boolean
fieldErrors?: Record<string, string>
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function asBool(value: unknown): boolean {
return value === true
}
export function TelegramForm({
config,
onChange,
isEdit,
fieldErrors = {},
}: TelegramFormProps) {
const { t } = useTranslation()
const typingConfig = asRecord(config.typing)
const placeholderConfig = asRecord(config.placeholder)
const placeholderEnabled = asBool(placeholderConfig.enabled)
const tokenExtraHint =
isEdit && asString(config.token)
? ` ${t("channels.field.secretHintSet")}`
: ""
return (
<div className="space-y-5">
<Field
label={t("channels.field.token")}
required
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
error={fieldErrors.token}
>
<KeyInput
value={asString(config._token)}
onChange={(v) => onChange("_token", v)}
placeholder={maskedSecretPlaceholder(
config.token,
t("channels.field.tokenPlaceholder"),
)}
/>
</Field>
<Field
label={t("channels.field.baseUrl")}
hint={t("channels.form.desc.baseUrl")}
>
<Input
value={asString(config.base_url)}
onChange={(e) => onChange("base_url", e.target.value)}
placeholder="https://api.telegram.org"
/>
</Field>
<Field
label={t("channels.field.proxy")}
hint={t("channels.form.desc.proxy")}
>
<Input
value={asString(config.proxy)}
onChange={(e) => onChange("proxy", e.target.value)}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("channels.field.allowFrom")}
hint={t("channels.form.desc.allowFrom")}
>
<Input
value={asStringArray(config.allow_from).join(", ")}
onChange={(e) =>
onChange(
"allow_from",
e.target.value
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
)
}
placeholder={t("channels.field.allowFromPlaceholder")}
/>
</Field>
<SwitchCardField
label={t("channels.field.typingEnabled")}
hint={t("channels.form.desc.typingEnabled")}
checked={asBool(typingConfig.enabled)}
onCheckedChange={(checked) =>
onChange("typing", { ...typingConfig, enabled: checked })
}
ariaLabel={t("channels.field.typingEnabled")}
/>
<SwitchCardField
label={t("channels.field.placeholderEnabled")}
hint={t("channels.form.desc.placeholderEnabled")}
checked={placeholderEnabled}
onCheckedChange={(checked) =>
onChange("placeholder", {
...placeholderConfig,
enabled: checked,
})
}
ariaLabel={t("channels.field.placeholderEnabled")}
>
{placeholderEnabled && (
<div className="space-y-1">
<Input
value={asString(placeholderConfig.text)}
onChange={(e) =>
onChange("placeholder", {
...placeholderConfig,
text: e.target.value,
})
}
placeholder={t("channels.field.placeholderText")}
aria-label={t("channels.field.placeholderText")}
/>
</div>
)}
</SwitchCardField>
</div>
)
}
@@ -0,0 +1,62 @@
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
interface AssistantMessageProps {
content: string
timestamp?: string | number
}
export function AssistantMessage({
content,
timestamp = "",
}: AssistantMessageProps) {
const [isCopied, setIsCopied] = useState(false)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
const handleCopy = () => {
navigator.clipboard.writeText(content).then(() => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
})
}
return (
<div className="group flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{formattedTimestamp && (
<>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
</div>
</div>
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
<Button
variant="ghost"
size="icon"
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,67 @@
import { IconArrowUp } from "@tabler/icons-react"
import type { KeyboardEvent } from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface ChatComposerProps {
input: string
onInputChange: (value: string) => void
onSend: () => void
isConnected: boolean
hasDefaultModel: boolean
}
export function ChatComposer({
input,
onInputChange,
onSend,
isConnected,
hasDefaultModel,
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = isConnected && hasDefaultModel
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSend()
}
}
return (
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
<TextareaAutosize
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("chat.placeholder")}
disabled={!canInput}
className={cn(
"max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1}
maxRows={8}
/>
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">{/* action buttons */}</div>
<Button
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!input.trim() || !isConnected}
>
<IconArrowUp className="size-4" />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
import {
IconPlugConnectedX,
IconRobot,
IconRobotOff,
IconStar,
} from "@tabler/icons-react"
import { Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
interface ChatEmptyStateProps {
hasConfiguredModels: boolean
defaultModelName: string
isConnected: boolean
}
export function ChatEmptyState({
hasConfiguredModels,
defaultModelName,
isConnected,
}: ChatEmptyStateProps) {
const { t } = useTranslation()
if (!hasConfiguredModels) {
return (
<div className="flex flex-col items-center justify-center py-20 opacity-70">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
<IconRobotOff className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">
{t("chat.empty.noConfiguredModel")}
</h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
{t("chat.empty.noConfiguredModelDescription")}
</p>
<Button asChild variant="secondary" size="sm" className="px-4">
<Link to="/models">{t("chat.empty.goToModels")}</Link>
</Button>
</div>
)
}
if (!defaultModelName) {
return (
<div className="flex flex-col items-center justify-center py-20 opacity-70">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
<IconStar className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">
{t("chat.empty.noSelectedModel")}
</h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
{t("chat.empty.noSelectedModelDescription")}
</p>
</div>
)
}
if (!isConnected) {
return (
<div className="flex flex-col items-center justify-center py-20 opacity-70">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
<IconPlugConnectedX className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">
{t("chat.empty.notRunning")}
</h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
{t("chat.empty.notRunningDescription")}
</p>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center py-20 opacity-70">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500">
<IconRobot className="h-8 w-8" />
</div>
<h3 className="mb-2 text-xl font-medium">{t("chat.welcome")}</h3>
<p className="text-muted-foreground text-center text-sm">
{t("chat.welcomeDesc")}
</p>
</div>
)
}
@@ -0,0 +1,150 @@
import { IconPlus } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { AssistantMessage } from "@/components/chat/assistant-message"
import { ChatComposer } from "@/components/chat/chat-composer"
import { ChatEmptyState } from "@/components/chat/chat-empty-state"
import { ModelSelector } from "@/components/chat/model-selector"
import { SessionHistoryMenu } from "@/components/chat/session-history-menu"
import { TypingIndicator } from "@/components/chat/typing-indicator"
import { UserMessage } from "@/components/chat/user-message"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history"
export function ChatPage() {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [input, setInput] = useState("")
const {
messages,
isTyping,
activeSessionId,
sendMessage,
switchSession,
newChat,
} = usePicoChat()
const { state: gwState } = useGateway()
const isConnected = gwState === "running"
const {
defaultModelName,
hasConfiguredModels,
apiKeyModels,
oauthModels,
localModels,
handleSetDefault,
} = useChatModels({ isConnected })
const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } =
useSessionHistory({
activeSessionId,
onDeletedActiveSession: newChat,
})
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
}
useEffect(() => {
if (isAtBottom && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isTyping, isAtBottom])
const handleSend = () => {
if (!input.trim() || !isConnected) return
sendMessage(input.trim())
setInput("")
}
return (
<div className="bg-background/95 flex h-full flex-col">
<PageHeader
title={t("navigation.chat")}
titleExtra={
hasConfiguredModels && (
<ModelSelector
defaultModelName={defaultModelName}
apiKeyModels={apiKeyModels}
oauthModels={oauthModels}
localModels={localModels}
onValueChange={handleSetDefault}
/>
)
}
>
<Button
variant="outline"
size="sm"
onClick={newChat}
className="h-9 gap-2"
>
<IconPlus className="size-4" />
<span className="hidden sm:inline">{t("chat.newChat")}</span>
</Button>
<SessionHistoryMenu
sessions={sessions}
activeSessionId={activeSessionId}
hasMore={hasMore}
observerRef={observerRef}
onOpenChange={(open) => {
if (open) {
void loadSessions(true)
}
}}
onSwitchSession={switchSession}
onDeleteSession={handleDeleteSession}
/>
</PageHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && (
<ChatEmptyState
hasConfiguredModels={hasConfiguredModels}
defaultModelName={defaultModelName}
isConnected={isConnected}
/>
)}
{messages.map((msg) => (
<div key={msg.id} className="flex w-full">
{msg.role === "assistant" ? (
<AssistantMessage
content={msg.content}
timestamp={msg.timestamp}
/>
) : (
<UserMessage content={msg.content} />
)}
</div>
))}
{isTyping && <TypingIndicator />}
</div>
</div>
<ChatComposer
input={input}
onInputChange={setInput}
onSend={handleSend}
isConnected={isConnected}
hasDefaultModel={Boolean(defaultModelName)}
/>
</div>
)
}
@@ -0,0 +1,84 @@
import { useTranslation } from "react-i18next"
import type { ModelInfo } from "@/api/models"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface ModelSelectorProps {
defaultModelName: string
apiKeyModels: ModelInfo[]
oauthModels: ModelInfo[]
localModels: ModelInfo[]
onValueChange: (modelName: string) => void
}
export function ModelSelector({
defaultModelName,
apiKeyModels,
oauthModels,
localModels,
onValueChange,
}: ModelSelectorProps) {
const { t } = useTranslation()
return (
<Select value={defaultModelName} onValueChange={onValueChange}>
<SelectTrigger
size="sm"
className="text-muted-foreground hover:text-foreground focus-visible:border-input h-8 max-w-[160px] min-w-[80px] bg-transparent shadow-none focus-visible:ring-0 sm:max-w-[220px]"
>
<SelectValue placeholder={t("chat.noModel")} />
</SelectTrigger>
<SelectContent>
{apiKeyModels.length > 0 && (
<SelectGroup>
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>
{apiKeyModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{apiKeyModels.length > 0 &&
(oauthModels.length > 0 || localModels.length > 0) && (
<SelectSeparator />
)}
{oauthModels.length > 0 && (
<SelectGroup>
<SelectLabel>{t("chat.modelGroup.oauth")}</SelectLabel>
{oauthModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
{oauthModels.length > 0 &&
(localModels.length > 0 || apiKeyModels.length > 0) && (
<SelectSeparator />
)}
{localModels.length > 0 && (
<SelectGroup>
<SelectLabel>{t("chat.modelGroup.local")}</SelectLabel>
{localModels.map((model) => (
<SelectItem key={model.index} value={model.model_name}>
{model.model_name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,98 @@
import { IconHistory, IconTrash } from "@tabler/icons-react"
import dayjs from "dayjs"
import type { RefObject } from "react"
import { useTranslation } from "react-i18next"
import type { SessionSummary } from "@/api/sessions"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
interface SessionHistoryMenuProps {
sessions: SessionSummary[]
activeSessionId: string
hasMore: boolean
observerRef: RefObject<HTMLDivElement | null>
onOpenChange: (open: boolean) => void
onSwitchSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
export function SessionHistoryMenu({
sessions,
activeSessionId,
hasMore,
observerRef,
onOpenChange,
onSwitchSession,
onDeleteSession,
}: SessionHistoryMenuProps) {
const { t } = useTranslation()
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2">
<IconHistory className="size-4" />
<span className="hidden sm:inline">{t("chat.history")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<ScrollArea className="max-h-[300px]">
{sessions.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-muted-foreground text-xs">
{t("chat.noHistory")}
</span>
</DropdownMenuItem>
) : (
sessions.map((session) => (
<DropdownMenuItem
key={session.id}
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${
session.id === activeSessionId ? "bg-accent" : ""
}`}
onClick={() => onSwitchSession(session.id)}
>
<span className="line-clamp-1 text-sm font-medium">
{session.preview}
</span>
<span className="text-muted-foreground text-xs">
{t("chat.messagesCount", {
count: session.message_count,
})}{" "}
· {dayjs(session.updated).fromNow()}
</span>
<Button
variant="ghost"
size="icon"
aria-label={t("chat.deleteSession")}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDeleteSession(session.id)
}}
>
<IconTrash className="h-4 w-4" />
</Button>
</DropdownMenuItem>
))
)}
{hasMore && sessions.length > 0 && (
<div ref={observerRef} className="py-2 text-center">
<span className="text-muted-foreground animate-pulse text-xs">
{t("chat.loadingMore")}
</span>
</div>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
export function TypingIndicator() {
const { t } = useTranslation()
const thinkingSteps = [
t("chat.thinking.step1"),
t("chat.thinking.step2"),
t("chat.thinking.step3"),
t("chat.thinking.step4"),
]
const [stepIndex, setStepIndex] = useState(0)
useEffect(() => {
const stepsCount = thinkingSteps.length
const interval = setInterval(() => {
setStepIndex((prev) => (prev + 1) % stepsCount)
}, 3000)
return () => clearInterval(interval)
}, [thinkingSteps.length])
return (
<div className="flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70">
<span>PicoClaw</span>
</div>
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
<div className="flex items-center gap-1.5">
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70" />
</div>
<div className="bg-muted relative h-1 w-36 overflow-hidden rounded-full">
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]" />
</div>
<p
key={stepIndex}
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs"
>
{thinkingSteps[stepIndex]}
</p>
</div>
</div>
)
}
@@ -0,0 +1,13 @@
interface UserMessageProps {
content: string
}
export function UserMessage({ content }: UserMessageProps) {
return (
<div className="flex w-full flex-col items-end gap-1.5">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm">
{content}
</div>
</div>
)
}
@@ -0,0 +1,337 @@
import { IconCode, IconDeviceFloppy } from "@tabler/icons-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels"
import {
getAutoStartStatus,
getLauncherConfig,
setAutoStartEnabled as updateAutoStartEnabled,
setLauncherConfig as updateLauncherConfig,
} from "@/api/system"
import {
AdvancedSection,
AgentDefaultsSection,
DevicesSection,
LauncherSection,
RuntimeSection,
} from "@/components/config/config-sections"
import {
type CoreConfigForm,
EMPTY_FORM,
EMPTY_LAUNCHER_FORM,
type LauncherForm,
buildFormFromConfig,
parseCIDRText,
parseIntField,
} from "@/components/config/form-model"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
export function ConfigPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [form, setForm] = useState<CoreConfigForm>(EMPTY_FORM)
const [baseline, setBaseline] = useState<CoreConfigForm>(EMPTY_FORM)
const [launcherForm, setLauncherForm] =
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
const [launcherBaseline, setLauncherBaseline] =
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
const [autoStartEnabled, setAutoStartEnabled] = useState(false)
const [autoStartBaseline, setAutoStartBaseline] = useState(false)
const [saving, setSaving] = useState(false)
const { data, isLoading, error } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
if (!res.ok) {
throw new Error("Failed to load config")
}
return res.json()
},
})
const {
data: launcherConfig,
isLoading: isLauncherLoading,
error: launcherError,
} = useQuery({
queryKey: ["system", "launcher-config"],
queryFn: getLauncherConfig,
})
const {
data: autoStartStatus,
isLoading: isAutoStartLoading,
error: autoStartError,
} = useQuery({
queryKey: ["system", "autostart"],
queryFn: getAutoStartStatus,
})
useEffect(() => {
if (!data) return
const parsed = buildFormFromConfig(data)
setForm(parsed)
setBaseline(parsed)
}, [data])
useEffect(() => {
if (!launcherConfig) return
const parsed: LauncherForm = {
port: String(launcherConfig.port),
publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
}
setLauncherForm(parsed)
setLauncherBaseline(parsed)
}, [launcherConfig])
useEffect(() => {
if (!autoStartStatus) return
setAutoStartEnabled(autoStartStatus.enabled)
setAutoStartBaseline(autoStartStatus.enabled)
}, [autoStartStatus])
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
const launcherDirty =
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
const autoStartDirty = autoStartEnabled !== autoStartBaseline
const isDirty = configDirty || launcherDirty || autoStartDirty
const autoStartSupported = autoStartStatus?.supported !== false
const autoStartHint = autoStartError
? t("pages.config.autostart_load_error")
: !autoStartSupported
? t("pages.config.autostart_unsupported")
: t("pages.config.autostart_hint")
const launcherHint = launcherError
? t("pages.config.launcher_load_error")
: t("pages.config.launcher_restart_hint")
const updateField = <K extends keyof CoreConfigForm>(
key: K,
value: CoreConfigForm[K],
) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const updateLauncherField = <K extends keyof LauncherForm>(
key: K,
value: LauncherForm[K],
) => {
setLauncherForm((prev) => ({ ...prev, [key]: value }))
}
const handleReset = () => {
setForm(baseline)
setLauncherForm(launcherBaseline)
setAutoStartEnabled(autoStartBaseline)
toast.info(t("pages.config.reset_success"))
}
const handleSave = async () => {
try {
setSaving(true)
if (configDirty) {
const workspace = form.workspace.trim()
const dmScope = form.dmScope.trim()
if (!workspace) {
throw new Error("Workspace path is required.")
}
if (!dmScope) {
throw new Error("Session scope is required.")
}
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
min: 1,
})
const maxToolIterations = parseIntField(
form.maxToolIterations,
"Max tool iterations",
{ min: 1 },
)
const summarizeMessageThreshold = parseIntField(
form.summarizeMessageThreshold,
"Summarize message threshold",
{ min: 1 },
)
const summarizeTokenPercent = parseIntField(
form.summarizeTokenPercent,
"Summarize token percent",
{ min: 1, max: 100 },
)
const heartbeatInterval = parseIntField(
form.heartbeatInterval,
"Heartbeat interval",
{ min: 1 },
)
await patchAppConfig({
agents: {
defaults: {
workspace,
restrict_to_workspace: form.restrictToWorkspace,
max_tokens: maxTokens,
max_tool_iterations: maxToolIterations,
summarize_message_threshold: summarizeMessageThreshold,
summarize_token_percent: summarizeTokenPercent,
},
},
session: {
dm_scope: dmScope,
},
heartbeat: {
enabled: form.heartbeatEnabled,
interval: heartbeatInterval,
},
devices: {
enabled: form.devicesEnabled,
monitor_usb: form.monitorUSB,
},
})
setBaseline(form)
queryClient.invalidateQueries({ queryKey: ["config"] })
}
if (launcherDirty) {
const port = parseIntField(launcherForm.port, "Service port", {
min: 1,
max: 65535,
})
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
const savedLauncherConfig = await updateLauncherConfig({
port,
public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs,
})
const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port),
publicAccess: savedLauncherConfig.public,
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n",
),
}
setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher)
queryClient.setQueryData(
["system", "launcher-config"],
savedLauncherConfig,
)
}
if (autoStartDirty) {
if (!autoStartSupported) {
throw new Error(t("pages.config.autostart_unsupported"))
}
const status = await updateAutoStartEnabled(autoStartEnabled)
setAutoStartEnabled(status.enabled)
setAutoStartBaseline(status.enabled)
queryClient.setQueryData(["system", "autostart"], status)
}
toast.success(t("pages.config.save_success"))
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("pages.config.save_error"),
)
} finally {
setSaving(false)
}
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.config")}
children={
<Button variant="outline" asChild>
<Link to="/config/raw">
<IconCode className="size-4" />
{t("pages.config.open_raw")}
</Link>
</Button>
}
/>
<div className="flex-1 overflow-auto p-3 lg:p-6">
<div className="mx-auto w-full max-w-[1000px] space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.config.load_error")}
</div>
) : (
<div className="space-y-6">
{isDirty && (
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes")}
</div>
)}
<AgentDefaultsSection form={form} onFieldChange={updateField} />
<Separator />
<RuntimeSection form={form} onFieldChange={updateField} />
<Separator />
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
launcherHint={launcherHint}
disabled={saving || isLauncherLoading}
/>
<Separator />
<DevicesSection
form={form}
onFieldChange={updateField}
autoStartEnabled={autoStartEnabled}
autoStartHint={autoStartHint}
autoStartDisabled={
isAutoStartLoading ||
Boolean(autoStartError) ||
!autoStartSupported ||
saving
}
onAutoStartChange={setAutoStartEnabled}
/>
<Separator />
<AdvancedSection />
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,326 @@
import { IconCode } from "@tabler/icons-react"
import { Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import {
type CoreConfigForm,
DM_SCOPE_OPTIONS,
type LauncherForm,
} from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
type UpdateCoreField = <K extends keyof CoreConfigForm>(
key: K,
value: CoreConfigForm[K],
) => void
type UpdateLauncherField = <K extends keyof LauncherForm>(
key: K,
value: LauncherForm[K],
) => void
interface AgentDefaultsSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
}
export function AgentDefaultsSection({
form,
onFieldChange,
}: AgentDefaultsSectionProps) {
const { t } = useTranslation()
return (
<section className="space-y-3">
<div className="space-y-4">
<Field
label={t("pages.config.workspace")}
hint={t("pages.config.workspace_hint")}
>
<Input
value={form.workspace}
onChange={(e) => onFieldChange("workspace", e.target.value)}
placeholder="~/.picoclaw/workspace"
/>
</Field>
<SwitchCardField
label={t("pages.config.restrict_workspace")}
hint={t("pages.config.restrict_workspace_hint")}
checked={form.restrictToWorkspace}
onCheckedChange={(checked) =>
onFieldChange("restrictToWorkspace", checked)
}
/>
<Field
label={t("pages.config.max_tokens")}
hint={t("pages.config.max_tokens_hint")}
>
<Input
type="number"
min={1}
value={form.maxTokens}
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.max_tool_iterations")}
hint={t("pages.config.max_tool_iterations_hint")}
>
<Input
type="number"
min={1}
value={form.maxToolIterations}
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
/>
</Field>
<Field
label={t("pages.config.summarize_threshold")}
hint={t("pages.config.summarize_threshold_hint")}
>
<Input
type="number"
min={1}
value={form.summarizeMessageThreshold}
onChange={(e) =>
onFieldChange("summarizeMessageThreshold", e.target.value)
}
/>
</Field>
<Field
label={t("pages.config.summarize_token_percent")}
hint={t("pages.config.summarize_token_percent_hint")}
>
<Input
type="number"
min={1}
max={100}
value={form.summarizeTokenPercent}
onChange={(e) =>
onFieldChange("summarizeTokenPercent", e.target.value)
}
/>
</Field>
</div>
</section>
)
}
interface RuntimeSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
}
export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
const { t } = useTranslation()
const selectedDmScopeOption = DM_SCOPE_OPTIONS.find(
(scope) => scope.value === form.dmScope,
)
return (
<section className="space-y-3">
<div className="space-y-4">
<Field
label={t("pages.config.session_scope")}
hint={t("pages.config.session_scope_hint")}
>
<Select
value={form.dmScope}
onValueChange={(value) => onFieldChange("dmScope", value)}
>
<SelectTrigger>
<SelectValue>
{selectedDmScopeOption
? t(
selectedDmScopeOption.labelKey,
selectedDmScopeOption.labelDefault,
)
: form.dmScope}
</SelectValue>
</SelectTrigger>
<SelectContent>
{DM_SCOPE_OPTIONS.map((scope) => (
<SelectItem key={scope.value} value={scope.value}>
<div className="flex flex-col gap-0.5">
<span className="font-medium">{t(scope.labelKey)}</span>
<span className="text-muted-foreground text-xs">
{t(scope.descKey)}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<SwitchCardField
label={t("pages.config.heartbeat_enabled")}
hint={t("pages.config.heartbeat_enabled_hint")}
checked={form.heartbeatEnabled}
onCheckedChange={(checked) =>
onFieldChange("heartbeatEnabled", checked)
}
/>
{form.heartbeatEnabled && (
<Field
label={t("pages.config.heartbeat_interval")}
hint={t("pages.config.heartbeat_interval_hint")}
>
<Input
type="number"
min={1}
value={form.heartbeatInterval}
onChange={(e) =>
onFieldChange("heartbeatInterval", e.target.value)
}
/>
</Field>
)}
</div>
</section>
)
}
interface LauncherSectionProps {
launcherForm: LauncherForm
onFieldChange: UpdateLauncherField
launcherHint: string
disabled: boolean
}
export function LauncherSection({
launcherForm,
onFieldChange,
launcherHint,
disabled,
}: LauncherSectionProps) {
const { t } = useTranslation()
return (
<section className="space-y-3">
<div className="space-y-4">
<Field
label={t("pages.config.server_port")}
hint={t("pages.config.server_port_hint")}
>
<Input
type="number"
min={1}
max={65535}
value={launcherForm.port}
disabled={disabled}
onChange={(e) => onFieldChange("port", e.target.value)}
/>
</Field>
<SwitchCardField
label={t("pages.config.lan_access")}
hint={t("pages.config.lan_access_hint")}
checked={launcherForm.publicAccess}
disabled={disabled}
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
/>
<Field
label={t("pages.config.allowed_cidrs")}
hint={t("pages.config.allowed_cidrs_hint")}
>
<Textarea
value={launcherForm.allowedCIDRsText}
disabled={disabled}
placeholder={t("pages.config.allowed_cidrs_placeholder")}
className="min-h-[88px]"
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
/>
</Field>
<p className="text-muted-foreground text-xs">{launcherHint}</p>
</div>
</section>
)
}
interface DevicesSectionProps {
form: CoreConfigForm
onFieldChange: UpdateCoreField
autoStartEnabled: boolean
autoStartHint: string
autoStartDisabled: boolean
onAutoStartChange: (checked: boolean) => void
}
export function DevicesSection({
form,
onFieldChange,
autoStartEnabled,
autoStartHint,
autoStartDisabled,
onAutoStartChange,
}: DevicesSectionProps) {
const { t } = useTranslation()
return (
<section className="space-y-3">
<div className="space-y-4">
<SwitchCardField
label={t("pages.config.devices_enabled")}
hint={t("pages.config.devices_enabled_hint")}
checked={form.devicesEnabled}
onCheckedChange={(checked) =>
onFieldChange("devicesEnabled", checked)
}
/>
<SwitchCardField
label={t("pages.config.monitor_usb")}
hint={t("pages.config.monitor_usb_hint")}
checked={form.monitorUSB}
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
/>
<SwitchCardField
label={t("pages.config.autostart_label")}
hint={autoStartHint}
checked={autoStartEnabled}
disabled={autoStartDisabled}
onCheckedChange={onAutoStartChange}
/>
</div>
</section>
)
}
export function AdvancedSection() {
const { t } = useTranslation()
return (
<section className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("pages.config.advanced_desc")}
</p>
<div>
<Button variant="outline" asChild>
<Link to="/config/raw">
<IconCode className="size-4" />
{t("pages.config.open_raw")}
</Link>
</Button>
</div>
</section>
)
}
@@ -0,0 +1,172 @@
export type JsonRecord = Record<string, unknown>
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
maxTokens: string
maxToolIterations: string
summarizeMessageThreshold: string
summarizeTokenPercent: string
dmScope: string
heartbeatEnabled: boolean
heartbeatInterval: string
devicesEnabled: boolean
monitorUSB: boolean
}
export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
}
export const DM_SCOPE_OPTIONS = [
{
value: "per-channel-peer",
labelKey: "pages.config.session_scope_per_channel_peer",
labelDefault: "Per Channel + Peer",
descKey: "pages.config.session_scope_per_channel_peer_desc",
descDefault: "Separate context for each user in each channel.",
},
{
value: "per-channel",
labelKey: "pages.config.session_scope_per_channel",
labelDefault: "Per Channel",
descKey: "pages.config.session_scope_per_channel_desc",
descDefault: "One shared context per channel.",
},
{
value: "per-peer",
labelKey: "pages.config.session_scope_per_peer",
labelDefault: "Per Peer",
descKey: "pages.config.session_scope_per_peer_desc",
descDefault: "One context per user across channels.",
},
{
value: "global",
labelKey: "pages.config.session_scope_global",
labelDefault: "Global",
descKey: "pages.config.session_scope_global_desc",
descDefault: "All messages share one global context.",
},
] as const
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
maxTokens: "32768",
maxToolIterations: "50",
summarizeMessageThreshold: "20",
summarizeTokenPercent: "75",
dmScope: "per-channel-peer",
heartbeatEnabled: true,
heartbeatInterval: "30",
devicesEnabled: false,
monitorUSB: true,
}
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
}
function asRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord
}
return {}
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asBool(value: unknown): boolean {
return value === true
}
function asNumberString(value: unknown, fallback: string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
if (typeof value === "string" && value.trim() !== "") {
return value
}
return fallback
}
export function buildFormFromConfig(config: unknown): CoreConfigForm {
const root = asRecord(config)
const agents = asRecord(root.agents)
const defaults = asRecord(agents.defaults)
const session = asRecord(root.session)
const heartbeat = asRecord(root.heartbeat)
const devices = asRecord(root.devices)
return {
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
restrictToWorkspace:
defaults.restrict_to_workspace === undefined
? EMPTY_FORM.restrictToWorkspace
: asBool(defaults.restrict_to_workspace),
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
maxToolIterations: asNumberString(
defaults.max_tool_iterations,
EMPTY_FORM.maxToolIterations,
),
summarizeMessageThreshold: asNumberString(
defaults.summarize_message_threshold,
EMPTY_FORM.summarizeMessageThreshold,
),
summarizeTokenPercent: asNumberString(
defaults.summarize_token_percent,
EMPTY_FORM.summarizeTokenPercent,
),
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
heartbeatEnabled:
heartbeat.enabled === undefined
? EMPTY_FORM.heartbeatEnabled
: asBool(heartbeat.enabled),
heartbeatInterval: asNumberString(
heartbeat.interval,
EMPTY_FORM.heartbeatInterval,
),
devicesEnabled:
devices.enabled === undefined
? EMPTY_FORM.devicesEnabled
: asBool(devices.enabled),
monitorUSB:
devices.monitor_usb === undefined
? EMPTY_FORM.monitorUSB
: asBool(devices.monitor_usb),
}
}
export function parseIntField(
rawValue: string,
label: string,
options: { min?: number; max?: number } = {},
): number {
const value = Number(rawValue)
if (!Number.isInteger(value)) {
throw new Error(`${label} must be an integer.`)
}
if (options.min !== undefined && value < options.min) {
throw new Error(`${label} must be >= ${options.min}.`)
}
if (options.max !== undefined && value > options.max) {
throw new Error(`${label} must be <= ${options.max}.`)
}
return value
}
export function parseCIDRText(raw: string): string[] {
if (!raw.trim()) {
return []
}
return raw
.split(/[\n,]/)
.map((v) => v.trim())
.filter((v) => v.length > 0)
}
@@ -0,0 +1,204 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
export function RawJsonPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery({
queryKey: ["config"],
queryFn: async () => {
const res = await fetch("/api/config")
if (!res.ok) {
throw new Error("Failed to fetch config")
}
return res.json()
},
})
const mutation = useMutation({
mutationFn: async (newConfig: string) => {
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: newConfig,
})
if (!res.ok) {
throw new Error("Failed to save config")
}
},
onSuccess: (_, submittedConfig) => {
toast.success(t("pages.config.save_success"))
try {
const savedConfig = JSON.parse(submittedConfig)
setLastSavedConfig(savedConfig)
setIsDirty(false)
queryClient.invalidateQueries({ queryKey: ["config"] })
} catch {
queryClient.invalidateQueries({ queryKey: ["config"] })
}
},
onError: () => {
toast.error(t("pages.config.save_error"))
},
})
const [editorValue, setEditorValue] = useState("")
const [isDirty, setIsDirty] = useState(false)
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
string,
unknown
> | null>(null)
const effectiveEditorValue =
editorValue || (config ? JSON.stringify(config, null, 2) : "")
const handleSave = () => {
try {
JSON.parse(effectiveEditorValue)
mutation.mutate(effectiveEditorValue)
} catch (error) {
toast.error(
t(
"pages.config.invalid_json",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
}
}
const handleFormat = () => {
try {
const formatted = JSON.stringify(
JSON.parse(effectiveEditorValue),
null,
2,
)
setEditorValue(formatted)
toast.success(t("pages.config.format_success"))
} catch (error) {
toast.error(
t(
"pages.config.format_error",
error instanceof Error ? error.message : "Invalid JSON format.",
),
)
}
}
const [showResetDialog, setShowResetDialog] = useState(false)
const confirmReset = () => {
if (lastSavedConfig) {
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
} else if (config) {
setEditorValue(JSON.stringify(config, null, 2))
}
setIsDirty(false)
toast.info(t("pages.config.reset_success"))
setShowResetDialog(false)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<p>{t("labels.loading")}</p>
</div>
) : (
<div className="space-y-3">
{isDirty && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes")}
</div>
)}
<div className="bg-muted/30 relative rounded-lg border">
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
<Textarea
value={effectiveEditorValue}
onChange={(e) => {
setEditorValue(e.target.value)
setIsDirty(true)
}}
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
placeholder={t("pages.config.json_placeholder")}
/>
</ScrollArea>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleFormat}
disabled={mutation.isPending}
>
{t("pages.config.format")}
</Button>
<AlertDialog
open={showResetDialog}
onOpenChange={setShowResetDialog}
>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={!isDirty}
onClick={() => setShowResetDialog(true)}
>
{t("common.reset")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.config.reset_confirm_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.config.reset_confirm_desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>
{t("common.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,108 @@
import {
IconKey,
IconLoader2,
IconPlayerStopFilled,
IconSparkles,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { OAuthProviderStatus } from "@/api/oauth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CredentialCard } from "./credential-card"
interface AnthropicCredentialCardProps {
status?: OAuthProviderStatus
activeAction: string
token: string
onTokenChange: (value: string) => void
onStopLoading: () => void
onSaveToken: () => void
onAskLogout: () => void
}
export function AnthropicCredentialCard({
status,
activeAction,
token,
onTokenChange,
onStopLoading,
onSaveToken,
onAskLogout,
}: AnthropicCredentialCardProps) {
const { t } = useTranslation()
const actionBusy = activeAction !== ""
const tokenLoading = activeAction === "anthropic:token"
const stopLabel = t("credentials.actions.stopLoading")
return (
<CredentialCard
title={
<span className="inline-flex items-center gap-2">
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
<IconSparkles className="size-3.5" />
</span>
<span>Anthropic</span>
</span>
}
description={t("credentials.providers.anthropic.description")}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
actions={
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
<div className="flex h-full flex-col gap-3">
<div className="flex h-full items-center gap-2">
<Input
value={token}
onChange={(e) => onTokenChange(e.target.value)}
type="password"
placeholder={t("credentials.fields.anthropicToken")}
/>
<Button
size="sm"
className="w-fit"
disabled={actionBusy || !token.trim()}
onClick={onSaveToken}
>
{tokenLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconKey className="size-4" />
{t("credentials.actions.saveToken")}
</Button>
{tokenLoading && (
<Button
size="icon-sm"
variant="ghost"
onClick={onStopLoading}
aria-label={stopLabel}
title={stopLabel}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
</div>
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "anthropic:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout")}
</Button>
) : null
}
/>
)
}
@@ -0,0 +1,106 @@
import {
IconBrandGoogle,
IconLoader2,
IconLockOpen,
IconPlayerStopFilled,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { OAuthProviderStatus } from "@/api/oauth"
import { Button } from "@/components/ui/button"
import { CredentialCard } from "./credential-card"
interface AntigravityCredentialCardProps {
status?: OAuthProviderStatus
activeAction: string
onStopLoading: () => void
onStartBrowserOAuth: () => void
onAskLogout: () => void
}
export function AntigravityCredentialCard({
status,
activeAction,
onStopLoading,
onStartBrowserOAuth,
onAskLogout,
}: AntigravityCredentialCardProps) {
const { t } = useTranslation()
const actionBusy = activeAction !== ""
const browserLoading = activeAction === "google-antigravity:browser"
return (
<CredentialCard
title={
<span className="inline-flex items-center gap-2">
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
<IconBrandGoogle className="size-3.5" />
</span>
<span>Google Antigravity</span>
</span>
}
description={t("credentials.providers.antigravity.description")}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
details={
<div className="space-y-1">
{status?.email && (
<p>
{t("credentials.labels.email")}: {status.email}
</p>
)}
{status?.project_id && (
<p>
{t("credentials.labels.project")}: {status.project_id}
</p>
)}
</div>
}
actions={
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartBrowserOAuth}
>
{browserLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconLockOpen className="size-4" />
{t("credentials.actions.browser")}
</Button>
{browserLoading && (
<Button
size="icon-xs"
variant="secondary"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-3" />
</Button>
)}
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "google-antigravity:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout")}
</Button>
) : null
}
/>
)
}
@@ -0,0 +1,44 @@
import type { ReactNode } from "react"
import type { OAuthProviderStatus } from "@/api/oauth"
import { ProviderStatusLine } from "./provider-status-line"
interface CredentialCardProps {
title: ReactNode
description: string
status: OAuthProviderStatus["status"]
authMethod?: string
details?: ReactNode
actions: ReactNode
footer?: ReactNode
}
export function CredentialCard({
title,
description,
status,
authMethod,
details,
actions,
footer,
}: CredentialCardProps) {
return (
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
<div className="min-h-16">
<h3 className="text-base font-semibold">{title}</h3>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
</div>
<ProviderStatusLine status={status} authMethod={authMethod} />
<div className="text-muted-foreground mt-3 min-h-11 text-xs leading-5">
{details}
</div>
<div className="mt-auto flex flex-col gap-4 pt-4">
<div className="min-h-[112px]">{actions}</div>
<div className="min-h-8">{footer}</div>
</div>
</section>
)
}
@@ -0,0 +1,127 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { useCredentialsPage } from "@/hooks/use-credentials-page"
import { AnthropicCredentialCard } from "./anthropic-credential-card"
import { AntigravityCredentialCard } from "./antigravity-credential-card"
import { DeviceCodeSheet } from "./device-code-sheet"
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
import { OpenAICredentialCard } from "./openai-credential-card"
export function CredentialsPage() {
const { t } = useTranslation()
const {
loading,
error,
activeAction,
activeFlow,
flowHint,
openAIToken,
anthropicToken,
openaiStatus,
anthropicStatus,
antigravityStatus,
logoutDialogOpen,
logoutConfirmProvider,
logoutProviderLabel,
deviceSheetOpen,
deviceFlow,
setOpenAIToken,
setAnthropicToken,
startBrowserOAuth,
startOpenAIDeviceCode,
stopLoading,
saveToken,
askLogout,
handleConfirmLogout,
handleLogoutDialogOpenChange,
handleDeviceSheetOpenChange,
} = useCredentialsPage()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.credentials")} />
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
<div className="pt-2">
<p className="text-muted-foreground text-sm">
{t("credentials.description")}
</p>
</div>
{error && (
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
{activeFlow && (
<div className="bg-muted mt-4 rounded-lg border px-4 py-3 text-sm">
<p className="font-medium">{t("credentials.flow.current")}</p>
<p className="text-muted-foreground mt-1">{flowHint}</p>
</div>
)}
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-10 text-sm">
<IconLoader2 className="size-4 animate-spin" />
{t("credentials.loading")}
</div>
) : (
<div className="grid grid-cols-1 gap-4 py-5 lg:auto-rows-fr lg:grid-cols-3">
<OpenAICredentialCard
status={openaiStatus}
activeAction={activeAction}
token={openAIToken}
onTokenChange={setOpenAIToken}
onStartBrowserOAuth={() => void startBrowserOAuth("openai")}
onStartDeviceCode={() => void startOpenAIDeviceCode()}
onStopLoading={stopLoading}
onSaveToken={() => void saveToken("openai", openAIToken.trim())}
onAskLogout={() => askLogout("openai")}
/>
<AnthropicCredentialCard
status={anthropicStatus}
activeAction={activeAction}
token={anthropicToken}
onTokenChange={setAnthropicToken}
onStopLoading={stopLoading}
onSaveToken={() =>
void saveToken("anthropic", anthropicToken.trim())
}
onAskLogout={() => askLogout("anthropic")}
/>
<AntigravityCredentialCard
status={antigravityStatus}
activeAction={activeAction}
onStopLoading={stopLoading}
onStartBrowserOAuth={() =>
void startBrowserOAuth("google-antigravity")
}
onAskLogout={() => askLogout("google-antigravity")}
/>
</div>
)}
</div>
<LogoutConfirmDialog
open={logoutDialogOpen}
providerLabel={logoutProviderLabel}
isSubmitting={activeAction === `${logoutConfirmProvider}:logout`}
onOpenChange={handleLogoutDialogOpenChange}
onConfirm={handleConfirmLogout}
/>
<DeviceCodeSheet
open={deviceSheetOpen}
flow={deviceFlow}
flowHint={flowHint}
onOpenChange={handleDeviceSheetOpenChange}
/>
</div>
)
}
@@ -0,0 +1,92 @@
import { IconRefresh } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { OAuthFlowState } from "@/api/oauth"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface DeviceCodeSheetProps {
open: boolean
flow: OAuthFlowState | null
flowHint: string
onOpenChange: (open: boolean) => void
}
export function DeviceCodeSheet({
open,
flow,
flowHint,
onOpenChange,
}: DeviceCodeSheetProps) {
const { t } = useTranslation()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="data-[side=right]:!w-full data-[side=right]:sm:!w-[480px] data-[side=right]:sm:!max-w-[480px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle>{t("credentials.device.title")}</SheetTitle>
<SheetDescription>
{t("credentials.device.description")}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-6 py-5">
<div>
<p className="text-muted-foreground text-xs uppercase">
{t("credentials.device.code")}
</p>
<p className="mt-1 rounded-md border px-3 py-2 font-mono text-lg font-semibold tracking-wide">
{flow?.user_code || "-"}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase">
{t("credentials.device.url")}
</p>
<a
href={flow?.verify_url || "#"}
target="_blank"
rel="noreferrer"
className="text-primary mt-1 block text-sm break-all underline"
>
{flow?.verify_url || "-"}
</a>
</div>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<IconRefresh className="size-4" />
{t("credentials.device.polling")}
</div>
{flow && (
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
{flowHint}
</div>
)}
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button asChild disabled={!flow?.verify_url}>
<a href={flow?.verify_url || "#"} target="_blank" rel="noreferrer">
{t("credentials.device.open")}
</a>
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,57 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface LogoutConfirmDialogProps {
open: boolean
providerLabel: string
isSubmitting: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void | Promise<void>
}
export function LogoutConfirmDialog({
open,
providerLabel,
isSubmitting,
onOpenChange,
onConfirm,
}: LogoutConfirmDialogProps) {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("credentials.logoutDialog.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"credentials.logoutDialog.description",
"This will remove your saved credential for {{provider}}.",
{ provider: providerLabel },
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} variant="destructive">
{isSubmitting && <IconLoader2 className="size-4 animate-spin" />}
{t("credentials.actions.logout")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,162 @@
import {
IconBrandOpenai,
IconClockHour4,
IconKey,
IconLoader2,
IconPlayerStopFilled,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { OAuthProviderStatus } from "@/api/oauth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CredentialCard } from "./credential-card"
interface OpenAICredentialCardProps {
status?: OAuthProviderStatus
activeAction: string
token: string
onTokenChange: (value: string) => void
onStartBrowserOAuth: () => void
onStartDeviceCode: () => void
onStopLoading: () => void
onSaveToken: () => void
onAskLogout: () => void
}
export function OpenAICredentialCard({
status,
activeAction,
token,
onTokenChange,
onStartBrowserOAuth,
onStartDeviceCode,
onStopLoading,
onSaveToken,
onAskLogout,
}: OpenAICredentialCardProps) {
const { t } = useTranslation()
const actionBusy = activeAction !== ""
const browserLoading = activeAction === "openai:browser"
const deviceLoading = activeAction === "openai:device"
const oauthLoading = browserLoading || deviceLoading
const tokenLoading = activeAction === "openai:token"
return (
<CredentialCard
title={
<span className="inline-flex items-center gap-2">
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
<IconBrandOpenai className="size-3.5" />
</span>
<span>OpenAI</span>
</span>
}
description={t("credentials.providers.openai.description")}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
details={
status?.account_id ? (
<p>
{t("credentials.labels.account")}: {status.account_id}
</p>
) : null
}
actions={
<div className="border-muted flex h-[120px] flex-col rounded-lg border p-3">
<div className="flex h-full flex-col gap-3">
<div className="min-h-8">
<div className="flex flex-nowrap items-center gap-2 overflow-x-auto">
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartBrowserOAuth}
>
{browserLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconBrandOpenai className="size-4" />
{t("credentials.actions.browser")}
</Button>
{oauthLoading && !deviceLoading && (
<Button
size="icon-xs"
variant="secondary"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartDeviceCode}
>
{deviceLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconClockHour4 className="size-4" />
{t("credentials.actions.deviceCode")}
</Button>
</div>
</div>
<div className="min-h-9 flex-1">
<div className="flex h-full items-center gap-2">
<Input
value={token}
onChange={(e) => onTokenChange(e.target.value)}
type="password"
placeholder={t("credentials.fields.openaiToken")}
/>
<Button
size="sm"
disabled={actionBusy || !token.trim()}
onClick={onSaveToken}
>
{tokenLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconKey className="size-4" />
{t("credentials.actions.saveToken")}
</Button>
{tokenLoading && (
<Button
size="icon-sm"
variant="ghost"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
</div>
</div>
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "openai:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout")}
</Button>
) : null
}
/>
)
}
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next"
import type { OAuthProviderStatus } from "@/api/oauth"
interface ProviderStatusLineProps {
status: OAuthProviderStatus["status"]
authMethod?: string
}
export function ProviderStatusLine({
status,
authMethod,
}: ProviderStatusLineProps) {
const { t } = useTranslation()
const style =
status === "connected"
? "bg-green-500/10 text-green-700 dark:text-green-300"
: status === "needs_refresh"
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: status === "expired"
? "bg-red-500/10 text-red-700 dark:text-red-300"
: "bg-muted text-muted-foreground"
return (
<div className="flex items-center justify-between gap-2">
<span className={`rounded px-2 py-1 text-xs font-medium ${style}`}>
{status === "connected"
? t("credentials.status.connected")
: status === "needs_refresh"
? t("credentials.status.needsRefresh")
: status === "expired"
? t("credentials.status.expired")
: t("credentials.status.notLoggedIn")}
</span>
{authMethod && (
<span className="text-muted-foreground text-xs uppercase">
{authMethod}
</span>
)}
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,27 @@
import { IconMenu2 } from "@tabler/icons-react"
import type { ReactNode } from "react"
import { SidebarTrigger } from "@/components/ui/sidebar"
interface PageHeaderProps {
title: string
titleExtra?: ReactNode
children?: ReactNode
}
export function PageHeader({ title, titleExtra, children }: PageHeaderProps) {
return (
<div className="flex h-14 shrink-0 items-center justify-between px-6 pt-2">
<div className="flex items-center gap-4">
<SidebarTrigger className="border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5">
<IconMenu2 />
</SidebarTrigger>
<h2 className="text-foreground/90 text-xl font-medium tracking-tight">
{title}
</h2>
{titleExtra}
</div>
{children && <div className="flex items-center gap-2">{children}</div>}
</div>
)
}
@@ -0,0 +1,16 @@
export function maskedSecretPlaceholder(value: unknown, fallback = ""): string {
const secret = typeof value === "string" ? value.trim() : ""
if (!secret) {
return fallback
}
if (secret.length < 7) {
const first = secret[0]
const last = secret[secret.length - 1]
return `${first}***${last}`
}
const prefix = secret.slice(0, Math.min(3, secret.length))
const suffix = secret.slice(-Math.min(4, secret.length))
return `${prefix}***${suffix}`
}
+158
View File
@@ -0,0 +1,158 @@
import { IconChevronDown, IconEye, IconEyeOff } from "@tabler/icons-react"
import { type ReactNode, useState } from "react"
import { useTranslation } from "react-i18next"
import {
FieldDescription,
FieldLabel,
Field as UiField,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
interface FieldProps {
label: string
hint?: string
error?: string
required?: boolean
children: ReactNode
}
export function Field({ label, hint, error, required, children }: FieldProps) {
return (
<UiField className="gap-2.5">
<div className="space-y-1">
<FieldLabel>
{label}
{required && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
{hint && (
<FieldDescription className="text-xs leading-normal">
{hint}
</FieldDescription>
)}
</div>
{children}
{error && (
<FieldDescription className="text-destructive text-xs leading-normal">
{error}
</FieldDescription>
)}
</UiField>
)
}
interface KeyInputProps {
value: string
onChange: (v: string) => void
placeholder?: string
}
export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
const [show, setShow] = useState(false)
return (
<div className="relative">
<Input
type={show ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="pr-10"
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
tabIndex={-1}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2 transition-colors"
>
{show ? (
<IconEyeOff className="size-4" />
) : (
<IconEye className="size-4" />
)}
</button>
</div>
)
}
interface SwitchCardFieldProps {
label: string
hint?: string
error?: string
checked: boolean
onCheckedChange: (checked: boolean) => void
ariaLabel?: string
disabled?: boolean
children?: ReactNode
}
export function SwitchCardField({
label,
hint,
error,
checked,
onCheckedChange,
ariaLabel,
disabled,
children,
}: SwitchCardFieldProps) {
return (
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{label}</p>
{hint && (
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
{hint}
</p>
)}
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-label={ariaLabel ?? label}
/>
</div>
{children && <div className="mt-3">{children}</div>}
{error && (
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
)}
</div>
)
}
interface AdvancedSectionProps {
children: ReactNode
}
export function AdvancedSection({ children }: AdvancedSectionProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<div className="border-border/50 rounded-lg border">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="hover:bg-muted/40 flex w-full items-center justify-between rounded-lg px-4 py-3 transition-colors"
>
<span className="text-muted-foreground text-sm">
{t("models.advanced.toggle")}
</span>
<IconChevronDown
className={[
"text-muted-foreground size-4 transition-transform duration-200",
open ? "rotate-180" : "",
].join(" ")}
/>
</button>
{open && (
<div className="border-border/30 space-y-5 border-t px-4 pt-4 pb-4">
{children}
</div>
)}
</div>
)
}
@@ -0,0 +1,197 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-normal font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconCheck, IconChevronRight } from "@tabler/icons-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+236
View File
@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-3 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+190
View File
@@ -0,0 +1,190 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<IconSelector className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<IconChevronUp
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<IconChevronDown
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+144
View File
@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<IconX
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+700
View File
@@ -0,0 +1,700 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { IconLayoutSidebar } from "@tabler/icons-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<IconLayoutSidebar />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }