diff --git a/app/[lang]/admin/admin-shared.ts b/app/[lang]/admin/admin-shared.ts index fc93310e..fc8b5284 100644 --- a/app/[lang]/admin/admin-shared.ts +++ b/app/[lang]/admin/admin-shared.ts @@ -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 diff --git a/app/[lang]/admin/setting-field.tsx b/app/[lang]/admin/setting-field.tsx index 2bd02325..d11be1ce 100644 --- a/app/[lang]/admin/setting-field.tsx +++ b/app/[lang]/admin/setting-field.tsx @@ -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 = ( - - ) - break case "secret": control = (
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index d4d09755..183c0777 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -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 } diff --git a/app/globals.css b/app/globals.css index c4df4ee3..6cf34e3f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } } diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx deleted file mode 100644 index 7164f44b..00000000 --- a/components/ai-elements/model-selector.tsx +++ /dev/null @@ -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 - -export const ModelSelector = (props: ModelSelectorProps) => ( - -) - -export type ModelSelectorTriggerProps = ComponentProps - -export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( - -) - -export type ModelSelectorContentProps = ComponentProps & { - title?: ReactNode -} - -export const ModelSelectorContent = ({ - className, - children, - title = "Model Selector", - ...props -}: ModelSelectorContentProps) => ( - - {title} - - {children} - - -) - -export type ModelSelectorDialogProps = ComponentProps - -export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( - -) - -export type ModelSelectorInputProps = ComponentProps - -export const ModelSelectorInput = ({ - className, - ...props -}: ModelSelectorInputProps) => ( - -) - -export type ModelSelectorListProps = ComponentProps - -export const ModelSelectorList = ({ - className, - ...props -}: ModelSelectorListProps) => { - const listRef = useRef>(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 ( -
- - {/* Bottom shadow indicator for scrollable content */} -
-
- ) -} - -export type ModelSelectorEmptyProps = ComponentProps - -export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( - -) - -export type ModelSelectorGroupProps = ComponentProps - -export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( - -) - -export type ModelSelectorItemProps = ComponentProps - -export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( - -) - -export type ModelSelectorShortcutProps = ComponentProps - -export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( - -) - -export type ModelSelectorSeparatorProps = ComponentProps< - typeof CommandSeparator -> - -export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( - -) - -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 - } - - return ( - // biome-ignore lint/performance/noImgElement: External URL from models.dev - {`${provider} - ) -} - -export type ModelSelectorLogoGroupProps = ComponentProps<"div"> - -export const ModelSelectorLogoGroup = ({ - className, - ...props -}: ModelSelectorLogoGroupProps) => ( -
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) => ( - -) - -export type ModelSelectorSectionHeaderProps = { - icon: ReactNode - label: string - className?: string -} - -export const ModelSelectorSectionHeader = ({ - icon, - label, - className, -}: ModelSelectorSectionHeaderProps) => ( -
- - {label} -
-) diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index 271db5cf..b3dbc985 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -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(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 & { +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() + const [hasAutoClosed, setHasAutoClosed] = useState(false) + const [startTime, setStartTime] = useState(null) - const [hasAutoClosed, setHasAutoClosed] = useState(false) - const [startTime, setStartTime] = useState(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 ( - - - {children} - - - ) - }, -) + // 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 Thinking... - } - if (duration === undefined) { - return

Thought for a few seconds

- } - return

Thought for {duration} seconds

-} - -export const ReasoningTrigger = memo( - ({ - className, - children, - getThinkingMessage = defaultGetThinkingMessage, - ...props - }: ReasoningTriggerProps) => { - const { isStreaming, isOpen, duration } = useReasoning() - - return ( - - {children ?? ( - <> - - {getThinkingMessage(isStreaming, duration)} - - - )} - - ) - }, -) - -export type ReasoningContentProps = ComponentProps< - typeof CollapsibleContent -> & { - children: string -} - -export const ReasoningContent = memo( - ({ className, children, ...props }: ReasoningContentProps) => ( - -
{children}
-
- ), -) - -Reasoning.displayName = "Reasoning" -ReasoningTrigger.displayName = "ReasoningTrigger" -ReasoningContent.displayName = "ReasoningContent" + + + {getThinkingMessage(isStreaming, duration)} + + + +
{children}
+
+ + ) +} diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx index 274bf85f..1ebed4f0 100644 --- a/components/ai-elements/shimmer.tsx +++ b/components/ai-elements/shimmer.tsx @@ -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 ( - {children} - + ) } diff --git a/components/button-with-tooltip.tsx b/components/button-with-tooltip.tsx deleted file mode 100644 index 089b0596..00000000 --- a/components/button-with-tooltip.tsx +++ /dev/null @@ -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 { - tooltipContent: string - children: React.ReactNode - asChild?: boolean -} - -export function ButtonWithTooltip({ - tooltipContent, - children, - ...buttonProps -}: ButtonWithTooltipProps) { - return ( - - - - - - - {tooltipContent} - - - - ) -} diff --git a/components/chat/TemplateCreateDialog.tsx b/components/chat/TemplateCreateDialog.tsx deleted file mode 100644 index c88a5be9..00000000 --- a/components/chat/TemplateCreateDialog.tsx +++ /dev/null @@ -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(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 ( - - -
- - - - {dict.templates.createTitle} - - - {dict.templates.createDescription} - - - -
- {/* Prompt field - required */} -
- -