Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
29121f5e78 fix: use totalUsage with all token types for accurate quota tracking
The onFinish callback's 'usage' only contains the final step's tokens,
which underreports usage for multi-step tool calls (like diagram generation).
Changed to 'totalUsage' which provides cumulative counts across all steps.

Include all 4 token types for accurate counting:
1. inputTokens - non-cached input tokens
2. outputTokens - generated output tokens
3. cachedInputTokens - tokens read from prompt cache
4. inputTokenDetails.cacheWriteTokens - tokens written to cache

Tested locally:
- Request 1 (cache write): 334 + 62 + 0 + 6671 = 7,067 tokens
- Request 2 (cache read): 334 + 184 + 6551 + 120 = 7,189 tokens
- DynamoDB total: 14,256 ✓
2025-12-23 20:16:24 +09:00
8 changed files with 364 additions and 570 deletions

View File

@@ -144,68 +144,6 @@
--sidebar-ring: oklch(0.7 0.16 265); --sidebar-ring: oklch(0.7 0.16 265);
} }
/* ============================================
REFINED MINIMAL DESIGN SYSTEM
============================================ */
:root {
/* Surface layers for depth */
--surface-0: oklch(1 0 0);
--surface-1: oklch(0.985 0.002 240);
--surface-2: oklch(0.97 0.004 240);
--surface-elevated: oklch(1 0 0);
/* Subtle borders */
--border-subtle: oklch(0.94 0.008 260);
--border-default: oklch(0.91 0.012 260);
/* Interactive states */
--interactive-hover: oklch(0.96 0.015 260);
--interactive-active: oklch(0.93 0.02 265);
/* Success state */
--success: oklch(0.65 0.18 145);
--success-muted: oklch(0.95 0.03 145);
/* Animation timing */
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dark {
--surface-0: oklch(0.15 0.015 260);
--surface-1: oklch(0.18 0.015 260);
--surface-2: oklch(0.22 0.015 260);
--surface-elevated: oklch(0.25 0.015 260);
--border-subtle: oklch(0.25 0.012 260);
--border-default: oklch(0.3 0.015 260);
--interactive-hover: oklch(0.25 0.02 265);
--interactive-active: oklch(0.3 0.025 270);
--success: oklch(0.7 0.16 145);
--success-muted: oklch(0.25 0.04 145);
}
/* Expose surface colors to Tailwind */
@theme inline {
--color-surface-0: var(--surface-0);
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-surface-elevated: var(--surface-elevated);
--color-border-subtle: var(--border-subtle);
--color-border-default: var(--border-default);
--color-interactive-hover: var(--interactive-hover);
--color-interactive-active: var(--interactive-active);
--color-success: var(--success);
--color-success-muted: var(--success-muted);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@@ -319,83 +257,3 @@
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
/* ============================================
REFINED DIALOG STYLES
============================================ */
/* Refined dialog shadow - multi-layer soft shadow */
.shadow-dialog {
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.03),
0 2px 4px oklch(0 0 0 / 0.02),
0 12px 24px oklch(0 0 0 / 0.06),
0 24px 48px oklch(0 0 0 / 0.04);
}
.dark .shadow-dialog {
box-shadow:
0 0 0 1px oklch(1 0 0 / 0.05),
0 2px 4px oklch(0 0 0 / 0.2),
0 12px 24px oklch(0 0 0 / 0.3),
0 24px 48px oklch(0 0 0 / 0.2);
}
/* Dialog animations */
@keyframes dialog-in {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
/* Check pop animation for validation success */
@keyframes check-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.animate-dialog-in,
.animate-dialog-out,
.animate-check-pop {
animation: none;
}
}

View File

@@ -557,32 +557,12 @@ Continue from EXACTLY where you stopped.`,
}, },
onError: (error) => { onError: (error) => {
// Handle server-side quota limit (429 response) // Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) { if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast() quotaManager.showQuotaLimitToast()
return return
} }
if (error.message.includes("Daily token limit")) { if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast() quotaManager.showTokenLimitToast(dailyTokenLimit)
return return
} }
if ( if (

View File

@@ -103,40 +103,41 @@ function ProviderLogo({
) )
} }
// Configuration section with title and optional action // Reusable validation button component
function ConfigSection({ function ValidationButton({
title, status,
icon: Icon, onClick,
action, disabled,
children, dict,
}: { }: {
title: string status: ValidationStatus
icon: React.ComponentType<{ className?: string }> onClick: () => void
action?: React.ReactNode disabled: boolean
children: React.ReactNode dict: ReturnType<typeof useDictionary>
}) { }) {
return ( return (
<div className="space-y-4"> <Button
<div className="flex items-center justify-between"> variant={status === "success" ? "outline" : "default"}
<div className="flex items-center gap-2"> size="sm"
<Icon className="h-4 w-4 text-muted-foreground" /> onClick={onClick}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> disabled={disabled}
{title} className={cn(
</span> "h-9 px-4 min-w-[80px]",
</div> status === "success" &&
{action} "text-emerald-600 border-emerald-200 dark:border-emerald-800",
</div> )}
{children} >
</div> {status === "validating" ? (
) <Loader2 className="h-4 w-4 animate-spin" />
} ) : status === "success" ? (
<>
// Card wrapper with subtle depth <Check className="h-4 w-4 mr-1.5" />
function ConfigCard({ children }: { children: React.ReactNode }) { {dict.modelConfig.verified}
return ( </>
<div className="rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5"> ) : (
{children} dict.modelConfig.test
</div> )}
</Button>
) )
} }
@@ -153,6 +154,7 @@ export function ModelConfigDialog({
const [validationStatus, setValidationStatus] = const [validationStatus, setValidationStatus] =
useState<ValidationStatus>("idle") useState<ValidationStatus>("idle")
const [validationError, setValidationError] = useState<string>("") const [validationError, setValidationError] = useState<string>("")
const [scrollState, setScrollState] = useState({ top: false, bottom: true })
const [customModelInput, setCustomModelInput] = useState("") const [customModelInput, setCustomModelInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const validationResetTimeoutRef = useRef<ReturnType< const validationResetTimeoutRef = useRef<ReturnType<
@@ -184,6 +186,26 @@ export function ModelConfigDialog({
(p) => p.id === selectedProviderId, (p) => p.id === selectedProviderId,
) )
// Track scroll position for gradient shadows
useEffect(() => {
const scrollEl = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
) as HTMLElement | null
if (!scrollEl) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollEl
setScrollState({
top: scrollTop > 10,
bottom: scrollTop < scrollHeight - clientHeight - 10,
})
}
handleScroll() // Initial check
scrollEl.addEventListener("scroll", handleScroll)
return () => scrollEl.removeEventListener("scroll", handleScroll)
}, [selectedProvider])
// Cleanup validation reset timeout on unmount // Cleanup validation reset timeout on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -368,35 +390,34 @@ export function ModelConfigDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0"> <DialogContent className="sm:max-w-3xl h-[75vh] max-h-[700px] overflow-hidden flex flex-col gap-0 p-0">
{/* Header */} <DialogHeader className="px-6 pt-6 pb-4 border-b bg-gradient-to-r from-primary/5 via-primary/3 to-transparent">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0"> <DialogTitle className="flex items-center gap-2.5 text-xl font-semibold">
<DialogTitle className="flex items-center gap-3"> <div className="p-1.5 rounded-lg bg-primary/10">
<div className="p-2 rounded-xl bg-surface-2">
<Server className="h-5 w-5 text-primary" /> <Server className="h-5 w-5 text-primary" />
</div> </div>
{dict.modelConfig?.title || "AI Model Configuration"} {dict.modelConfig?.title || "AI Model Configuration"}
</DialogTitle> </DialogTitle>
<DialogDescription className="mt-1"> <DialogDescription className="text-sm">
{dict.modelConfig?.description || {dict.modelConfig?.description ||
"Configure multiple AI providers and models for your workspace"} "Configure multiple AI providers and models for your workspace"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle"> <div className="flex flex-1 min-h-0 overflow-hidden">
{/* Provider List (Left Sidebar) */} {/* Provider List (Left Sidebar) */}
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle"> <div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20">
<div className="px-4 py-3"> <div className="px-4 py-3 border-b">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{dict.modelConfig.providers} {dict.modelConfig.providers}
</span> </span>
</div> </div>
<ScrollArea className="flex-1 px-2"> <ScrollArea className="flex-1">
<div className="space-y-1 pb-2"> <div className="p-2">
{config.providers.length === 0 ? ( {config.providers.length === 0 ? (
<div className="px-3 py-8 text-center"> <div className="px-3 py-8 text-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-3">
<Plus className="h-5 w-5 text-muted-foreground" /> <Plus className="h-5 w-5 text-muted-foreground" />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -404,78 +425,67 @@ export function ModelConfigDialog({
</p> </p>
</div> </div>
) : ( ) : (
config.providers.map((provider) => ( <div className="flex flex-col gap-1">
<button {config.providers.map((provider) => (
key={provider.id} <button
type="button" key={provider.id}
onClick={() => { type="button"
setSelectedProviderId( onClick={() => {
provider.id, setSelectedProviderId(
) provider.id,
setValidationStatus( )
provider.validated setValidationStatus(
? "success" provider.validated
: "idle", ? "success"
) : "idle",
setShowApiKey(false) )
}} setShowApiKey(false)
className={cn( }}
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
"text-left text-sm transition-all duration-150",
"hover:bg-interactive-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selectedProviderId ===
provider.id &&
"bg-surface-0 shadow-sm ring-1 ring-border-subtle",
)}
>
<div
className={cn( className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center", "group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"bg-surface-2 transition-colors duration-150",
selectedProviderId === selectedProviderId ===
provider.id && provider.id &&
"bg-primary/10", "bg-background shadow-sm ring-1 ring-border",
)} )}
> >
<ProviderLogo <ProviderLogo
provider={provider.provider} provider={provider.provider}
className="flex-shrink-0" className="flex-shrink-0"
/> />
</div> <span className="flex-1 truncate font-medium">
<span className="flex-1 truncate font-medium"> {getProviderDisplayName(
{getProviderDisplayName( provider,
provider,
)}
</span>
{provider.validated ? (
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-success-muted">
<Check className="h-3 w-3 text-success" />
</div>
) : (
<ChevronRight
className={cn(
"h-4 w-4 text-muted-foreground/50 transition-transform duration-150",
selectedProviderId ===
provider.id &&
"translate-x-0.5",
)} )}
/> </span>
)} {provider.validated ? (
</button> <div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-emerald-500/10">
)) <Check className="h-3 w-3 text-emerald-500" />
</div>
) : (
<ChevronRight
className={cn(
"h-4 w-4 text-muted-foreground/50 transition-transform",
selectedProviderId ===
provider.id &&
"translate-x-0.5",
)}
/>
)}
</button>
))}
</div>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
{/* Add Provider */} {/* Add Provider */}
<div className="p-3 border-t border-border-subtle"> <div className="p-2 border-t">
<Select <Select
onValueChange={(v) => onValueChange={(v) =>
handleAddProvider(v as ProviderName) handleAddProvider(v as ProviderName)
} }
> >
<SelectTrigger className="w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover"> <SelectTrigger className="h-9 bg-background hover:bg-accent">
<Plus className="h-4 w-4 mr-2 text-muted-foreground" /> <Plus className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue <SelectValue
placeholder={ placeholder={
@@ -504,23 +514,41 @@ export function ModelConfigDialog({
</div> </div>
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden relative">
{selectedProvider ? ( {selectedProvider ? (
<> <>
<ScrollArea className="flex-1" ref={scrollRef}> {/* Top gradient shadow */}
<div className="p-6 space-y-8"> <div
className={cn(
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
scrollState.top
? "opacity-100"
: "opacity-0",
)}
/>
{/* Bottom gradient shadow */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
scrollState.bottom
? "opacity-100"
: "opacity-0",
)}
/>
<ScrollArea className="h-full" ref={scrollRef}>
<div className="p-6 space-y-6">
{/* Provider Header */} {/* Provider Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2"> <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-muted">
<ProviderLogo <ProviderLogo
provider={ provider={
selectedProvider.provider selectedProvider.provider
} }
className="h-6 w-6" className="h-5 w-5"
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg tracking-tight"> <h3 className="font-semibold text-base">
{ {
PROVIDER_INFO[ PROVIDER_INFO[
selectedProvider selectedProvider
@@ -528,7 +556,7 @@ export function ModelConfigDialog({
].label ].label
} }
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
{selectedProvider.models {selectedProvider.models
.length === 0 .length === 0
? dict.modelConfig ? dict.modelConfig
@@ -545,8 +573,8 @@ export function ModelConfigDialog({
</p> </p>
</div> </div>
{selectedProvider.validated && ( {selectedProvider.validated && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success"> <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Check className="h-3.5 w-3.5 animate-check-pop" /> <Check className="h-3.5 w-3.5" />
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{ {
dict.modelConfig dict.modelConfig
@@ -558,13 +586,18 @@ export function ModelConfigDialog({
</div> </div>
{/* Configuration Section */} {/* Configuration Section */}
<ConfigSection <div className="space-y-4">
title={ <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
dict.modelConfig.configuration <Settings2 className="h-4 w-4" />
} <span>
icon={Settings2} {
> dict.modelConfig
<ConfigCard> .configuration
}
</span>
</div>
<div className="rounded-xl border bg-card p-4 space-y-4">
{/* Display Name */} {/* Display Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
@@ -823,7 +856,7 @@ export function ModelConfigDialog({
"h-9 px-4", "h-9 px-4",
validationStatus === validationStatus ===
"success" && "success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted", "text-emerald-600 border-emerald-200 dark:border-emerald-800",
)} )}
> >
{validationStatus === {validationStatus ===
@@ -832,7 +865,7 @@ export function ModelConfigDialog({
) : validationStatus === ) : validationStatus ===
"success" ? ( "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" /> <Check className="h-4 w-4 mr-1.5" />
{ {
dict dict
.modelConfig .modelConfig
@@ -942,7 +975,7 @@ export function ModelConfigDialog({
"h-9 px-4", "h-9 px-4",
validationStatus === validationStatus ===
"success" && "success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted", "text-emerald-600 border-emerald-200 dark:border-emerald-800",
)} )}
> >
{validationStatus === {validationStatus ===
@@ -951,7 +984,7 @@ export function ModelConfigDialog({
) : validationStatus === ) : validationStatus ===
"success" ? ( "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" /> <Check className="h-4 w-4 mr-1.5" />
{ {
dict dict
.modelConfig .modelConfig
@@ -1020,19 +1053,26 @@ export function ModelConfigDialog({
.modelConfig .modelConfig
.customEndpoint .customEndpoint
} }
className="h-9 rounded-xl font-mono text-xs" className="h-9 font-mono text-xs"
/> />
</div> </div>
</> </>
)} )}
</ConfigCard> </div>
</ConfigSection> </div>
{/* Models Section */} {/* Models Section */}
<ConfigSection <div className="space-y-4">
title={dict.modelConfig.models} <div className="flex items-center justify-between">
icon={Sparkles} <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
action={ <Sparkles className="h-4 w-4" />
<span>
{
dict.modelConfig
.models
}
</span>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<Input <Input
@@ -1048,6 +1088,7 @@ export function ModelConfigDialog({
e.target e.target
.value, .value,
) )
// Clear duplicate error when typing
if ( if (
duplicateError duplicateError
) { ) {
@@ -1076,11 +1117,12 @@ export function ModelConfigDialog({
} }
}} }}
className={cn( className={cn(
"h-8 w-44 rounded-lg font-mono text-xs", "h-8 w-48 font-mono text-xs",
duplicateError && duplicateError &&
"border-destructive focus-visible:ring-destructive", "border-destructive focus-visible:ring-destructive",
)} )}
/> />
{/* Show duplicate error for custom model input */}
{duplicateError && ( {duplicateError && (
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive"> <p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
{duplicateError} {duplicateError}
@@ -1090,7 +1132,7 @@ export function ModelConfigDialog({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 rounded-lg" className="h-8"
onClick={() => { onClick={() => {
if ( if (
customModelInput.trim() customModelInput.trim()
@@ -1127,7 +1169,7 @@ export function ModelConfigDialog({
0 0
} }
> >
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover"> <SelectTrigger className="w-32 h-8 hover:bg-accent">
<span className="text-xs"> <span className="text-xs">
{availableSuggestions.length === {availableSuggestions.length ===
0 0
@@ -1160,14 +1202,14 @@ export function ModelConfigDialog({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
} </div>
>
{/* Model List */} {/* Model List */}
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]"> <div className="rounded-xl border bg-card overflow-hidden min-h-[120px]">
{selectedProvider.models {selectedProvider.models
.length === 0 ? ( .length === 0 ? (
<div className="p-6 text-center h-full flex flex-col items-center justify-center"> <div className="p-4 text-center h-full flex flex-col items-center justify-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-2">
<Sparkles className="h-5 w-5 text-muted-foreground" /> <Sparkles className="h-5 w-5 text-muted-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -1178,7 +1220,7 @@ export function ModelConfigDialog({
</p> </p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-border-subtle"> <div className="divide-y">
{selectedProvider.models.map( {selectedProvider.models.map(
(model, index) => ( (model, index) => (
<div <div
@@ -1186,7 +1228,16 @@ export function ModelConfigDialog({
model.id model.id
} }
className={cn( className={cn(
"transition-colors duration-150 hover:bg-interactive-hover/50", "transition-colors hover:bg-muted/30",
index ===
0 &&
"rounded-t-xl",
index ===
selectedProvider
.models
.length -
1 &&
"rounded-b-xl",
)} )}
> >
<div className="flex items-center gap-3 p-3 min-w-0"> <div className="flex items-center gap-3 p-3 min-w-0">
@@ -1213,8 +1264,8 @@ export function ModelConfigDialog({
) : model.validated === ) : model.validated ===
true ? ( true ? (
// Valid // Valid
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center"> <div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
<Check className="h-4 w-4 text-success" /> <Check className="h-4 w-4 text-emerald-500" />
</div> </div>
) : model.validated === ) : model.validated ===
false ? ( false ? (
@@ -1415,7 +1466,7 @@ export function ModelConfigDialog({
</div> </div>
)} )}
</div> </div>
</ConfigSection> </div>
{/* Danger Zone */} {/* Danger Zone */}
<div className="pt-4"> <div className="pt-4">
@@ -1425,7 +1476,7 @@ export function ModelConfigDialog({
onClick={() => onClick={() =>
setDeleteConfirmOpen(true) setDeleteConfirmOpen(true)
} }
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl" className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{ {
@@ -1439,10 +1490,10 @@ export function ModelConfigDialog({
</> </>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center p-8 text-center"> <div className="h-full flex flex-col items-center justify-center p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-4">
<Server className="h-8 w-8 text-muted-foreground" /> <Server className="h-8 w-8 text-primary/60" />
</div> </div>
<h3 className="font-semibold text-lg tracking-tight mb-1"> <h3 className="font-semibold mb-1">
{dict.modelConfig.configureProviders} {dict.modelConfig.configureProviders}
</h3> </h3>
<p className="text-sm text-muted-foreground max-w-xs"> <p className="text-sm text-muted-foreground max-w-xs">
@@ -1454,7 +1505,7 @@ export function ModelConfigDialog({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0"> <div className="px-6 py-3 border-t bg-muted/20">
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5"> <p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
<Key className="h-3 w-3" /> <Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored} {dict.modelConfig.apiKeyStored}

View File

@@ -24,32 +24,6 @@ import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { cn } from "@/lib/utils"
// Reusable setting item component for consistent layout
function SettingItem({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div className="space-y-0.5 pr-4">
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-xs text-muted-foreground max-w-[260px]">
{description}
</p>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
const LANGUAGE_LABELS: Record<Locale, string> = { const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English", en: "English",
@@ -203,154 +177,145 @@ function SettingsContent({
} }
return ( return (
<DialogContent className="sm:max-w-lg p-0 gap-0"> <DialogContent className="sm:max-w-md">
{/* Header */} <DialogHeader>
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle> <DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription className="mt-1"> <DialogDescription>
{dict.settings.description} {dict.settings.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2">
{/* Content */} {accessCodeRequired && (
<div className="px-6 pb-6"> <div className="space-y-2">
<div className="divide-y divide-border-subtle"> <Label htmlFor="access-code">
{/* Access Code (conditional) */} {dict.settings.accessCode}
{accessCodeRequired && ( </Label>
<div className="py-4 first:pt-0 space-y-3"> <div className="flex gap-2">
<div className="space-y-0.5"> <Input
<Label id="access-code"
htmlFor="access-code" type="password"
className="text-sm font-medium" value={accessCode}
> onChange={(e) => setAccessCode(e.target.value)}
{dict.settings.accessCode} onKeyDown={handleKeyDown}
</Label> placeholder={
<p className="text-xs text-muted-foreground"> dict.settings.accessCodePlaceholder
{dict.settings.accessCodeDescription} }
</p> autoComplete="off"
</div> />
<div className="flex gap-2"> <Button
<Input onClick={handleSave}
id="access-code" disabled={isVerifying || !accessCode.trim()}
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
className="h-9"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
className="h-9 px-4 rounded-xl"
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div>
)}
{/* Language */}
<SettingItem
label={dict.settings.language}
description={dict.settings.languageDescription}
>
<Select
value={currentLang}
onValueChange={changeLanguage}
>
<SelectTrigger
id="language-select"
className="w-[120px] h-9 rounded-xl"
> >
<SelectValue /> {isVerifying ? "..." : dict.common.save}
</SelectTrigger> </Button>
<SelectContent> </div>
{i18n.locales.map((locale) => ( <p className="text-[0.8rem] text-muted-foreground">
<SelectItem key={locale} value={locale}> {dict.settings.accessCodeDescription}
{LANGUAGE_LABELS[locale]} </p>
</SelectItem> {error && (
))} <p className="text-[0.8rem] text-destructive">
</SelectContent> {error}
</Select> </p>
</SettingItem> )}
</div>
)}
{/* Theme */} <div className="flex items-center justify-between">
<SettingItem <div className="space-y-0.5">
label={dict.settings.theme} <Label htmlFor="language-select">
description={dict.settings.themeDescription} {dict.settings.language}
> </Label>
<Button <p className="text-[0.8rem] text-muted-foreground">
id="theme-toggle" {dict.settings.languageDescription}
variant="outline" </p>
size="icon" </div>
onClick={onToggleDarkMode} <Select value={currentLang} onValueChange={changeLanguage}>
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover" <SelectTrigger id="language-select" className="w-32">
> <SelectValue />
{darkMode ? ( </SelectTrigger>
<Sun className="h-4 w-4" /> <SelectContent>
) : ( {i18n.locales.map((locale) => (
<Moon className="h-4 w-4" /> <SelectItem key={locale} value={locale}>
)} {LANGUAGE_LABELS[locale]}
</Button> </SelectItem>
</SettingItem> ))}
</SelectContent>
</Select>
</div>
{/* Draw.io Style */} <div className="flex items-center justify-between">
<SettingItem <div className="space-y-0.5">
label={dict.settings.drawioStyle} <Label htmlFor="theme-toggle">
description={`${dict.settings.drawioStyleDescription} ${ {dict.settings.theme}
drawioUi === "min" </Label>
? dict.settings.minimal <p className="text-[0.8rem] text-muted-foreground">
: dict.settings.sketch {dict.settings.themeDescription}
}`} </p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
> >
<Button {darkMode ? (
id="drawio-ui" <Sun className="h-4 w-4" />
variant="outline" ) : (
onClick={onToggleDrawioUi} <Moon className="h-4 w-4" />
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal" )}
> </Button>
{dict.settings.switchTo}{" "} </div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min" {drawioUi === "min"
? dict.settings.sketch ? dict.settings.minimal
: dict.settings.minimal} : dict.settings.sketch}
</Button> </p>
</SettingItem> </div>
<Button
{/* Close Protection */} id="drawio-ui"
<SettingItem variant="outline"
label={dict.settings.closeProtection} size="sm"
description={dict.settings.closeProtectionDescription} onClick={onToggleDrawioUi}
> >
<Switch {dict.settings.switchTo}{" "}
id="close-protection" {drawioUi === "min"
checked={closeProtection} ? dict.settings.sketch
onCheckedChange={(checked) => { : dict.settings.minimal}
setCloseProtection(checked) </Button>
localStorage.setItem( </div>
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(), <div className="flex items-center justify-between">
) <div className="space-y-0.5">
onCloseProtectionChange?.(checked) <Label htmlFor="close-protection">
}} {dict.settings.closeProtection}
/> </Label>
</SettingItem> <p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</div> </div>
</div> </div>
<div className="pt-4 border-t border-border/50">
{/* Footer */} <p className="text-[0.75rem] text-muted-foreground text-center">
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
<p className="text-xs text-muted-foreground text-center">
Version {process.env.APP_VERSION} Version {process.env.APP_VERSION}
</p> </p>
</div> </div>
@@ -363,9 +328,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense <Suspense
fallback={ fallback={
<DialogContent className="sm:max-w-lg p-0"> <DialogContent className="sm:max-w-md">
<div className="h-80 flex items-center justify-center"> <div className="h-64 flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" /> <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div> </div>
</DialogContent> </DialogContent>
} }

View File

@@ -38,10 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"duration-200",
className className
)} )}
{...props} {...props}
@@ -60,32 +57,13 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
// Base styles "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"fixed top-[50%] left-[50%] z-50 w-full",
"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",
// 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",
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
"duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className={cn( <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
"absolute top-4 right-4 rounded-xl p-1.5",
"text-muted-foreground/60 hover:text-foreground",
"hover:bg-interactive-hover",
"transition-all duration-150",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:pointer-events-none",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
)}>
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -124,10 +102,7 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn("text-lg leading-none font-semibold", className)}
"text-xl font-semibold tracking-tight leading-tight",
className
)}
{...props} {...props}
/> />
) )
@@ -140,10 +115,7 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn("text-muted-foreground text-sm", className)}
"text-sm text-muted-foreground leading-relaxed",
className
)}
{...props} {...props}
/> />
) )

View File

@@ -8,30 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
// Base styles "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"border border-border-subtle bg-surface-1", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"text-sm text-foreground",
// Placeholder
"placeholder:text-muted-foreground/60",
// Selection
"selection:bg-primary selection:text-primary-foreground",
// Transitions
"transition-all duration-150 ease-out",
// Hover state
"hover:border-border-default",
// Focus state - refined ring
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
// File input
"file:text-foreground file:inline-flex file:h-7 file:border-0",
"file:bg-transparent file:text-sm file:font-medium",
// Disabled
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
// Invalid state
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40",
// Dark mode background
"dark:bg-surface-1",
className className
)} )}
{...props} {...props}

View File

@@ -588,15 +588,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openai": { case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
if (baseURL) { if (baseURL || overrides?.apiKey) {
// Custom base URL = third-party proxy, use Chat Completions API const customOpenAI = createOpenAI({
// for compatibility (most proxies don't support /responses endpoint) apiKey,
const customOpenAI = createOpenAI({ apiKey, baseURL }) ...(baseURL && { baseURL }),
model = customOpenAI.chat(modelId) })
} else if (overrides?.apiKey) { // Use Responses API (default) instead of .chat() to support reasoning
// Custom API key but official OpenAI endpoint, use Responses API // for gpt-5, o1, o3, o4 models. Chat Completions API does not emit reasoning events.
// to support reasoning for gpt-5, o1, o3, o4 models
const customOpenAI = createOpenAI({ apiKey })
model = customOpenAI(modelId) model = customOpenAI(modelId)
} else { } else {
model = openai(modelId) model = openai(modelId)

View File

@@ -18,39 +18,36 @@ export interface QuotaConfig {
* This hook only provides UI feedback when limits are exceeded. * This hook only provides UI feedback when limits are exceeded.
*/ */
export function useQuotaManager(config: QuotaConfig): { export function useQuotaManager(config: QuotaConfig): {
showQuotaLimitToast: (used?: number, limit?: number) => void showQuotaLimitToast: () => void
showTokenLimitToast: (used?: number, limit?: number) => void showTokenLimitToast: (used: number) => void
showTPMLimitToast: (limit?: number) => void showTPMLimitToast: () => void
} { } {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary() const dict = useDictionary()
// Show quota limit toast (request-based) // Show quota limit toast (request-based)
const showQuotaLimitToast = useCallback( const showQuotaLimitToast = useCallback(() => {
(used?: number, limit?: number) => { toast.custom(
toast.custom( (t) => (
(t) => ( <QuotaLimitToast
<QuotaLimitToast used={dailyRequestLimit}
used={used ?? dailyRequestLimit} limit={dailyRequestLimit}
limit={limit ?? dailyRequestLimit} onDismiss={() => toast.dismiss(t)}
onDismiss={() => toast.dismiss(t)} />
/> ),
), { duration: 15000 },
{ duration: 15000 }, )
) }, [dailyRequestLimit])
},
[dailyRequestLimit],
)
// Show token limit toast // Show token limit toast
const showTokenLimitToast = useCallback( const showTokenLimitToast = useCallback(
(used?: number, limit?: number) => { (used: number) => {
toast.custom( toast.custom(
(t) => ( (t) => (
<QuotaLimitToast <QuotaLimitToast
type="token" type="token"
used={used ?? dailyTokenLimit} used={used}
limit={limit ?? dailyTokenLimit} limit={dailyTokenLimit}
onDismiss={() => toast.dismiss(t)} onDismiss={() => toast.dismiss(t)}
/> />
), ),
@@ -61,21 +58,15 @@ export function useQuotaManager(config: QuotaConfig): {
) )
// Show TPM limit toast // Show TPM limit toast
const showTPMLimitToast = useCallback( const showTPMLimitToast = useCallback(() => {
(limit?: number) => { const limitDisplay =
const effectiveLimit = limit ?? tpmLimit tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
const limitDisplay = const message = formatMessage(dict.quota.tpmMessageDetailed, {
effectiveLimit >= 1000 limit: limitDisplay,
? `${effectiveLimit / 1000}k` seconds: 60,
: String(effectiveLimit) })
const message = formatMessage(dict.quota.tpmMessageDetailed, { toast.error(message, { duration: 8000 })
limit: limitDisplay, }, [tpmLimit, dict])
seconds: 60,
})
toast.error(message, { duration: 8000 })
},
[tpmLimit, dict],
)
return { return {
showQuotaLimitToast, showQuotaLimitToast,