Enhance AI Video Assistant platform with new Makefile for development commands, update CORS origins for local access, and implement API client for credential management. Add seed data for model credentials and refactor ComponentsModelsPage to utilize API for dynamic data loading. Update Next.js configuration for Turbopack compatibility.

This commit is contained in:
Xin Wang
2026-06-08 22:39:45 +08:00
parent 42cab2a6ef
commit 7e8e8624b4
8 changed files with 293 additions and 251 deletions

View File

@@ -47,11 +47,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useState, type ReactNode } from "react";
type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import {
credentialsApi,
type Credential,
type InterfaceType,
type ModelType,
} from "@/lib/api";
/** 各资源类型可选的接口类型 */
const interfaceOptionsByType: Record<ModelType, InterfaceType[]> = {
@@ -64,126 +66,8 @@ const interfaceOptionsByType: Record<ModelType, InterfaceType[]> = {
const modelTypes: ModelType[] = ["LLM", "ASR", "TTS", "Realtime", "Embedding"];
type ModelResource = {
id: string;
name: string;
modelId: string;
type: ModelType;
interfaceType: InterfaceType;
apiUrl: string;
apiKey: string;
};
const mockModels: ModelResource[] = [
{
id: "model_001",
name: "DeepSeek-V3",
modelId: "deepseek-chat",
type: "LLM",
interfaceType: "openai",
apiUrl: "https://api.deepseek.com/v1",
apiKey: "sk-deepseek-7f3a9c2e1b",
},
{
id: "model_002",
name: "Qwen-Max",
modelId: "qwen-max",
type: "LLM",
interfaceType: "openai",
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-qwen-4d8e2a6f0c",
},
{
id: "model_003",
name: "讯飞语音识别",
modelId: "iat",
type: "ASR",
interfaceType: "xfyun",
apiUrl: "https://iat-api.xfyun.cn/v2/iat",
apiKey: "xf-asr-9b1c3d5e7a",
},
{
id: "model_004",
name: "Paraformer 识别",
modelId: "paraformer-realtime-v2",
type: "ASR",
interfaceType: "dashscope",
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/asr",
apiKey: "sk-paraformer-2e4f6a",
},
{
id: "model_005",
name: "讯飞语音合成",
modelId: "tts",
type: "TTS",
interfaceType: "xfyun",
apiUrl: "https://tts-api.xfyun.cn/v2/tts",
apiKey: "xf-tts-6c8a0b2d4f",
},
{
id: "model_006",
name: "CosyVoice 合成",
modelId: "cosyvoice-v1",
type: "TTS",
interfaceType: "dashscope",
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts",
apiKey: "sk-cosyvoice-1a3c5e",
},
{
id: "model_007",
name: "OpenAI TTS",
modelId: "tts-1",
type: "TTS",
interfaceType: "openai",
apiUrl: "https://api.openai.com/v1/audio/speech",
apiKey: "sk-openai-tts-8f0a2c",
},
{
id: "model_008",
name: "GPT Realtime",
modelId: "gpt-4o-realtime-preview",
type: "Realtime",
interfaceType: "openai",
apiUrl: "https://api.openai.com/v1/realtime",
apiKey: "sk-realtime-3b5d7f9a1c",
},
{
id: "model_009",
name: "Gemini Live",
modelId: "gemini-2.0-flash-live",
type: "Realtime",
interfaceType: "gemini",
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKey: "gm-live-5e7a9c1b3d",
},
{
id: "model_010",
name: "text-embedding-3",
modelId: "text-embedding-3-small",
type: "Embedding",
interfaceType: "openai",
apiUrl: "https://api.openai.com/v1/embeddings",
apiKey: "sk-embed-0c2e4a6f8b",
},
{
id: "model_011",
name: "Kimi-K2",
modelId: "moonshot-v1-8k",
type: "LLM",
interfaceType: "openai",
apiUrl: "https://api.moonshot.cn/v1",
apiKey: "sk-kimi-7a9c1e3b5d",
},
{
id: "model_012",
name: "BGE Embedding",
modelId: "bge-m3",
type: "Embedding",
interfaceType: "openai",
apiUrl: "https://api.siliconflow.cn/v1/embeddings",
apiKey: "sk-bge-2d4f6a8c0e",
},
];
// 列表项类型直接复用 API 契约(camelCase,api_key 已打码)
type ModelResource = Credential;
type ModelForm = {
name: string;
@@ -212,14 +96,41 @@ export function ComponentsModelsPage() {
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
const [currentPage, setCurrentPage] = useState(1);
const [models, setModels] = useState<ModelResource[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// 编辑时记住原记录,保存时回填 isDefault 等未在表单暴露的字段
const [editingModel, setEditingModel] = useState<ModelResource | null>(null);
const [form, setForm] = useState<ModelForm>(emptyForm);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
const loadModels = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
setModels(await credentialsApi.list());
} catch (error) {
setLoadError(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// 挂载时拉取列表:这是与后端(外部系统)的同步,loadModels 内的 setLoading 属预期
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadModels();
}, [loadModels]);
function updateForm<K extends keyof ModelForm>(key: K, value: ModelForm[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
@@ -256,28 +167,73 @@ export function ComponentsModelsPage() {
function openCreate() {
setEditingId(null);
setEditingModel(null);
setForm(emptyForm);
setShowKey(false);
setTestResult("idle");
setSaveError(null);
setDialogOpen(true);
}
function openEdit(model: ModelResource) {
setEditingId(model.id);
setEditingModel(model);
setForm({
name: model.name,
modelId: model.modelId,
type: model.type,
interfaceType: model.interfaceType,
apiUrl: model.apiUrl,
// 后端回传已打码,原样带回 → 不修改则保留旧 key(写时哨兵)
apiKey: model.apiKey,
});
setShowKey(false);
setTestResult("idle");
setSaveError(null);
setDialogOpen(true);
}
const filteredModels = mockModels.filter((model) => {
async function handleSave() {
setSaving(true);
setSaveError(null);
const payload = {
name: form.name.trim(),
modelId: form.modelId.trim(),
type: form.type,
interfaceType: form.interfaceType,
apiUrl: form.apiUrl.trim(),
apiKey: form.apiKey,
// 表单未暴露 isDefault,编辑时沿用原值,新建默认 false
isDefault: editingModel?.isDefault ?? false,
};
try {
if (editingId) {
await credentialsApi.update(editingId, payload);
} else {
await credentialsApi.create(payload);
}
setDialogOpen(false);
await loadModels();
} catch (error) {
setSaveError(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
setDeletingId(id);
try {
await credentialsApi.remove(id);
await loadModels();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
const filteredModels = models.filter((model) => {
if (typeFilter !== "全部" && model.type !== typeFilter) {
return false;
}
@@ -448,8 +404,20 @@ export function ComponentsModelsPage() {
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem variant="destructive" className="rounded-lg">
<Trash2 size={14} />
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === model.id}
onSelect={(event) => {
event.preventDefault();
void handleDelete(model.id);
}}
>
{deletingId === model.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -458,13 +426,41 @@ export function ComponentsModelsPage() {
</div>
))}
{filteredModels.length === 0 && (
{loading && (
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && loadError && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
<div className="font-medium text-destructive">
</div>
<div className="mt-2 text-sm text-muted-foreground">
{loadError}
</div>
<Button
variant="outline"
size="sm"
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => void loadModels()}
>
</Button>
</div>
)}
{!loading && !loadError && filteredModels.length === 0 && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
{models.length === 0 ? "暂无模型资源" : "未找到匹配的模型"}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{models.length === 0
? "点击右上角「添加模型」创建第一个资源。"
: "请调整关键词或筛选条件后再试。"}
</div>
</div>
)}
@@ -716,22 +712,33 @@ export function ComponentsModelsPage() {
</span>
)}
{saveError && (
<span className="flex items-center gap-1.5 text-xs text-destructive">
<XCircle size={14} />
{saveError}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={saving}
onClick={() => setDialogOpen(false)}
>
</Button>
<Button
className="gap-2"
disabled={!canSave}
onClick={() => setDialogOpen(false)}
disabled={!canSave || saving}
onClick={() => void handleSave()}
>
<Brain size={16} />
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Brain size={16} />
)}
{editingId ? "保存" : "添加"}
</Button>
</div>