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:
dayuan.jiang
2026-10-12 08:28:39 +09:00
parent 14f29af939
commit fb87291cde
38 changed files with 586 additions and 2077 deletions
+6 -17
View File
@@ -1,3 +1,5 @@
import type { MaskedSecret as SecretValue } from "@/lib/admin/auth"
import type { AdminProviderInput as AdminProvider } from "@/lib/admin/providers"
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
@@ -5,7 +7,10 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string }
// The same shapes the admin API validates and returns. Secret fields of a
// provider hold either a masked marker (unchanged) or a plaintext string
// (new value).
export type { AdminProvider, SecretValue }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
@@ -24,22 +29,6 @@ export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
-27
View File
@@ -3,13 +3,6 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
@@ -206,26 +199,6 @@ export function SettingField({
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
case "secret":
control = (
<div className="w-full max-w-md">
-4
View File
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
+9 -22
View File
@@ -98,9 +98,7 @@
--shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
--duration-normal: 200ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/*
@@ -241,13 +239,6 @@
.shadow-pop {
box-shadow: var(--shadow-pop);
}
/* Older names kept for components that still use them */
.shadow-soft {
box-shadow: var(--shadow-float);
}
.shadow-dialog {
box-shadow: var(--shadow-pop);
}
}
@keyframes fadeIn {
@@ -284,17 +275,13 @@
}
}
@keyframes check-pop {
0% {
transform: scale(0.8);
opacity: 0;
/* A highlight sweeps across "Thinking..." text (Shimmer) */
@keyframes shimmer {
from {
background-position: 100% center;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
to {
background-position: 0% center;
}
}
@@ -307,15 +294,15 @@
.animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both;
}
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
.animate-shimmer {
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-message-in,
.animate-panel-in,
.animate-check-pop {
.animate-shimmer {
animation: none;
}
}
-238
View File
@@ -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>
)
+59 -162
View File
@@ -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>
)
}
+5 -22
View File
@@ -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>
)
}
-36
View File
@@ -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>
)
}
-206
View File
@@ -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>
+27 -81
View File
@@ -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>
-4
View File
@@ -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">
+8 -19
View File
@@ -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)
+5 -13
View File
@@ -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>
)
}
+12 -25
View File
@@ -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>
-44
View File
@@ -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>
)
}
+2 -41
View File
@@ -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
View File
@@ -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>
)
}
+2 -35
View File
@@ -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>
)
}
-11
View File
@@ -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,
-51
View File
@@ -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,
}
+1 -10
View File
@@ -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,
}
+1 -7
View File
@@ -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 }
+1 -1
View File
@@ -55,4 +55,4 @@ function ScrollBar({
)
}
export { ScrollArea, ScrollBar }
export { ScrollArea }
-37
View File
@@ -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,
}
+2 -95
View File
@@ -18,56 +18,6 @@ import {
type ProviderName,
} from "@/lib/types/model-config"
// Old storage keys for migration
const OLD_KEYS = {
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
}
/**
* Migrate from old single-provider format to new multi-model format
*/
function migrateOldConfig(): MultiModelConfig | null {
if (typeof window === "undefined") return null
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
// No old config to migrate. A local Ollama needs no key.
if (!oldProvider || !oldModel) return null
if (!oldApiKey && oldProvider !== "ollama") return null
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
// Create new config from old format
const provider = createProviderConfig(oldProvider as ProviderName)
provider.apiKey = oldApiKey ?? ""
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
// Without one, the old version used the server's Ollama (OLLAMA_BASE_URL
// or this machine), not the cloud address new providers get
else if (oldProvider === "ollama") provider.baseUrl = ""
const model = createModelConfig(oldModel)
provider.models.push(model)
const config: MultiModelConfig = {
version: 1,
providers: [provider],
selectedModelId: model.id,
}
// Clear old keys after migration
localStorage.removeItem(OLD_KEYS.aiProvider)
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
localStorage.removeItem(OLD_KEYS.aiApiKey)
localStorage.removeItem(OLD_KEYS.aiModel)
return config
}
const isKnownProvider = (p: { provider: string }) =>
Object.hasOwn(PROVIDER_INFO, p.provider)
@@ -96,7 +46,6 @@ function parseStoredConfig(stored: string): MultiModelConfig {
function loadConfig(): MultiModelConfig {
if (typeof window === "undefined") return createEmptyConfig()
// First, check if new format exists
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) {
try {
@@ -106,17 +55,6 @@ function loadConfig(): MultiModelConfig {
}
}
// Try migration from old format
const migrated = migrateOldConfig()
if (migrated) {
// Save migrated config
localStorage.setItem(
STORAGE_KEYS.modelConfigs,
JSON.stringify(migrated),
)
return migrated
}
return createEmptyConfig()
}
@@ -163,7 +101,6 @@ export interface UseModelConfigReturn {
// Getters
models: FlattenedModel[]
selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined
// Actions
@@ -181,7 +118,6 @@ export interface UseModelConfigReturn {
updates: Partial<ModelConfig>,
) => void
deleteModel: (providerId: string, modelConfigId: string) => void
resetConfig: () => void
}
export function useModelConfig(): UseModelConfigReturn {
@@ -281,7 +217,7 @@ export function useModelConfig(): UseModelConfigReturn {
id: m.id,
modelId: m.modelId,
provider: m.provider,
providerLabel: `Server · ${m.providerLabel}`,
providerLabel: m.providerLabel,
apiKey: "",
baseUrl: undefined,
awsAccessKeyId: undefined,
@@ -291,17 +227,11 @@ export function useModelConfig(): UseModelConfigReturn {
validated: true,
source: "server" as const,
isDefault: m.isDefault,
apiKeyEnv: m.apiKeyEnv,
baseUrlEnv: m.baseUrlEnv,
})),
// User models from local configuration
...userModels,
]
const selectedModel = config.selectedModelId
? models.find((m) => m.id === config.selectedModelId)
: undefined
// Actions
const setSelectedModelId = useCallback((modelId: string | undefined) => {
setConfig((prev) => ({
@@ -426,15 +356,10 @@ export function useModelConfig(): UseModelConfigReturn {
[serverModels],
)
const resetConfig = useCallback(() => {
setConfig(createEmptyConfig())
}, [])
return {
config,
isLoaded: isLoaded && serverLoaded,
models,
selectedModel,
selectedModelId: config.selectedModelId,
setSelectedModelId,
addProvider,
@@ -443,7 +368,6 @@ export function useModelConfig(): UseModelConfigReturn {
addModel,
updateModel,
deleteModel,
resetConfig,
}
}
@@ -491,24 +415,7 @@ export function getSelectedAIConfig(): {
// Load multi-model config
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (!stored) {
// Fallback to old format for backward compatibility
return {
accessCode,
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
// Old format didn't support AWS
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
providerId: "",
}
}
if (!stored) return { ...empty, accessCode }
let config: MultiModelConfig
try {
+1 -1
View File
@@ -56,7 +56,7 @@ const StoredAdminProviderSchema = AdminProviderSchema.extend({
vertexApiKey: z.string().optional(),
})
type AdminProviderInput = z.infer<typeof AdminProviderSchema>
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
// Stored form: secrets are plain strings
export interface StoredAdminProvider {
+1 -9
View File
@@ -10,7 +10,7 @@
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export type SettingType = "string" | "secret" | "number" | "boolean"
export interface SettingDef {
key: string
@@ -18,7 +18,6 @@ export interface SettingDef {
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
@@ -123,13 +122,6 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"clear": "Clear",
"edit": "Edit",
"delete": "Delete",
"loading": "Loading.."
},
"nav": {
"editor": "Editor",
"newChat": "New diagram",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel",
"showPanel": "Show chat panel",
"aiChat": "AI chat"
},
"providers": {
"useServerDefault": "Use Server Default",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"inputLabel": "Chat input",
"send": "Send",
"stopGeneration": "Stop generation",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal",
"styledMode": "Styled",
"minimalTooltip": "Use minimal for faster generation (no colors)",
"regenerate": "Regenerate response",
"copyResponse": "Copy response",
"copied": "Copied!",
@@ -82,17 +59,6 @@
"accessCode": "Access code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
"aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.",
"provider": "Provider",
"modelId": "Model ID",
"apiKey": "API Key",
"apiKeyPlaceholder": "Your API key",
"baseUrl": "Base URL (optional)",
"customEndpoint": "Custom endpoint URL",
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download",
"proxy": "Proxy settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP proxy",
@@ -170,13 +132,9 @@
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload",
"failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
"continueWithoutSaving": "Continue without saving",
@@ -208,8 +166,6 @@
"quota": {
"dailyLimit": "Daily Quota Reached",
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
"messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "Use Your API Key",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "Thought for 1 second",
"thoughtBrief": "Thought for a few seconds"
},
"dev": {
"title": "Dev: XML Streaming Simulator",
"preset": "Preset:",
"selectPreset": "Select a preset...",
"clear": "Clear",
"placeholder": "Paste mxCell XML here or select a preset...",
"interval": "Interval:",
"chars": "Chars:",
"streaming": "Streaming...",
"simulate": "Simulate",
"stop": "Stop",
"testQuotaToast": "Test Quota Toast",
"simulatingMessage": "[Dev] Simulating XML streaming",
"successMessage": "Successfully displayed the diagram."
},
"about": {
"modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)",
"seekingSponsorship": "Call for Sponsorship",
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"sessionHistory": {
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No diagrams yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this diagram?",
"deleteDescription": "The diagram and its conversation are deleted for good.",
"justNow": "Just now",
@@ -351,20 +277,13 @@
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": {
"configure": "Configure",
"addProvider": "Add provider",
"addModel": "Add Model",
"modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete provider",
"deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.",
"validationError": "The test failed",
"addModelFirst": "Add a model first.",
"displayName": "Display Name",
"bedrockApiKey": "Bedrock API key",
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
@@ -372,7 +291,6 @@
"apiKey": "API key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"getApiKey": "Get API key",
"noTools": "no tool calls",
@@ -388,8 +306,6 @@
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type the provider name...",
"requestFailed": "Request failed ({status})",
"cancel": "Cancel",
"delete": "Delete",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": {
"label": "Check Timeout (ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "Trim Old Diagrams",
"description": "Old diagrams in the chat history are kept as a short marker"
},
"ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs",
"description": "When off, custom model URLs can't point to private networks; safer"
@@ -697,7 +609,6 @@
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas",
"openFileHint": "Keep working on a diagram you have"
"drawYourself": "Start with a blank canvas"
}
}
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "保存",
"cancel": "キャンセル",
"close": "閉じる",
"clear": "クリア",
"edit": "編集",
"delete": "削除",
"loading": "読み込み中.."
},
"nav": {
"editor": "エディタ",
"newChat": "新しい図",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示",
"showPanel": "チャットパネルを表示",
"aiChat": "AI チャット"
},
"providers": {
"useServerDefault": "サーバーデフォルトを使用",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"inputLabel": "チャット入力",
"send": "送信",
"stopGeneration": "生成を停止",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
"minimalTooltip": "高速生成のためミニマルを使用(色なし)",
"regenerate": "応答を再生成",
"copyResponse": "応答をコピー",
"copied": "コピーしました!",
@@ -82,17 +59,6 @@
"accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
"aiProvider": "AI プロバイダー設定",
"aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。",
"provider": "プロバイダー",
"modelId": "モデル ID",
"apiKey": "API キー",
"apiKeyPlaceholder": "あなたの API キー",
"baseUrl": "ベース URL(オプション)",
"customEndpoint": "カスタムエンドポイント URL",
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード",
"proxy": "プロキシ設定",
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
"httpProxy": "HTTP プロキシ",
@@ -170,13 +132,9 @@
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
@@ -208,8 +166,6 @@
"quota": {
"dailyLimit": "1日の割当量に達しました",
"tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
"messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "APIキーを使用",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "1 秒考えました",
"thoughtBrief": "数秒考えました"
},
"dev": {
"title": "開発:XMLストリーミングシミュレーター",
"preset": "プリセット:",
"selectPreset": "プリセットを選択...",
"clear": "クリア",
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
"interval": "間隔:",
"chars": "文字:",
"streaming": "ストリーミング中...",
"simulate": "シミュレート",
"stop": "停止",
"testQuotaToast": "クォータトーストをテスト",
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
"successMessage": "ダイアグラムの表示に成功しました。"
},
"about": {
"modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)",
"seekingSponsorship": "スポンサー募集",
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"sessionHistory": {
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "まだ図はありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "この図を削除しますか?",
"deleteDescription": "図とその会話は完全に削除されます。",
"justNow": "たった今",
@@ -306,20 +232,13 @@
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": {
"configure": "設定",
"addProvider": "プロバイダーを追加",
"addModel": "モデルを追加",
"modelId": "モデルID",
"modelLabel": "表示名",
"streaming": "ストリーミングを有効",
"deleteProvider": "プロバイダーを削除",
"deleteModel": "モデルを削除",
"noModels": "モデルが設定されていません。モデルを追加してください。",
"validationError": "テストに失敗しました",
"addModelFirst": "先にモデルを追加してください。",
"displayName": "表示名",
"bedrockApiKey": "Bedrock API キー",
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
"awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン",
@@ -327,7 +246,6 @@
"apiKey": "API キー",
"enterApiKey": "API キーを入力",
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"getApiKey": "API キーを取得",
"noTools": "ツール呼び出し非対応",
@@ -343,8 +261,6 @@
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"requestFailed": "リクエストに失敗しました({status})",
"cancel": "キャンセル",
"delete": "削除",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": {
"label": "チェックのタイムアウト(ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "古い図を省略",
"description": "会話履歴の古い図は短い印だけ残します"
},
"ALLOW_PRIVATE_URLS": {
"label": "内部ネットワークへのアクセス",
"description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
@@ -697,7 +609,6 @@
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める",
"openFileHint": "手元の図の続きを編集"
"drawYourself": "白紙のキャンバスから始める"
}
}
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "儲存",
"cancel": "取消",
"close": "關閉",
"clear": "清除",
"edit": "編輯",
"delete": "刪除",
"loading": "載入中..."
},
"nav": {
"editor": "編輯器",
"newChat": "新增圖紙",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板",
"showPanel": "顯示聊天面板",
"aiChat": "AI 對話"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"inputLabel": "聊天輸入框",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約",
"styledMode": "精緻",
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
"regenerate": "重新產生回應",
"copyResponse": "複製回應",
"copied": "已複製!",
@@ -82,17 +59,6 @@
"accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 服務商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "服務商",
"modelId": "模型 ID",
"apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰",
"baseUrl": "基礎 URL(可選)",
"customEndpoint": "自訂端點 URL",
"overrides": "覆寫",
"clearSettings": "清除設定",
"useServerDefault": "使用伺服器預設值",
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
"diagramActions": "圖表操作",
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
"history": "歷史紀錄",
"download": "下載",
"proxy": "代理設定",
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
"httpProxy": "HTTP 代理",
@@ -170,13 +132,9 @@
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
"failedToPersist": "卸載前無法持久化狀態",
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
@@ -208,8 +166,6 @@
"quota": {
"dailyLimit": "已達每日配額",
"tokenLimit": "已達每日 token 上限",
"tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
"messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "使用自己的 API Key",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
"title": "開發:XML 串流模擬器",
"preset": "預設:",
"selectPreset": "選擇預設...",
"clear": "清除",
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
"interval": "間隔:",
"chars": "字元:",
"streaming": "串流傳輸中...",
"simulate": "模擬",
"stop": "停止",
"testQuotaToast": "測試配額提示",
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
"successMessage": "成功顯示圖表。"
},
"about": {
"modelChange": "模型變更與用量限制",
"walletCrying": "(別名:我的錢包頂不住了)",
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
"contactMe": "聯絡我",
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "還沒有圖紙",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除這張圖紙?",
"deleteDescription": "圖紙和它的對話會被永久刪除。",
"justNow": "剛剛",
@@ -351,20 +277,13 @@
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"configure": "配置",
"addProvider": "新增服務商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除服務商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"validationError": "測試沒通過",
"addModelFirst": "先新增一個模型。",
"displayName": "顯示名稱",
"bedrockApiKey": "Bedrock API Key",
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
"awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
@@ -372,7 +291,6 @@
"apiKey": "API Key",
"enterApiKey": "輸入 API Key",
"enterSecretKey": "輸入 Secret Access Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"getApiKey": "取得 API Key",
"noTools": "不支援工具呼叫",
@@ -388,8 +306,6 @@
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入服務商名稱...",
"requestFailed": "請求失敗({status})",
"cancel": "取消",
"delete": "刪除",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": {
"label": "檢查逾時(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "精簡舊圖",
"description": "對話歷史裡的舊圖表只留簡短標記"
},
"ALLOW_PRIVATE_URLS": {
"label": "存取內網",
"description": "關閉後,自訂模型位址不能指向內網,更安全"
@@ -697,7 +609,6 @@
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始",
"openFileHint": "接著編輯已有的圖"
"drawYourself": "從空白畫布開始"
}
}
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "保存",
"cancel": "取消",
"close": "关闭",
"clear": "清除",
"edit": "编辑",
"delete": "删除",
"loading": "加载中..."
},
"nav": {
"editor": "编辑器",
"newChat": "新建图纸",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板",
"showPanel": "显示聊天面板",
"aiChat": "AI 对话"
},
"providers": {
"useServerDefault": "使用服务器默认值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"inputLabel": "聊天输入框",
"send": "发送",
"stopGeneration": "停止生成",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"uploadFile": "上传文件(图片、PDF、文本)",
"minimalStyle": "简约",
"styledMode": "精致",
"minimalTooltip": "使用简约模式以加快生成速度(无颜色)",
"regenerate": "重新生成响应",
"copyResponse": "复制响应",
"copied": "已复制!",
@@ -82,17 +59,6 @@
"accessCode": "访问码",
"accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。",
"aiProvider": "AI 服务商设置",
"aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。",
"provider": "服务商",
"modelId": "模型 ID",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "您的 API 密钥",
"baseUrl": "基础 URL(可选)",
"customEndpoint": "自定义端点 URL",
"overrides": "覆盖",
"clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值",
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "选择发送消息的方式。",
"enterToSend": "回车发送",
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载",
"proxy": "代理设置",
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
"httpProxy": "HTTP 代理",
@@ -170,13 +132,9 @@
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
"sessionCorrupted": "会话数据已损坏。重新开始。",
"failedToSave": "无法保存消息到 localStorage",
"failedToRestore": "无法从 localStorage 恢复",
"failedToPersist": "卸载前无法持久化状态",
"failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
@@ -208,8 +166,6 @@
"quota": {
"dailyLimit": "已达每日配额",
"tokenLimit": "已达每日 token 上限",
"tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制(每分钟 {limit} token)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "看来您今天的体验次数已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
"messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "使用自己的 API Key",
"selfHost": "自托管",
"sponsor": "赞助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了几秒钟"
},
"dev": {
"title": "开发:XML 流式模拟器",
"preset": "预设:",
"selectPreset": "选择预设...",
"clear": "清除",
"placeholder": "在此粘贴 mxCell XML 或选择预设...",
"interval": "间隔:",
"chars": "字符:",
"streaming": "流式传输中...",
"simulate": "模拟",
"stop": "停止",
"testQuotaToast": "测试配额提示",
"simulatingMessage": "[开发] 模拟 XML 流式传输",
"successMessage": "成功显示图表。"
},
"about": {
"modelChange": "模型变更与用量限制",
"walletCrying": "(别名:我的钱包顶不住了)",
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
"contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
},
"sessionHistory": {
"tooltip": "聊天历史",
"newChat": "新对话",
"empty": "还没有图纸",
"emptyHint": "开始对话吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除这张图纸?",
"deleteDescription": "图纸和它的对话会被永久删除。",
"justNow": "刚刚",
@@ -351,20 +277,13 @@
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": {
"configure": "配置",
"addProvider": "添加服务商",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelLabel": "显示名称",
"streaming": "启用流式输出",
"deleteProvider": "删除服务商",
"deleteModel": "删除模型",
"noModels": "尚未配置模型。添加模型以开始使用。",
"validationError": "测试没通过",
"addModelFirst": "先添加一个模型。",
"displayName": "显示名称",
"bedrockApiKey": "Bedrock API Key",
"bedrockApiKeyHint": "填了 API 密钥,下面的访问密钥就不用填。",
"awsAccessKeyId": "AWS 访问密钥 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
@@ -372,7 +291,6 @@
"apiKey": "API Key",
"enterApiKey": "输入 API Key",
"enterSecretKey": "输入 Secret Access Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"getApiKey": "获取 API Key",
"noTools": "不支持工具调用",
@@ -388,8 +306,6 @@
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
"typeProviderName": "输入服务商名称...",
"requestFailed": "请求失败({status})",
"cancel": "取消",
"delete": "删除",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": {
"label": "检查超时(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "精简旧图",
"description": "对话历史里的旧图表只留简短标记"
},
"ALLOW_PRIVATE_URLS": {
"label": "访问内网",
"description": "关闭后,自定义模型地址不能指向内网,更安全"
@@ -697,7 +609,6 @@
"lede": "描述一张图,或上传截图、PDF。得到的是可以继续编辑和导出的 draw.io 图。",
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸",
"drawYourself": "从空白画布开始",
"openFileHint": "接着编辑已有的图"
"drawYourself": "从空白画布开始"
}
}
+3 -58
View File
@@ -1,5 +1,6 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
import { isIndexedDBAvailable } from "./session-storage"
// Constants
const DB_NAME = "next-ai-drawio-templates"
@@ -14,7 +15,6 @@ export interface Template {
description?: string
createdAt: number
updatedAt: number
clickCount: number
runCount: number
lastUsedAt: number
pinned: boolean
@@ -24,12 +24,7 @@ export type TemplateCreateInput = Pick<Template, "prompt"> &
Partial<
Omit<
Template,
| "id"
| "createdAt"
| "updatedAt"
| "clickCount"
| "runCount"
| "lastUsedAt"
"id" | "createdAt" | "updatedAt" | "runCount" | "lastUsedAt"
>
>
@@ -37,12 +32,6 @@ interface TemplateDB extends DBSchema {
templates: {
key: string
value: Template
indexes: {
"by-updated": number
"by-pinned": number
"by-run-count": number
"by-last-used": number
}
}
}
@@ -64,13 +53,7 @@ async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const templateStore = db.createObjectStore(STORE_NAME, {
keyPath: "id",
})
templateStore.createIndex("by-updated", "updatedAt")
templateStore.createIndex("by-pinned", "pinned")
templateStore.createIndex("by-run-count", "runCount")
templateStore.createIndex("by-last-used", "lastUsedAt")
db.createObjectStore(STORE_NAME, { keyPath: "id" })
}
}
},
@@ -79,16 +62,6 @@ async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
return dbPromise
}
// Check if IndexedDB is available
export function isIndexedDBAvailable(): boolean {
if (typeof window === "undefined") return false
try {
return "indexedDB" in window && window.indexedDB !== null
} catch {
return false
}
}
// CRUD Operations
export async function getAllTemplates(): Promise<Template[]> {
@@ -103,17 +76,6 @@ export async function getAllTemplates(): Promise<Template[]> {
}
}
export async function getTemplate(id: string): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
} catch (error) {
console.error("Failed to get template:", error)
return null
}
}
export async function createTemplate(
input: TemplateCreateInput,
): Promise<Template | null> {
@@ -130,7 +92,6 @@ export async function createTemplate(
description: input.description?.trim() || undefined,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: input.pinned ?? false,
@@ -200,7 +161,6 @@ export async function duplicateTemplate(
title: `${existing.title} ${copySuffix}`,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
@@ -215,20 +175,6 @@ export async function duplicateTemplate(
// Usage tracking
export async function incrementClickCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
template.clickCount += 1
template.updatedAt = Date.now()
await db.put(STORE_NAME, template)
} catch (error) {
console.error("Failed to increment click count:", error)
}
}
export async function incrementRunCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
@@ -366,7 +312,6 @@ export async function importTemplates(
description: t.description ? String(t.description) : undefined,
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
updatedAt: now,
clickCount: typeof t.clickCount === "number" ? t.clickCount : 0,
runCount: typeof t.runCount === "number" ? t.runCount : 0,
lastUsedAt: typeof t.lastUsedAt === "number" ? t.lastUsedAt : 0,
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
-9
View File
@@ -52,7 +52,6 @@ export interface ProviderConfig {
vertexApiKey?: string // Express Mode API key
models: ModelConfig[]
validated?: boolean // Has API key been validated
}
// The complete multi-model configuration
@@ -60,9 +59,6 @@ export interface MultiModelConfig {
version: 1
providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID
// Older versions saved here whether untested models were listed in the
// model picker; they always are now
showUnvalidatedModels?: boolean
}
// Flattened model for dropdown display
@@ -86,10 +82,6 @@ export interface FlattenedModel {
source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean
// Custom env var name(s) for server models
// Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
baseUrlEnv?: string
}
// Providers whose server credentials live in fixed env vars
@@ -557,7 +549,6 @@ export function createProviderConfig(provider: ProviderName): ProviderConfig {
apiKey: "",
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
models: [],
validated: false,
}
}
-2
View File
@@ -64,7 +64,6 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"ai": "^6.0.300",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -72,7 +71,6 @@
"idb": "^8.0.3",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.577.0",
"motion": "^12.23.25",
"nanoid": "^5.0.0",
"negotiator": "^1.0.0",
"next": "^16.0.7",
-1
View File
@@ -399,7 +399,6 @@ test("a test result for a key changed on another settings tab is dropped", async
const saved = await savedConfig(page)
expect(saved.providers[0].apiKey).toBe("new-key")
expect(saved.providers[0].models[0].validated).not.toBe(true)
expect(saved.providers[0].validated).not.toBe(true)
})
test("a test result for a model renamed on another settings tab is dropped", async ({
-5
View File
@@ -37,7 +37,6 @@ describe("sortTemplates", () => {
prompt: "test prompt",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
@@ -115,7 +114,6 @@ describe("searchTemplates", () => {
description: "Basic flowchart template",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
@@ -127,7 +125,6 @@ describe("searchTemplates", () => {
description: "Entity relationship diagram",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
@@ -138,7 +135,6 @@ describe("searchTemplates", () => {
prompt: "Draw network topology",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
@@ -236,7 +232,6 @@ describe("validateImportData", () => {
prompt: "test prompt",
createdAt: Date.now(),
updatedAt: Date.now(),
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
-42
View File
@@ -260,45 +260,3 @@ describe("useModelConfig across tabs", () => {
expect(result.current.config.providers).toHaveLength(2)
})
})
describe("useModelConfig migration", () => {
const OLD = {
provider: "next-ai-draw-io-ai-provider",
baseUrl: "next-ai-draw-io-ai-base-url",
apiKey: "next-ai-draw-io-ai-api-key",
model: "next-ai-draw-io-ai-model",
}
it("moves an old keyless Ollama setup into the new format", async () => {
localStorage.setItem(OLD.provider, "ollama")
localStorage.setItem(OLD.baseUrl, "http://localhost:11434/api")
localStorage.setItem(OLD.apiKey, "")
localStorage.setItem(OLD.model, "llama3.2")
const { result } = await renderLoaded()
const [provider] = result.current.config.providers
expect(provider).toMatchObject({
provider: "ollama",
apiKey: "",
baseUrl: "http://localhost:11434/api",
models: [{ modelId: "llama3.2" }],
})
expect(result.current.selectedModelId).toBe(provider.models[0].id)
expect(localStorage.getItem(OLD.model)).toBeNull()
})
it("keeps an old Ollama without a URL on the server's Ollama", async () => {
localStorage.setItem(OLD.provider, "ollama")
localStorage.setItem(OLD.apiKey, "")
localStorage.setItem(OLD.model, "llama3.2")
const { result } = await renderLoaded()
// No cloud address: the request takes OLLAMA_BASE_URL or this machine
expect(result.current.config.providers[0].baseUrl).toBe("")
})
it("still needs a key for other providers", async () => {
localStorage.setItem(OLD.provider, "openai")
localStorage.setItem(OLD.model, "gpt-4o")
const { result } = await renderLoaded()
expect(result.current.config.providers).toEqual([])
})
})