Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/admin/public/provider/kimi-color.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
672 changes: 125 additions & 547 deletions backend/admin/src/app/admin/llm/components/provider-form.tsx

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions backend/admin/src/app/admin/llm/components/wizard/form-stepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client';

import React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';

export interface StepMeta {
key: string;
title: string;
description: string;
}

interface FormStepperProps {
steps: StepMeta[];
currentStep: number;
visitedSteps: number[];
onStepClick: (index: number) => void;
}

export function FormStepper({ steps, currentStep, visitedSteps, onStepClick }: FormStepperProps) {
return (
<ol className="flex w-full items-center">
{steps.map((step, index) => {
const isCurrent = index === currentStep;
const isCompleted = index < currentStep;
const isVisited = visitedSteps.includes(index);
return (
<li key={step.key} className={cn("flex items-center", index < steps.length - 1 && "flex-1")}>
<button
type="button"
disabled={!isVisited || isCurrent}
onClick={() => onStepClick(index)}
className={cn(
"group flex items-center gap-3 rounded-lg px-2 py-1 text-left transition-colors",
isVisited && !isCurrent && "cursor-pointer hover:bg-muted/60",
(!isVisited || isCurrent) && "cursor-default"
)}
>
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border text-sm font-semibold transition-colors",
isCurrent && "border-primary bg-primary text-primary-foreground",
isCompleted && "border-primary bg-primary/10 text-primary",
!isCurrent && !isCompleted && "border-muted-foreground/30 text-muted-foreground"
)}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</span>
<span className="hidden sm:block">
<span className={cn(
"block text-sm font-medium leading-tight",
isCurrent ? "text-foreground" : "text-muted-foreground group-hover:text-foreground"
)}>
{step.title}
</span>
<span className="block text-xs text-muted-foreground/80 leading-tight mt-0.5">
{step.description}
</span>
</span>
</button>
{index < steps.length - 1 && (
<div className={cn("mx-3 h-px flex-1", isCompleted ? "bg-primary" : "bg-border")} />
)}
</li>
);
})}
</ol>
);
}
165 changes: 165 additions & 0 deletions backend/admin/src/app/admin/llm/components/wizard/step-basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
'use client';

import React, { useState } from 'react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import type { UseFormReturn } from 'react-hook-form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { X, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PROVIDER_OPTIONS, FormValues } from '../../schema';

interface StepBasicProps {
form: UseFormReturn<FormValues>;
actions?: React.ReactNode;
}

export function StepBasic({ form, actions }: StepBasicProps) {
const { t } = useTranslation();
const [tagInput, setTagInput] = useState("");

return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1.5">
<CardTitle>{t('llm.form.basic.title')}</CardTitle>
<CardDescription>{t('llm.form.basic.description')}</CardDescription>
</div>
{actions}
</div>
</CardHeader>
<CardContent className="space-y-6">
<FormField
control={form.control}
name="provider_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t('llm.form.basic.brand')}</FormLabel>
<FormControl>
<div className="grid grid-cols-3 md:grid-cols-4 gap-3">
{PROVIDER_OPTIONS.map((option) => {
const selected = field.value === option.value;
return (
<div key={option.value} className="relative group">
<button
type="button"
onClick={() => {
field.onChange(option.value);
// 名称为空或仍是上一品牌的自动填充值时,跟随新品牌覆盖;手动改过的名称保留
const currentName = form.getValues('name');
const isAutoFilled = PROVIDER_OPTIONS.some((o) => o.label === currentName);
(currentName && !isAutoFilled) || form.setValue('name', option.label, { shouldValidate: true });
}}
className={cn(
"flex w-full flex-col items-center gap-2 rounded-lg border bg-background p-3 text-sm transition-all hover:border-primary/60 hover:bg-muted/50",
selected && "border-primary ring-2 ring-primary/30 bg-primary/5 hover:bg-primary/5"
)}
>
<div className="relative h-8 w-8 shrink-0 overflow-hidden rounded-md">
<Image src={option.icon} alt={option.label} fill className="object-contain" />
</div>
<span className="text-xs font-medium text-center leading-tight">{option.label}</span>
</button>
<a
href={option.docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label={t('llm.form.basic.brandDocs', { name: option.label })}
title={t('llm.form.basic.brandDocs', { name: option.label })}
className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded text-muted-foreground/50 opacity-70 transition-all hover:bg-background hover:text-primary hover:opacity-100 hover:shadow-sm focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 group-hover:opacity-100"
>
<ExternalLink className="h-3 w-3" />
</a>
</div>
);
})}
</div>
</FormControl>
<FormDescription>{t('llm.form.basic.brandHint')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('llm.form.basic.name')}</FormLabel>
<FormControl>
<Input placeholder={t('llm.form.basic.namePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t('llm.form.basic.tags')}</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-2 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 min-h-[2.5rem]">
{field.value?.map((tag, index) => (
<Badge key={index} variant="secondary" className="flex items-center gap-1">
{tag}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newTags = [...(field.value || [])];
newTags.splice(index, 1);
field.onChange(newTags);
}}
/>
</Badge>
))}
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground min-w-[120px]"
placeholder={field.value?.length ? "" : t('llm.form.basic.tagsPlaceholder')}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = tagInput.trim();
if (val) {
const currentTags = field.value || [];
if (!currentTags.includes(val)) {
field.onChange([...currentTags, val]);
}
setTagInput("");
}
} else if (e.key === 'Backspace' && !tagInput && field.value?.length) {
const newTags = [...(field.value || [])];
newTags.pop();
field.onChange(newTags);
}
}}
/>
</div>
</FormControl>
<FormDescription>{t('llm.form.basic.tagsDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
);
}
125 changes: 125 additions & 0 deletions backend/admin/src/app/admin/llm/components/wizard/step-connection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use client';

import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { UseFormReturn } from 'react-hook-form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, Eye, EyeOff, Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FormValues } from '../../schema';

interface StepConnectionProps {
form: UseFormReturn<FormValues>;
actions?: React.ReactNode;
}

export function StepConnection({ form, actions }: StepConnectionProps) {
const { t } = useTranslation();
const [showApiKey, setShowApiKey] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);

return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1.5">
<CardTitle>{t('llm.form.connection.title')}</CardTitle>
<CardDescription>{t('llm.form.connection.description')}</CardDescription>
</div>
{actions}
</div>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('llm.form.connection.baseUrl')}</FormLabel>
<FormControl>
<Input placeholder={t('llm.form.connection.baseUrlPlaceholder')} {...field} />
</FormControl>
<FormDescription>{t('llm.form.connection.baseUrlDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('llm.form.connection.apiKey')}</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showApiKey ? 'text' : 'password'}
placeholder={t('llm.form.connection.apiKeyPlaceholder')}
autoComplete="off"
className="pr-10"
{...field}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
tabIndex={-1}
aria-label={showApiKey ? t('llm.form.connection.hideApiKey') : t('llm.form.connection.showApiKey')}
title={showApiKey ? t('llm.form.connection.hideApiKey') : t('llm.form.connection.showApiKey')}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-muted-foreground/50"
>
<Settings2 className="h-4 w-4" />
<span className="flex-1 text-left">{t('llm.form.connection.advancedConfig')}</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", advancedOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<FormField
control={form.control}
name="config_json"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
rows={5}
placeholder={t('llm.form.connection.advancedConfigPlaceholder')}
className="font-mono text-sm"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
Loading