mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
fix: complete bedrock support and UI/UX improvements
- Add bedrock to ALLOWED_CLIENT_PROVIDERS for client credentials - Pass AWS credentials through full chain (headers → API → provider) - Replace non-existent GPT-5 models with real ones (o1, o3-mini) - Add accessibility: aria-labels, focus-visible rings, inline errors - Add more AWS regions (Ohio, London, Paris, Mumbai, Seoul, São Paulo) - Fix setTimeout cleanup with useRef on component unmount - Fix TypeScript type consistency in getSelectedAIConfig fallback
This commit is contained in:
@@ -214,6 +214,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
baseUrl: req.headers.get("x-ai-base-url"),
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
modelId: req.headers.get("x-ai-model"),
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||
awsRegion: req.headers.get("x-aws-region"),
|
||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||
}
|
||||
|
||||
// Read minimal style preference from header
|
||||
|
||||
@@ -1041,6 +1041,20 @@ Continue from EXACTLY where you stopped.`,
|
||||
"x-ai-api-key": config.aiApiKey,
|
||||
}),
|
||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
||||
// AWS Bedrock credentials
|
||||
...(config.awsAccessKeyId && {
|
||||
"x-aws-access-key-id": config.awsAccessKeyId,
|
||||
}),
|
||||
...(config.awsSecretAccessKey && {
|
||||
"x-aws-secret-access-key":
|
||||
config.awsSecretAccessKey,
|
||||
}),
|
||||
...(config.awsRegion && {
|
||||
"x-aws-region": config.awsRegion,
|
||||
}),
|
||||
...(config.awsSessionToken && {
|
||||
"x-aws-session-token": config.awsSessionToken,
|
||||
}),
|
||||
}),
|
||||
...(minimalStyle && {
|
||||
"x-minimal-style": "true",
|
||||
|
||||
@@ -116,6 +116,9 @@ export function ModelConfigDialog({
|
||||
const [scrollState, setScrollState] = useState({ top: false, bottom: true })
|
||||
const [customModelInput, setCustomModelInput] = useState("")
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const validationResetTimeoutRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [validatingModelIndex, setValidatingModelIndex] = useState<
|
||||
number | null
|
||||
@@ -156,6 +159,15 @@ export function ModelConfigDialog({
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll)
|
||||
}, [selectedProvider])
|
||||
|
||||
// Cleanup validation reset timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (validationResetTimeoutRef.current) {
|
||||
clearTimeout(validationResetTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get suggested models for current provider
|
||||
const suggestedModels = selectedProvider
|
||||
? SUGGESTED_MODELS[selectedProvider.provider] || []
|
||||
@@ -292,8 +304,14 @@ export function ModelConfigDialog({
|
||||
if (allValid) {
|
||||
setValidationStatus("success")
|
||||
updateProvider(selectedProviderId!, { validated: true })
|
||||
// Reset to idle after showing success briefly
|
||||
setTimeout(() => setValidationStatus("idle"), 1500)
|
||||
// Reset to idle after showing success briefly (with cleanup)
|
||||
if (validationResetTimeoutRef.current) {
|
||||
clearTimeout(validationResetTimeoutRef.current)
|
||||
}
|
||||
validationResetTimeoutRef.current = setTimeout(() => {
|
||||
setValidationStatus("idle")
|
||||
validationResetTimeoutRef.current = null
|
||||
}, 1500)
|
||||
} else {
|
||||
setValidationStatus("error")
|
||||
setValidationError(`${errorCount} model(s) failed validation`)
|
||||
@@ -360,7 +378,7 @@ export function ModelConfigDialog({
|
||||
setShowApiKey(false)
|
||||
}}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent",
|
||||
"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",
|
||||
selectedProviderId ===
|
||||
provider.id &&
|
||||
"bg-background shadow-sm ring-1 ring-border",
|
||||
@@ -605,7 +623,12 @@ export function ModelConfigDialog({
|
||||
!showApiKey,
|
||||
)
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={
|
||||
showApiKey
|
||||
? "Hide secret access key"
|
||||
: "Show secret access key"
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -642,12 +665,16 @@ export function ModelConfigDialog({
|
||||
<SelectTrigger className="h-9 font-mono text-xs hover:bg-accent">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="us-east-1">
|
||||
us-east-1
|
||||
(N.
|
||||
Virginia)
|
||||
</SelectItem>
|
||||
<SelectItem value="us-east-2">
|
||||
us-east-2
|
||||
(Ohio)
|
||||
</SelectItem>
|
||||
<SelectItem value="us-west-2">
|
||||
us-west-2
|
||||
(Oregon)
|
||||
@@ -656,14 +683,30 @@ export function ModelConfigDialog({
|
||||
eu-west-1
|
||||
(Ireland)
|
||||
</SelectItem>
|
||||
<SelectItem value="eu-west-2">
|
||||
eu-west-2
|
||||
(London)
|
||||
</SelectItem>
|
||||
<SelectItem value="eu-west-3">
|
||||
eu-west-3
|
||||
(Paris)
|
||||
</SelectItem>
|
||||
<SelectItem value="eu-central-1">
|
||||
eu-central-1
|
||||
(Frankfurt)
|
||||
</SelectItem>
|
||||
<SelectItem value="ap-south-1">
|
||||
ap-south-1
|
||||
(Mumbai)
|
||||
</SelectItem>
|
||||
<SelectItem value="ap-northeast-1">
|
||||
ap-northeast-1
|
||||
(Tokyo)
|
||||
</SelectItem>
|
||||
<SelectItem value="ap-northeast-2">
|
||||
ap-northeast-2
|
||||
(Seoul)
|
||||
</SelectItem>
|
||||
<SelectItem value="ap-southeast-1">
|
||||
ap-southeast-1
|
||||
(Singapore)
|
||||
@@ -672,6 +715,11 @@ export function ModelConfigDialog({
|
||||
ap-southeast-2
|
||||
(Sydney)
|
||||
</SelectItem>
|
||||
<SelectItem value="sa-east-1">
|
||||
sa-east-1
|
||||
(São
|
||||
Paulo)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -771,7 +819,12 @@ export function ModelConfigDialog({
|
||||
!showApiKey,
|
||||
)
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={
|
||||
showApiKey
|
||||
? "Hide API key"
|
||||
: "Show API key"
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -984,7 +1037,7 @@ export function ModelConfigDialog({
|
||||
model.id
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 transition-colors hover:bg-muted/30",
|
||||
"transition-colors hover:bg-muted/30",
|
||||
index ===
|
||||
0 &&
|
||||
"rounded-t-xl",
|
||||
@@ -996,87 +1049,94 @@ export function ModelConfigDialog({
|
||||
"rounded-b-xl",
|
||||
)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0"
|
||||
title={
|
||||
model.validationError ||
|
||||
""
|
||||
}
|
||||
>
|
||||
{validatingModelIndex !==
|
||||
null &&
|
||||
index ===
|
||||
validatingModelIndex ? (
|
||||
// Currently validating
|
||||
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
) : validatingModelIndex !==
|
||||
null &&
|
||||
index >
|
||||
validatingModelIndex &&
|
||||
model.validated ===
|
||||
undefined ? (
|
||||
// Queued
|
||||
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
true ? (
|
||||
// Valid
|
||||
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
false ? (
|
||||
// Invalid
|
||||
<div className="w-full h-full rounded-lg bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
) : (
|
||||
// Not validated yet
|
||||
<div className="w-full h-full rounded-lg bg-primary/5 flex items-center justify-center">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Status icon */}
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
|
||||
{validatingModelIndex !==
|
||||
null &&
|
||||
index ===
|
||||
validatingModelIndex ? (
|
||||
// Currently validating
|
||||
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
) : validatingModelIndex !==
|
||||
null &&
|
||||
index >
|
||||
validatingModelIndex &&
|
||||
model.validated ===
|
||||
undefined ? (
|
||||
// Queued
|
||||
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
true ? (
|
||||
// Valid
|
||||
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
false ? (
|
||||
// Invalid
|
||||
<div className="w-full h-full rounded-lg bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
) : (
|
||||
// Not validated yet
|
||||
<div className="w-full h-full rounded-lg bg-primary/5 flex items-center justify-center">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) => {
|
||||
updateModel(
|
||||
selectedProviderId!,
|
||||
model.id,
|
||||
{
|
||||
modelId:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
validated:
|
||||
undefined,
|
||||
validationError:
|
||||
undefined,
|
||||
},
|
||||
)
|
||||
}}
|
||||
className="flex-1 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
handleDeleteModel(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
aria-label={`Delete ${model.modelId}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) => {
|
||||
updateModel(
|
||||
selectedProviderId!,
|
||||
model.id,
|
||||
{/* Show validation error inline */}
|
||||
{model.validated ===
|
||||
false &&
|
||||
model.validationError && (
|
||||
<p className="text-xs text-destructive px-3 pb-2 pl-14">
|
||||
{
|
||||
modelId:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
validated:
|
||||
undefined,
|
||||
validationError:
|
||||
undefined,
|
||||
},
|
||||
)
|
||||
}}
|
||||
className="flex-1 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
handleDeleteModel(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
model.validationError
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -299,6 +299,11 @@ export function getSelectedAIConfig(): {
|
||||
aiBaseUrl: string
|
||||
aiApiKey: string
|
||||
aiModel: string
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: string
|
||||
awsSecretAccessKey: string
|
||||
awsRegion: string
|
||||
awsSessionToken: string
|
||||
} {
|
||||
const empty = {
|
||||
accessCode: "",
|
||||
@@ -306,6 +311,10 @@ export function getSelectedAIConfig(): {
|
||||
aiBaseUrl: "",
|
||||
aiApiKey: "",
|
||||
aiModel: "",
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsRegion: "",
|
||||
awsSessionToken: "",
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return empty
|
||||
@@ -323,6 +332,11 @@ export function getSelectedAIConfig(): {
|
||||
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: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,5 +364,10 @@ export function getSelectedAIConfig(): {
|
||||
aiBaseUrl: model.baseUrl || "",
|
||||
aiApiKey: model.apiKey,
|
||||
aiModel: model.modelId,
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: model.awsAccessKeyId || "",
|
||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||
awsRegion: model.awsRegion || "",
|
||||
awsSessionToken: model.awsSessionToken || "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface ClientOverrides {
|
||||
baseUrl?: string | null
|
||||
apiKey?: string | null
|
||||
modelId?: string | null
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId?: string | null
|
||||
awsSecretAccessKey?: string | null
|
||||
awsRegion?: string | null
|
||||
awsSessionToken?: string | null
|
||||
}
|
||||
|
||||
// Providers that can be used with client-provided API keys
|
||||
@@ -41,6 +46,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"anthropic",
|
||||
"google",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
"siliconflow",
|
||||
@@ -537,12 +543,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||
const bedrockProvider = createAmazonBedrock({
|
||||
region: process.env.AWS_REGION || "us-west-2",
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||
const hasClientCredentials =
|
||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||
const bedrockRegion =
|
||||
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
|
||||
|
||||
const bedrockProvider = hasClientCredentials
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: overrides.awsAccessKeyId!,
|
||||
secretAccessKey: overrides.awsSecretAccessKey!,
|
||||
...(overrides?.awsSessionToken && {
|
||||
sessionToken: overrides.awsSessionToken,
|
||||
}),
|
||||
})
|
||||
: createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
|
||||
@@ -83,29 +83,22 @@ export const PROVIDER_INFO: Record<
|
||||
// Suggested models per provider for quick add
|
||||
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||
openai: [
|
||||
// GPT-5.2 series
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5.2-chat-latest",
|
||||
"gpt-5.2",
|
||||
// GPT-5.1 series
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-chat-latest",
|
||||
"gpt-5.1",
|
||||
// GPT-5 series
|
||||
"gpt-5-pro",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-chat-latest",
|
||||
// GPT-4.1 series
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
// GPT-4o series
|
||||
// GPT-4o series (latest)
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-2024-11-20",
|
||||
// GPT-4 Turbo
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-preview",
|
||||
// o1/o3 reasoning models
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o1-preview",
|
||||
"o3-mini",
|
||||
// GPT-4
|
||||
"gpt-4",
|
||||
// GPT-3.5
|
||||
"gpt-3.5-turbo",
|
||||
],
|
||||
anthropic: [
|
||||
// Claude 4.5 series (latest)
|
||||
|
||||
Reference in New Issue
Block a user