mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
refactor(settings): remove wrapper layers, dead props and unused dictionary keys
- components/model-selector.tsx uses Dialog, Command and Tooltip directly; delete components/ai-elements/model-selector.tsx and components/button-with-tooltip.tsx; one renderGroups for both lists; no "Server · " prefix round trip - components/ui: drop CommandDialog, CommandShortcut and six unused shadcn exports - model-config-dialog: no type-to-confirm on provider delete; provider-credentials-fields: drop footer and bedrockApiKey - hooks/use-model-config.ts and lib/types/model-config.ts: remove the 2025-12 single-provider migration, resetConfig, selectedModel and fields nothing reads - components/chat/TemplateFormDialog.tsx replaces the create and edit dialogs; TemplatePanel drops the setInput fallback, the templatesChanged listener, dictionary fallbacks and formatLastUsed; lib/template-storage.ts drops getTemplate, clickCount and indexes - reasoning.tsx becomes one component; shimmer uses a CSS keyframe (motion and @radix-ui/react-use-controllable-state removed) - error-toast.tsx deleted in favour of toast.error; ValidationCard drops the idle status; dev-xml-simulator uses English literals - admin: no enum setting type, shared types from lib/admin (AdminProviderInput stays exported for that), ENABLE_HISTORY_XML_REPLACE entry removed - app/globals.css: drop check-pop, --ease-spring, --duration-normal and the shadow aliases - dictionaries: 81 unused keys removed from all four languages
This commit is contained in:
@@ -1,238 +0,0 @@
|
||||
import { Cloud } from "lucide-react"
|
||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => {
|
||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// Show shadow if there is more content below
|
||||
// Using a small threshold to handle fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Initial check
|
||||
checkScroll()
|
||||
|
||||
// Event listeners
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
|
||||
// Observe content changes (e.g. async loading of items)
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => {
|
||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||
if (provider === "amazon-bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSectionHeaderProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ModelSelectorSectionHeader = ({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
}: ModelSelectorSectionHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -1,186 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Shimmer } from "./shimmer"
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number | undefined
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
export type ReasoningProps = {
|
||||
className?: string
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
/** Open while streaming, then folds once about a second after it ends */
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
children: string
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
})
|
||||
export function Reasoning({
|
||||
className,
|
||||
isStreaming = false,
|
||||
defaultOpen = true,
|
||||
getThinkingMessage,
|
||||
children,
|
||||
}: ReasoningProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
const [duration, setDuration] = useState<number>()
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration])
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [isStreaming, startTime])
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
// Auto-close once when streaming ends, after a short delay so the user
|
||||
// sees the content
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
}
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={setIsOpen}
|
||||
open={isOpen}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{children}</div>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
)
|
||||
|
||||
Reasoning.displayName = "Reasoning"
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
||||
ReasoningContent.displayName = "ReasoningContent"
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 text-sm data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
|
||||
<div className="whitespace-pre-wrap">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import { type CSSProperties, type ElementType, memo, useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextShimmerProps = {
|
||||
@@ -25,39 +18,29 @@ const ShimmerComponent = ({
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
)
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread],
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
<Component
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"animate-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className,
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
"--shimmer-duration": `${duration}s`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type React from "react"
|
||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface ButtonWithTooltipProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
tooltipContent: string
|
||||
children: React.ReactNode
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function ButtonWithTooltip({
|
||||
tooltipContent,
|
||||
children,
|
||||
...buttonProps
|
||||
}: ButtonWithTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button {...buttonProps}>{children}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Plus } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
createTemplate,
|
||||
type TemplateCreateInput,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialPrompt = "",
|
||||
}: TemplateCreateDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset form when dialog opens with the latest initialPrompt
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setPrompt(initialPrompt)
|
||||
setPinned(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const input: TemplateCreateInput = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const template = await createTemplate(input)
|
||||
if (template) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.createFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create template:", err)
|
||||
setError(dict.templates.createFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
{dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt" className="text-foreground">
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-foreground">
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Edit2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Bookmark, Edit2, Plus } from "lucide-react"
|
||||
import { useEffect, useId, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,22 +16,32 @@ import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||
import {
|
||||
createTemplate,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
interface TemplateFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
template: Template | null
|
||||
onSuccess: () => void
|
||||
/** The template to edit; without one the dialog creates a new template */
|
||||
template?: Template | null
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
// One form for creating and editing a template
|
||||
export function TemplateFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateEditDialogProps) {
|
||||
template,
|
||||
initialPrompt = "",
|
||||
}: TemplateFormDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const id = useId()
|
||||
const isEdit = !!template
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
@@ -39,32 +49,22 @@ export function TemplateEditDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||
// Fill the form each time the dialog opens, dropping cancelled edits
|
||||
useEffect(() => {
|
||||
if (open && template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
if (!open) return
|
||||
setTitle(template?.title || "")
|
||||
setDescription(template?.description || "")
|
||||
setPrompt(template ? template.prompt || "" : initialPrompt)
|
||||
setPinned(template?.pinned || false)
|
||||
setError(null)
|
||||
}, [open, template, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// any form this dialog may be rendered in
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
if (!template) return
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
@@ -74,40 +74,59 @@ export function TemplateEditDialog({
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const failed = isEdit
|
||||
? dict.templates.updateFailed
|
||||
: dict.templates.createFailed
|
||||
try {
|
||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||
const fields = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || template.title,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const updated = await updateTemplate(template.id, updates)
|
||||
if (updated) {
|
||||
// An emptied title keeps the old one when editing; a new template
|
||||
// gets a default title from its prompt
|
||||
const result = template
|
||||
? await updateTemplate(template.id, {
|
||||
...fields,
|
||||
title: title.trim() || template.title,
|
||||
})
|
||||
: await createTemplate({
|
||||
...fields,
|
||||
title: title.trim() || undefined,
|
||||
})
|
||||
if (result) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.updateFailed)
|
||||
setError(failed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update template:", err)
|
||||
setError(dict.templates.updateFailed)
|
||||
console.error("Failed to save template:", err)
|
||||
setError(failed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
{dict.templates.editTitle}
|
||||
{isEdit ? (
|
||||
<Edit2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5" />
|
||||
)}
|
||||
{isEdit
|
||||
? dict.templates.editTitle
|
||||
: dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.editDescription}
|
||||
{isEdit
|
||||
? dict.templates.editDescription
|
||||
: dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -115,14 +134,14 @@ export function TemplateEditDialog({
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-prompt"
|
||||
htmlFor={`${id}-prompt`}
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
id={`${id}-prompt`}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
@@ -134,13 +153,13 @@ export function TemplateEditDialog({
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-title"
|
||||
htmlFor={`${id}-title`}
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
id={`${id}-title`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
@@ -153,13 +172,13 @@ export function TemplateEditDialog({
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-description"
|
||||
htmlFor={`${id}-description`}
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
id={`${id}-description`}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
@@ -173,7 +192,7 @@ export function TemplateEditDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="edit-pinned"
|
||||
htmlFor={`${id}-pinned`}
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
@@ -184,7 +203,7 @@ export function TemplateEditDialog({
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit-pinned"
|
||||
id={`${id}-pinned`}
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
@@ -208,7 +227,9 @@ export function TemplateEditDialog({
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.common.save}
|
||||
: isEdit
|
||||
? dict.common.save
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -23,63 +24,33 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatRelativeTime } from "@/lib/relative-time"
|
||||
import {
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
exportTemplates,
|
||||
getAllTemplates,
|
||||
importTemplates,
|
||||
incrementClickCount,
|
||||
incrementRunCount,
|
||||
searchTemplates,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
import { TemplateFormDialog } from "./TemplateFormDialog"
|
||||
|
||||
interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
/** Whether it was sent */
|
||||
onSendTemplate?: (template: Template) => Promise<boolean>
|
||||
onSendTemplate: (template: Template) => Promise<boolean>
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||
if (!timestamp) return neverUsedText
|
||||
const now = Date.now()
|
||||
const diffMs = now - timestamp
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||
if (diffMins < 1) return rtf.format(0, "minute")
|
||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||
} catch {
|
||||
// Fallback if Intl.RelativeTimeFormat is not available
|
||||
if (diffMins < 1) return "<1m ago"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function TemplatePanel({
|
||||
setInput,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: TemplatePanelProps) {
|
||||
const dict = useDictionary()
|
||||
const params = useParams<{ lang: string }>()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
@@ -111,10 +82,6 @@ export function TemplatePanel({
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||
window.addEventListener("templatesChanged", loadTemplates)
|
||||
return () =>
|
||||
window.removeEventListener("templatesChanged", loadTemplates)
|
||||
}, [loadTemplates])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
@@ -133,7 +100,7 @@ export function TemplatePanel({
|
||||
const handleDuplicate = async (template: Template) => {
|
||||
const duplicated = await duplicateTemplate(
|
||||
template.id,
|
||||
dict.templates.copySuffix || "(copy)",
|
||||
dict.templates.copySuffix,
|
||||
)
|
||||
if (duplicated) {
|
||||
loadTemplates()
|
||||
@@ -179,23 +146,13 @@ export function TemplatePanel({
|
||||
|
||||
// Actually send the template
|
||||
const sendTemplate = async (template: Template) => {
|
||||
if (onSendTemplate) {
|
||||
// Sent first, while the chat is the one it was clicked in (the
|
||||
// counts wait for storage). A run only when it was sent (not
|
||||
// while a message is still being sent, or attachments read).
|
||||
const sending = onSendTemplate(template)
|
||||
// Increment click count only when actually sending
|
||||
await incrementClickCount(template.id)
|
||||
if (await sending) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
}
|
||||
} else {
|
||||
await incrementClickCount(template.id)
|
||||
// Fallback: just fill the input if no send callback provided
|
||||
setInput(template.prompt)
|
||||
// A run only when it was sent (not while a message is still being
|
||||
// sent, or attachments read)
|
||||
if (await onSendTemplate(template)) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
}
|
||||
setConfirmSendDialogOpen(false)
|
||||
setTemplateToSend(null)
|
||||
@@ -218,7 +175,7 @@ export function TemplatePanel({
|
||||
if (templates.length === 0) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.exportEmpty || "No templates to export",
|
||||
text: dict.templates.exportEmpty,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -260,8 +217,7 @@ export function TemplatePanel({
|
||||
if (!file) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text:
|
||||
dict.templates.importNoFile || "Please select a JSON file",
|
||||
text: dict.templates.importNoFile,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -370,7 +326,7 @@ export function TemplatePanel({
|
||||
<div className="mt-3">{importMessageBox}</div>
|
||||
)}
|
||||
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
@@ -500,7 +456,7 @@ export function TemplatePanel({
|
||||
<div className="relative shrink-0">
|
||||
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||
{template.runCount > 0
|
||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
|
||||
: dict.templates.neverUsed}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
@@ -517,10 +473,8 @@ export function TemplatePanel({
|
||||
}`}
|
||||
title={
|
||||
template.pinned
|
||||
? dict.templates
|
||||
.unpin || "Unpin"
|
||||
: dict.templates.pin ||
|
||||
"Pin"
|
||||
? dict.templates.unpin
|
||||
: dict.templates.pin
|
||||
}
|
||||
>
|
||||
<Bookmark
|
||||
@@ -545,10 +499,7 @@ export function TemplatePanel({
|
||||
handleDuplicate(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||
title={
|
||||
dict.templates.duplicate ||
|
||||
"Duplicate"
|
||||
}
|
||||
title={dict.templates.duplicate}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -570,13 +521,13 @@ export function TemplatePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<TemplateEditDialog
|
||||
<TemplateFormDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
template={templateToEdit}
|
||||
@@ -591,12 +542,10 @@ export function TemplatePanel({
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.deleteTitle ||
|
||||
"Delete this template?"}
|
||||
{dict.templates.deleteTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.deleteDescription ||
|
||||
"This will permanently delete this template. This action cannot be undone."}
|
||||
{dict.templates.deleteDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -621,12 +570,10 @@ export function TemplatePanel({
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.confirmSendTitle ||
|
||||
"Replace current input?"}
|
||||
{dict.templates.confirmSendTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.confirmSendDescription ||
|
||||
"You have unsent content in the input. Sending this template will replace it."}
|
||||
{dict.templates.confirmSendDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -634,8 +581,7 @@ export function TemplatePanel({
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSend}>
|
||||
{dict.templates.confirmSendButton ||
|
||||
"Send Template"}
|
||||
{dict.templates.confirmSendButton}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
|
||||
export type ValidationStatus =
|
||||
| "idle"
|
||||
| "capturing"
|
||||
| "validating"
|
||||
| "success"
|
||||
@@ -159,13 +158,10 @@ export function ValidationCard({
|
||||
color: "text-gray-600 bg-gray-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const statusDisplay = getStatusDisplay()
|
||||
if (!statusDisplay || state.status === "idle") return null
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
|
||||
@@ -18,9 +18,8 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { FilePreviewList } from "@/components/file-preview-list"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import {
|
||||
@@ -46,24 +45,16 @@ import { useUiStore } from "@/stores/ui-store"
|
||||
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
function showErrorToast(message: React.ReactNode) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
||||
),
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[], dict: any) {
|
||||
if (errors.length === 0) return
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(
|
||||
toast.error(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
return
|
||||
}
|
||||
showErrorToast(
|
||||
toast.error(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">
|
||||
{formatMessage(dict.errors.filesRejected, {
|
||||
@@ -83,6 +74,7 @@ function showValidationErrors(errors: string[], dict: any) {
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,10 +269,11 @@ export function Composer({
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
showErrorToast(
|
||||
toast.error(
|
||||
<span className="text-muted-foreground">
|
||||
{err instanceof Error ? err.message : dict.url.failed}
|
||||
</span>,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
} finally {
|
||||
setIsExtractingUrl(false)
|
||||
@@ -519,7 +512,7 @@ export function Composer({
|
||||
onSubmit={handleUrlExtract}
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open={saveTemplateOpen}
|
||||
onOpenChange={setSaveTemplateOpen}
|
||||
onSuccess={() => setSaveTemplateOpen(false)}
|
||||
@@ -532,10 +525,6 @@ export function Composer({
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
||||
<TemplatePanel
|
||||
setInput={(value) => {
|
||||
setInput(value)
|
||||
setTemplatesOpen(false)
|
||||
}}
|
||||
currentInput={input}
|
||||
onSendTemplate={(template) => {
|
||||
setTemplatesOpen(false)
|
||||
|
||||
@@ -16,13 +16,9 @@ import {
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { Reasoning } from "@/components/ai-elements/reasoning"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||
import type { ToolPartLike } from "@/components/chat/types"
|
||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
@@ -291,7 +287,7 @@ function UserMessage({
|
||||
{text && <MessageText text={text} />}
|
||||
</div>
|
||||
{saveTemplate && (
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||
onSuccess={() => setSaveTemplate(false)}
|
||||
@@ -398,13 +394,9 @@ function AssistantMessage({
|
||||
className="w-full"
|
||||
isStreaming={isStreamingReasoning}
|
||||
defaultOpen={!isRestored}
|
||||
getThinkingMessage={thinkingMessage}
|
||||
>
|
||||
<ReasoningTrigger
|
||||
getThinkingMessage={thinkingMessage}
|
||||
/>
|
||||
<ReasoningContent>
|
||||
{(part as { text: string }).text}
|
||||
</ReasoningContent>
|
||||
{(part as { text: string }).text}
|
||||
</Reasoning>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
|
||||
interface DevXmlSimulatorProps {
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||
onDisplayChart: (xml: string) => void
|
||||
onShowQuotaToast?: () => void
|
||||
}
|
||||
|
||||
export function DevXmlSimulator({
|
||||
setMessages,
|
||||
onDisplayChart,
|
||||
onShowQuotaToast,
|
||||
}: DevXmlSimulatorProps) {
|
||||
const dict = useDictionary()
|
||||
const [devXml, setDevXml] = useState("")
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: dict.dev.simulatingMessage,
|
||||
text: "[Dev] Simulating XML streaming",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -230,7 +226,7 @@ export function DevXmlSimulator({
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].state = "output-available"
|
||||
lastMsg.parts[0].output = dict.dev.successMessage
|
||||
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||
lastMsg.parts[0].input = { xml }
|
||||
}
|
||||
return updated
|
||||
@@ -247,12 +243,12 @@ export function DevXmlSimulator({
|
||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||
<details>
|
||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||
{dict.dev.title}
|
||||
Dev: XML Streaming Simulator
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.preset}
|
||||
Preset:
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
{dict.dev.selectPreset}
|
||||
Select a preset...
|
||||
</option>
|
||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
|
||||
onClick={() => setDevXml("")}
|
||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||
>
|
||||
{dict.dev.clear}
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={devXml}
|
||||
onChange={(e) => setDevXml(e.target.value)}
|
||||
placeholder={dict.dev.placeholder}
|
||||
placeholder="Paste mxCell XML here or select a preset..."
|
||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.interval}
|
||||
Interval:
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.chars}
|
||||
Chars:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -332,8 +328,8 @@ export function DevXmlSimulator({
|
||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSimulating
|
||||
? dict.dev.streaming
|
||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
? "Streaming..."
|
||||
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
</button>
|
||||
{isSimulating && (
|
||||
<button
|
||||
@@ -343,16 +339,7 @@ export function DevXmlSimulator({
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
{dict.dev.stop}
|
||||
</button>
|
||||
)}
|
||||
{onShowQuotaToast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowQuotaToast}
|
||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
>
|
||||
{dict.dev.testQuotaToast}
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
interface ErrorToastProps {
|
||||
message: React.ReactNode
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
tabIndex={0}
|
||||
onClick={onDismiss}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-destructive"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -164,7 +163,6 @@ export function ModelConfigDialog({
|
||||
)
|
||||
const [customModelInput, setCustomModelInput] = useState("")
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("")
|
||||
// Models whose test is running (they are all tested at once)
|
||||
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
@@ -441,7 +439,6 @@ export function ModelConfigDialog({
|
||||
setFetchModelsError("")
|
||||
updates = {
|
||||
...updates,
|
||||
validated: false,
|
||||
models: selectedProvider.models.map((m) => ({
|
||||
...m,
|
||||
validated: undefined,
|
||||
@@ -659,10 +656,6 @@ export function ModelConfigDialog({
|
||||
return
|
||||
}
|
||||
|
||||
// A model whose id changed was not tested
|
||||
if (allValid && !idChanged) {
|
||||
updateProvider(selectedProviderId, { validated: true })
|
||||
}
|
||||
markRejected(selectedProviderId, askedWith, rejected)
|
||||
// The status line is about the provider shown now
|
||||
if (selectedProviderIdRef.current !== selectedProviderId) return
|
||||
@@ -1004,7 +997,6 @@ export function ModelConfigDialog({
|
||||
name={provider.name}
|
||||
baseUrl={provider.baseUrl}
|
||||
awsRegion={provider.awsRegion}
|
||||
bedrockApiKey
|
||||
settingsLayout
|
||||
bedrockFilled={{
|
||||
apiKey: !!provider.apiKey,
|
||||
@@ -1389,10 +1381,7 @@ export function ModelConfigDialog({
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteConfirmOpen(open)
|
||||
if (!open) setDeleteConfirmText("")
|
||||
}}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
>
|
||||
<AlertDialogContent className="border-destructive/30">
|
||||
<AlertDialogHeader>
|
||||
@@ -1410,39 +1399,11 @@ export function ModelConfigDialog({
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{selectedProvider &&
|
||||
selectedProvider.models.length >= 3 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Label
|
||||
htmlFor="delete-confirm"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{formatMessage(t.typeToConfirm, {
|
||||
name: providerName(selectedProvider),
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) =>
|
||||
setDeleteConfirmText(e.target.value)
|
||||
}
|
||||
placeholder={t.typeProviderName}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteProvider}
|
||||
disabled={
|
||||
selectedProvider &&
|
||||
selectedProvider.models.length >= 3 &&
|
||||
deleteConfirmText !==
|
||||
providerName(selectedProvider)
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t.delete}
|
||||
</AlertDialogAction>
|
||||
|
||||
+368
-334
@@ -10,28 +10,38 @@ import {
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelector as ModelSelectorRoot,
|
||||
ModelSelectorSectionHeader,
|
||||
ModelSelectorSeparator,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_LOGO_MAP,
|
||||
} from "@/lib/types/model-config"
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
@@ -44,25 +54,22 @@ interface ModelSelectorProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ provider: string; models: FlattenedModel[] }
|
||||
>()
|
||||
): Map<string, ProviderGroup> {
|
||||
const groups = new Map<string, ProviderGroup>()
|
||||
for (const model of models) {
|
||||
// For server models, strip "Server · " prefix for cleaner grouping
|
||||
const key =
|
||||
model.source === "server"
|
||||
? model.providerLabel.replace(/^Server · /, "")
|
||||
: model.providerLabel
|
||||
const existing = groups.get(key)
|
||||
const existing = groups.get(model.providerLabel)
|
||||
if (existing) {
|
||||
existing.models.push(model)
|
||||
} else {
|
||||
groups.set(key, { provider: model.provider, models: [model] })
|
||||
groups.set(model.providerLabel, {
|
||||
provider: model.provider,
|
||||
models: [model],
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
@@ -88,6 +95,72 @@ export function shortModelName(id: string): string {
|
||||
return short
|
||||
}
|
||||
|
||||
// The command list with a shadow at the bottom while more items are below
|
||||
function ModelList({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof CommandList>) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// A small threshold handles fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
checkScroll()
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
// Content changes, e.g. the search filtering items
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ icon, label }: { icon: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1">
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
@@ -146,6 +219,52 @@ export function ModelSelector({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// One group per provider; `trailing` is the small label at the end of a
|
||||
// row ("Default" for server models, the test state for user models)
|
||||
const renderGroups = (
|
||||
groups: Map<string, ProviderGroup>,
|
||||
keyPrefix: "server" | "user",
|
||||
trailing: (model: FlattenedModel) => ReactNode,
|
||||
) =>
|
||||
Array.from(groups.entries()).map(
|
||||
([providerLabel, { provider, models: providerModels }]) => (
|
||||
<CommandGroup
|
||||
key={`${keyPrefix}-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[model.modelId, providerLabel]}
|
||||
onSelect={() => handleSelect(model.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId === model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ProviderLogo
|
||||
provider={provider}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{model.modelId}
|
||||
</span>
|
||||
{trailing(model)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)
|
||||
|
||||
const tooltipContent = selectedModel
|
||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||
@@ -186,329 +305,244 @@ export function ModelSelector({
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) toSettingsRef.current = false
|
||||
setOpen(next)
|
||||
}}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ProviderLogo
|
||||
provider={selectedModel.provider}
|
||||
className="size-3.5 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? shortModelName(selectedModel.modelId)
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
{/* The dialog trigger must wrap the button itself: Radix
|
||||
Tooltip's root renders no element to pass its props to */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ProviderLogo
|
||||
provider={selectedModel.provider}
|
||||
className="size-3.5 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? shortModelName(
|
||||
selectedModel.modelId,
|
||||
)
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ModelSelectorContent
|
||||
title={dict.modelConfig.selectModel}
|
||||
<DialogContent
|
||||
className="p-0"
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!toSettingsRef.current) return
|
||||
toSettingsRef.current = false
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
<DialogTitle className="sr-only">
|
||||
{dict.modelConfig.selectModel}
|
||||
</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
<CommandInput
|
||||
className="h-auto py-3.5"
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelList className="overflow-y-auto scrollbar-thin">
|
||||
<CommandEmpty>
|
||||
{dict.modelConfig.noModelsFound}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<ModelSelectorGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<CommandGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<Check
|
||||
<CommandItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
"cursor-pointer",
|
||||
!selectedModelId &&
|
||||
"bg-accent",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{
|
||||
dict.modelConfig
|
||||
.serverDefault
|
||||
}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<SectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig
|
||||
.serverModels
|
||||
}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
)}
|
||||
{renderGroups(
|
||||
groupedServerModels,
|
||||
"server",
|
||||
(model) =>
|
||||
model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig.serverModels
|
||||
}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedServerModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`server-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<ModelSelectorSeparator />
|
||||
)}
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<User />}
|
||||
label={dict.modelConfig.userModels}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedUserModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`user-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-2 text-xs",
|
||||
model.validated ===
|
||||
false
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{model.validated ===
|
||||
false
|
||||
? dict
|
||||
.modelConfig
|
||||
.modelFailed
|
||||
: dict
|
||||
.modelConfig
|
||||
.modelUntested}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</div>
|
||||
{/* Pinned footer: add a provider, configure models
|
||||
(z-10 above list shadow). Buttons, outside the
|
||||
search: reachable with Tab, never filtered out */}
|
||||
{(onAddProvider || onConfigure) && (
|
||||
<div
|
||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
||||
// The search's Enter would pick the highlighted
|
||||
// model instead of pressing the button
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onAddProvider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onAddProvider)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.addProviderEntry}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onConfigure && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onConfigure)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.configureModels}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
<SectionHeader
|
||||
icon={<User />}
|
||||
label={
|
||||
dict.modelConfig.userModels
|
||||
}
|
||||
/>
|
||||
{renderGroups(
|
||||
groupedUserModels,
|
||||
"user",
|
||||
(model) =>
|
||||
model.validated !==
|
||||
true && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-2 text-xs",
|
||||
model.validated ===
|
||||
false
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{model.validated ===
|
||||
false
|
||||
? dict
|
||||
.modelConfig
|
||||
.modelFailed
|
||||
: dict
|
||||
.modelConfig
|
||||
.modelUntested}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
{/* Pinned footer: add a provider, configure models
|
||||
(z-10 above list shadow). Buttons, outside the
|
||||
search: reachable with Tab, never filtered out */}
|
||||
{(onAddProvider || onConfigure) && (
|
||||
<div
|
||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
||||
// The search's Enter would pick the highlighted
|
||||
// model instead of pressing the button
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onAddProvider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onAddProvider)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{
|
||||
dict.modelConfig
|
||||
.addProviderEntry
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onConfigure && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onConfigure)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{
|
||||
dict.modelConfig
|
||||
.configureModels
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,9 +58,6 @@ interface ProviderCredentialsFieldsProps {
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
awsRegion?: string
|
||||
// Bedrock: also offer an API key (the user dialog; the admin panel
|
||||
// stores access keys only)
|
||||
bedrockApiKey?: boolean
|
||||
disabled?: boolean
|
||||
// Update a plain text field
|
||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||
@@ -68,9 +65,6 @@ interface ProviderCredentialsFieldsProps {
|
||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||
// shared component only supplies the label above it.
|
||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||
// Extra content after the fields — used for the Bedrock test row and the
|
||||
// EdgeOne test button, which aren't beside a credential input.
|
||||
footer?: ReactNode
|
||||
// The user's settings page: the secrets come first, the display name and
|
||||
// base URL fold under "More options", and Bedrock shows only the way of
|
||||
// signing in that is chosen. Switching it empties the other way's
|
||||
@@ -91,11 +85,9 @@ export function ProviderCredentialsFields({
|
||||
name,
|
||||
baseUrl,
|
||||
awsRegion,
|
||||
bedrockApiKey,
|
||||
disabled,
|
||||
onChange,
|
||||
renderSecret,
|
||||
footer,
|
||||
settingsLayout,
|
||||
clearSecrets,
|
||||
bedrockFilled,
|
||||
@@ -269,10 +261,8 @@ export function ProviderCredentialsFields({
|
||||
</div>
|
||||
)
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
return <div className="space-y-5">{footer}</div>
|
||||
}
|
||||
// EdgeOne needs no credentials
|
||||
if (provider === "edgeone") return null
|
||||
|
||||
if (settingsLayout) {
|
||||
return (
|
||||
@@ -447,7 +437,6 @@ export function ProviderCredentialsFields({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -458,26 +447,6 @@ export function ProviderCredentialsFields({
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
{/* Bedrock API key: used instead of the access keys */}
|
||||
{bedrockApiKey && (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="bedrock-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.bedrockApiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "apiKey",
|
||||
id: "bedrock-api-key",
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.bedrockApiKeyHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
@@ -535,8 +504,6 @@ export function ProviderCredentialsFields({
|
||||
{baseUrlField}
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,14 +12,6 @@ function AlertDialog({
|
||||
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>) {
|
||||
@@ -144,9 +136,6 @@ function AlertDialogCancel({
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
|
||||
@@ -5,13 +5,6 @@ import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
@@ -29,32 +22,6 @@ function Command({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
@@ -164,30 +131,12 @@ function CommandItem({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
@@ -24,12 +24,6 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
@@ -65,7 +59,7 @@ function DialogContent({
|
||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"grid gap-4 p-6",
|
||||
// Refined visual treatment
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-pop",
|
||||
// Entry/exit animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
@@ -151,13 +145,10 @@ function DialogDescription({
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -39,10 +39,4 @@ function PopoverContent({
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
|
||||
@@ -55,4 +55,4 @@ function ScrollBar({
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea }
|
||||
|
||||
@@ -12,12 +12,6 @@ function Select({
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
@@ -87,19 +81,6 @@ function SelectContent({
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
@@ -124,19 +105,6 @@ function SelectItem({
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
@@ -176,12 +144,7 @@ function SelectScrollDownButton({
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user