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

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
# AI Video Assistant 平台 —— 开发常用命令
#
# 用法:make <目标>,例如 make up / make db-seed。
# 多数目标是对 docker compose 的薄封装,集中沉淀平时手敲的开发脚本。
# 容器内执行 psql 的固定前缀(postgres/postgres/postgres,见 docker-compose.yaml)
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
.DEFAULT_GOAL := help
.PHONY: help up down restart logs api-logs db db-list db-seed db-clear db-reset
help: ## 列出所有可用目标
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
# ---- 服务编排 ----
up: ## 起 postgres + api + ui
docker compose up -d
down: ## 停掉所有服务(保留数据卷)
docker compose down
restart: ## 重建 api 容器(改了 env/CORS 后用)
docker compose up -d --force-recreate api
logs: ## 跟随全部服务日志
docker compose logs -f
api-logs: ## 只看后端日志
docker compose logs -f api
# ---- 数据库 ----
db: ## 进入交互式 psql
docker compose exec postgres psql -U postgres -d postgres
db-list: ## 列出模型凭证(key 明文,仅本地调试用)
@$(PSQL) -c "SELECT id, name, type, interface_type, is_default FROM provider_credentials ORDER BY id;"
db-seed: ## 灌入 12 条模型凭证种子(幂等)
$(PSQL) < backend/db/seed_credentials.sql
db-clear: ## 清空模型凭证表
$(PSQL) -c "TRUNCATE provider_credentials;"
db-reset: db-clear db-seed ## 清空后重新灌种子

View File

@@ -40,4 +40,6 @@ DATABASE_URL = os.getenv(
# ---- 服务 ----
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
CORS_ORIGINS = _split(os.getenv("CORS_ORIGINS", "http://localhost:3000"))
CORS_ORIGINS = _split(
os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
)

View File

@@ -0,0 +1,26 @@
-- 模型凭证种子数据(对应前端原 mockModels 的 12 条)。
--
-- 用法(从仓库根目录):
-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_credentials.sql
--
-- 说明:
-- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等,可重复执行不重复插入。
-- * api_key 在库里是明文(读取走 API 时才打码),这里填的是占位示例 key。
-- * 每种 type 选第一条置为默认(is_default),供后端 config_resolver 解析使用。
INSERT INTO provider_credentials
(id, name, model_id, type, interface_type, api_url, api_key, is_default)
VALUES
('model_001', 'DeepSeek-V3', 'deepseek-chat', 'LLM', 'openai', 'https://api.deepseek.com/v1', 'sk-deepseek-7f3a9c2e1b', TRUE),
('model_002', 'Qwen-Max', 'qwen-max', 'LLM', 'openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-qwen-4d8e2a6f0c', FALSE),
('model_003', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', 'xf-asr-9b1c3d5e7a', TRUE),
('model_004', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', FALSE),
('model_005', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', 'xf-tts-6c8a0b2d4f', TRUE),
('model_006', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', FALSE),
('model_007', 'OpenAI TTS', 'tts-1', 'TTS', 'openai', 'https://api.openai.com/v1/audio/speech', 'sk-openai-tts-8f0a2c', FALSE),
('model_008', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', TRUE),
('model_009', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', FALSE),
('model_010', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', TRUE),
('model_011', 'Kimi-K2', 'moonshot-v1-8k', 'LLM', 'openai', 'https://api.moonshot.cn/v1', 'sk-kimi-7a9c1e3b5d', FALSE),
('model_012', 'BGE Embedding', 'bge-m3', 'Embedding', 'openai', 'https://api.siliconflow.cn/v1/embeddings', 'sk-bge-2d4f6a8c0e', FALSE)
ON CONFLICT (id) DO NOTHING;

View File

@@ -42,7 +42,7 @@ services:
environment:
# 容器内连库:用服务名 postgres,覆盖 .env 里的 localhost
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
CORS_ORIGINS: "http://localhost:3000"
CORS_ORIGINS: "http://localhost:3000,http://127.0.0.1:3000"
ports:
- "8000:8000"
depends_on:

View File

@@ -3,6 +3,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ["127.0.0.1"],
// 钉死项目根,避免容器内 bind-mount 时 Turbopack 把根推断成 /app/src/app。
// __dirname 即本配置所在目录(容器内 = /app,宿主机 = ai-video/frontend)。
turbopack: {
root: __dirname,
},
};
export default nextConfig;

View File

@@ -1033,9 +1033,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1052,9 +1049,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1071,9 +1065,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1090,9 +1081,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1109,9 +1097,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1128,9 +1113,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1147,9 +1129,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1166,9 +1145,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1185,9 +1161,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1210,9 +1183,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1235,9 +1205,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1260,9 +1227,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1285,9 +1249,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1310,9 +1271,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1335,9 +1293,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1360,9 +1315,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1740,9 +1692,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1759,9 +1708,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1778,9 +1724,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1797,9 +1740,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3612,9 +3552,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3632,9 +3569,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3652,9 +3586,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3672,9 +3603,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4320,9 +4248,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4337,9 +4262,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4354,9 +4276,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4371,9 +4290,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4388,9 +4304,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4405,9 +4318,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4422,9 +4332,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4439,9 +4346,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4456,9 +4360,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4473,9 +4374,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8246,9 +8144,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8270,9 +8165,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8294,9 +8186,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8318,9 +8207,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [

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>

71
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* 后端 API 客户端。基址走 NEXT_PUBLIC_API_BASE,缺省指向本地后端 :8000。
*
* JSON 契约与后端 schemas.py 对齐(camelCase),所以返回体可直接喂给页面 state。
* 注意:api_key 读取时后端永远打码,写回打码占位符表示"不改 key"(写时哨兵)。
*/
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
export type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini";
/** 列表/详情返回(api_key 已打码) */
export type Credential = {
id: string;
name: string;
modelId: string;
type: ModelType;
interfaceType: InterfaceType;
apiUrl: string;
apiKey: string;
isDefault: boolean;
};
/** 创建/更新入参。apiKey 留空或打码值 → 后端保留旧 key */
export type CredentialUpsert = {
name: string;
modelId: string;
type: ModelType;
interfaceType: InterfaceType;
apiUrl: string;
apiKey: string;
isDefault: boolean;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) {
let detail = `请求失败 (${res.status})`;
try {
const body = (await res.json()) as { detail?: string };
if (body?.detail) detail = body.detail;
} catch {
// 响应体不是 JSON,沿用默认错误信息
}
throw new Error(detail);
}
// 204 / 空响应保护
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export const credentialsApi = {
list: () => request<Credential[]>("/api/credentials"),
create: (body: CredentialUpsert) =>
request<Credential>("/api/credentials", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: CredentialUpsert) =>
request<Credential>(`/api/credentials/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/credentials/${id}`, { method: "DELETE" }),
};