增加代码知识库;修复文档处理内容;增加API设置
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Copy, Check, List } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CheckCircle2, Edit, KeyRound, List, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -28,46 +27,159 @@ import {
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export interface APIKey {
|
||||
interface ModelConfig {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
provider: string;
|
||||
api_base: string | null;
|
||||
chat_model: string;
|
||||
embedding_model: string;
|
||||
is_active: boolean;
|
||||
has_api_key: boolean;
|
||||
api_key_masked: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface APIKeyCreate {
|
||||
name: string;
|
||||
is_active?: boolean;
|
||||
interface ProviderOption {
|
||||
provider: string;
|
||||
label: string;
|
||||
default_api_base: string;
|
||||
default_chat_model: string;
|
||||
default_embedding_model: string;
|
||||
chat_models: string[];
|
||||
embedding_models: string[];
|
||||
requires_api_key: boolean;
|
||||
supports_custom_api_base: boolean;
|
||||
}
|
||||
|
||||
export interface APIKeyUpdate {
|
||||
name?: string;
|
||||
is_active?: boolean;
|
||||
interface ProviderOptionsResponse {
|
||||
providers: ProviderOption[];
|
||||
defaults: {
|
||||
provider: string;
|
||||
api_base: string;
|
||||
chat_model: string;
|
||||
embedding_model: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelConfigForm {
|
||||
name: string;
|
||||
provider: string;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
chat_model: string;
|
||||
embedding_model: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_OPTIONS: ProviderOptionsResponse = {
|
||||
providers: [
|
||||
{
|
||||
provider: "dashscope",
|
||||
label: "DashScope",
|
||||
default_api_base: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
default_chat_model: "qwen3-max",
|
||||
default_embedding_model: "text-embedding-v4",
|
||||
chat_models: ["qwen3-max", "qwen-plus", "qwen-turbo", "qwen-max"],
|
||||
embedding_models: ["text-embedding-v4", "text-embedding-v3", "text-embedding-v2"],
|
||||
requires_api_key: true,
|
||||
supports_custom_api_base: true,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
label: "OpenAI",
|
||||
default_api_base: "https://api.openai.com/v1",
|
||||
default_chat_model: "gpt-4o",
|
||||
default_embedding_model: "text-embedding-3-small",
|
||||
chat_models: ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini"],
|
||||
embedding_models: ["text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"],
|
||||
requires_api_key: true,
|
||||
supports_custom_api_base: true,
|
||||
},
|
||||
{
|
||||
provider: "openai_compatible",
|
||||
label: "OpenAI Compatible",
|
||||
default_api_base: "",
|
||||
default_chat_model: "qwen3-max",
|
||||
default_embedding_model: "text-embedding-v4",
|
||||
chat_models: ["qwen3-max", "deepseek-chat", "gpt-4o-mini"],
|
||||
embedding_models: ["text-embedding-v4", "text-embedding-3-small"],
|
||||
requires_api_key: true,
|
||||
supports_custom_api_base: true,
|
||||
},
|
||||
{
|
||||
provider: "ollama",
|
||||
label: "Ollama",
|
||||
default_api_base: "http://localhost:11434",
|
||||
default_chat_model: "deepseek-r1:7b",
|
||||
default_embedding_model: "nomic-embed-text",
|
||||
chat_models: ["deepseek-r1:7b", "llama3.1", "qwen2.5"],
|
||||
embedding_models: ["nomic-embed-text", "mxbai-embed-large"],
|
||||
requires_api_key: false,
|
||||
supports_custom_api_base: true,
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
provider: "dashscope",
|
||||
api_base: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
chat_model: "qwen3-max",
|
||||
embedding_model: "text-embedding-v4",
|
||||
},
|
||||
};
|
||||
|
||||
export default function APIKeysPage() {
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||||
const [configs, setConfigs] = useState<ModelConfig[]>([]);
|
||||
const [options, setOptions] = useState<ProviderOptionsResponse>(DEFAULT_PROVIDER_OPTIONS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isAPIListDialogOpen, setIsAPIListDialogOpen] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isProviderDialogOpen, setIsProviderDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ModelConfig | null>(null);
|
||||
const [form, setForm] = useState<ModelConfigForm>({
|
||||
name: "",
|
||||
provider: DEFAULT_PROVIDER_OPTIONS.defaults.provider,
|
||||
api_key: "",
|
||||
api_base: DEFAULT_PROVIDER_OPTIONS.defaults.api_base,
|
||||
chat_model: DEFAULT_PROVIDER_OPTIONS.defaults.chat_model,
|
||||
embedding_model: DEFAULT_PROVIDER_OPTIONS.defaults.embedding_model,
|
||||
is_active: true,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// 获取 API Keys 列表
|
||||
const fetchAPIKeys = async () => {
|
||||
const selectedProvider = useMemo(
|
||||
() => options.providers.find((item) => item.provider === form.provider),
|
||||
[form.provider, options.providers]
|
||||
);
|
||||
|
||||
const fetchPageData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await api.get("/api/api-keys");
|
||||
setApiKeys(data);
|
||||
const [providerData, configData] = await Promise.all([
|
||||
api.get("/api/model-configs/providers"),
|
||||
api.get("/api/model-configs"),
|
||||
]);
|
||||
setOptions({
|
||||
providers: providerData.providers?.length ? providerData.providers : DEFAULT_PROVIDER_OPTIONS.providers,
|
||||
defaults: providerData.defaults || DEFAULT_PROVIDER_OPTIONS.defaults,
|
||||
});
|
||||
setConfigs(configData);
|
||||
if (!editingConfig) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
provider: providerData.defaults?.provider || DEFAULT_PROVIDER_OPTIONS.defaults.provider,
|
||||
api_base: providerData.defaults?.api_base || DEFAULT_PROVIDER_OPTIONS.defaults.api_base,
|
||||
chat_model: providerData.defaults?.chat_model || DEFAULT_PROVIDER_OPTIONS.defaults.chat_model,
|
||||
embedding_model:
|
||||
providerData.defaults?.embedding_model || DEFAULT_PROVIDER_OPTIONS.defaults.embedding_model,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取 API 密钥失败",
|
||||
description: "获取模型配置失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
@@ -76,228 +188,304 @@ export default function APIKeysPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAPIKeys();
|
||||
fetchPageData();
|
||||
}, []);
|
||||
|
||||
// 创建新的 API Key
|
||||
const createAPIKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入 API 密钥名称",
|
||||
variant: "destructive",
|
||||
});
|
||||
const resetFormForCreate = () => {
|
||||
const defaults = options.defaults || DEFAULT_PROVIDER_OPTIONS.defaults;
|
||||
setEditingConfig(null);
|
||||
setForm({
|
||||
name: "",
|
||||
provider: defaults.provider,
|
||||
api_key: "",
|
||||
api_base: defaults.api_base,
|
||||
chat_model: defaults.chat_model,
|
||||
embedding_model: defaults.embedding_model,
|
||||
is_active: true,
|
||||
});
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (config: ModelConfig) => {
|
||||
setEditingConfig(config);
|
||||
setForm({
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
api_key: "",
|
||||
api_base: config.api_base || "",
|
||||
chat_model: config.chat_model,
|
||||
embedding_model: config.embedding_model,
|
||||
is_active: config.is_active,
|
||||
});
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const updateProvider = (provider: string) => {
|
||||
const option = options.providers.find((item) => item.provider === provider);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
provider,
|
||||
api_base: option?.default_api_base || "",
|
||||
chat_model: option?.default_chat_model || current.chat_model,
|
||||
embedding_model: option?.default_embedding_model || current.embedding_model,
|
||||
}));
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: "错误", description: "请输入配置名称", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (!editingConfig && selectedProvider?.requires_api_key && !form.api_key.trim()) {
|
||||
toast({ title: "错误", description: "请输入 API 密钥", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const data = await api.post("/api/api-keys", {
|
||||
name: newKeyName,
|
||||
is_active: true,
|
||||
});
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
provider: form.provider,
|
||||
api_base: form.api_base.trim() || null,
|
||||
chat_model: form.chat_model.trim(),
|
||||
embedding_model: form.embedding_model.trim(),
|
||||
is_active: form.is_active,
|
||||
};
|
||||
if (form.api_key.trim()) {
|
||||
payload.api_key = form.api_key.trim();
|
||||
}
|
||||
|
||||
setApiKeys([...apiKeys, data]);
|
||||
setNewKeyName("");
|
||||
setIsDialogOpen(false);
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥创建成功",
|
||||
});
|
||||
} catch (error) {
|
||||
if (editingConfig) {
|
||||
const updated = await api.put(`/api/model-configs/${editingConfig.id}`, payload);
|
||||
setConfigs((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||
} else {
|
||||
const created = await api.post("/api/model-configs", {
|
||||
...payload,
|
||||
api_key: form.api_key.trim(),
|
||||
});
|
||||
setConfigs((current) => [created, ...current]);
|
||||
}
|
||||
|
||||
setIsEditorOpen(false);
|
||||
toast({ title: "成功", description: "模型配置已保存" });
|
||||
fetchPageData();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建 API 密钥失败",
|
||||
description: error?.message || "保存模型配置失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除 API Key
|
||||
const deleteAPIKey = async (id: number) => {
|
||||
const toggleActive = async (config: ModelConfig) => {
|
||||
try {
|
||||
const response = await api.delete(`/api/api-keys/${id}`);
|
||||
|
||||
if (!response.ok) throw new Error("删除 API 密钥失败");
|
||||
|
||||
setApiKeys(apiKeys.filter((key) => key.id !== id));
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥删除成功",
|
||||
const updated = await api.put(`/api/model-configs/${config.id}`, {
|
||||
is_active: !config.is_active,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除 API 密钥失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 更新 API Key 状态
|
||||
const toggleAPIKeyStatus = async (id: number, currentStatus: boolean) => {
|
||||
try {
|
||||
const response = await api.put(`/api/api-keys/${id}`, {
|
||||
is_active: !currentStatus,
|
||||
});
|
||||
|
||||
setApiKeys(
|
||||
apiKeys.map((key) =>
|
||||
key.id === id ? { ...key, is_active: !currentStatus } : key
|
||||
setConfigs((current) =>
|
||||
current.map((item) =>
|
||||
item.id === updated.id
|
||||
? updated
|
||||
: updated.is_active
|
||||
? { ...item, is_active: false }
|
||||
: item
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥状态更新成功",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({ title: "成功", description: "启用状态已更新" });
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "更新 API 密钥失败",
|
||||
description: error?.message || "更新启用状态失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 复制 API Key
|
||||
const copyAPIKey = async (id: number, key: string) => {
|
||||
const deleteConfig = async (id: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
}, 3000);
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥已复制到剪贴板",
|
||||
});
|
||||
} catch (error) {
|
||||
await api.delete(`/api/model-configs/${id}`);
|
||||
setConfigs((current) => current.filter((item) => item.id !== id));
|
||||
toast({ title: "成功", description: "模型配置已删除" });
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "复制 API 密钥失败",
|
||||
description: error?.message || "删除模型配置失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const providerLabel = (provider: string) =>
|
||||
options.providers.find((item) => item.provider === provider)?.label || provider;
|
||||
|
||||
const formatDate = (value: string | null) =>
|
||||
value ? new Date(value).toLocaleString("zh-CN") : "未使用";
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-10">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold">API 密钥</h1>
|
||||
<div className="flex gap-4">
|
||||
<Dialog
|
||||
open={isAPIListDialogOpen}
|
||||
onOpenChange={setIsAPIListDialogOpen}
|
||||
>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">API 密钥</h1>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Dialog open={isProviderDialogOpen} onOpenChange={setIsProviderDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
API 列表
|
||||
支持模型
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>可用 API 端点</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看可用 API 端点及使用方式。
|
||||
</DialogDescription>
|
||||
<DialogTitle>支持模型</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-6">
|
||||
<div className="border rounded-lg p-6 bg-slate-50">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
知识库查询
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
请求方法
|
||||
</h4>
|
||||
<code className="block p-3 bg-white border rounded-md text-sm font-mono text-blue-600">
|
||||
GET
|
||||
</code>
|
||||
<div className="space-y-4">
|
||||
{options.providers.map((provider) => (
|
||||
<div key={provider.provider} className="rounded-md border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="font-medium">{provider.label}</div>
|
||||
<Badge variant={provider.requires_api_key ? "default" : "secondary"}>
|
||||
{provider.requires_api_key ? "需要密钥" : "本地服务"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
接口路径
|
||||
</h4>
|
||||
<code className="block p-3 bg-white border rounded-md text-sm font-mono">
|
||||
/openapi/knowledge/{"{id}"}/query
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
查询参数
|
||||
</h4>
|
||||
<div className="bg-white border rounded-md p-3 space-y-2">
|
||||
<div className="grid grid-cols-3 text-sm">
|
||||
<div className="font-mono text-blue-600">query</div>
|
||||
<div className="col-span-2">
|
||||
查询语句
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 text-sm">
|
||||
<div className="font-mono text-blue-600">top_k</div>
|
||||
<div className="col-span-2">
|
||||
返回结果数量(可选,默认 3)
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-slate-500">语言模型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{provider.chat_models.map((model) => (
|
||||
<Badge key={model} variant="outline">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
||||
请求头
|
||||
</h4>
|
||||
<div className="bg-white border rounded-md p-3 grid grid-cols-3 text-sm">
|
||||
<div className="font-mono text-blue-600">
|
||||
X-API-Key
|
||||
<div>
|
||||
<div className="mb-1 text-slate-500">Embedding 模型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{provider.embedding_models.map((model) => (
|
||||
<Badge key={model} variant="outline">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2">你的 API 密钥</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<Dialog open={isEditorOpen} onOpenChange={setIsEditorOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button onClick={resetFormForCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
创建 API 密钥
|
||||
创建配置
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新 API 密钥</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建新的 API 密钥,用于程序化访问接口。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{editingConfig ? "编辑模型配置" : "创建模型配置"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Label htmlFor="name">配置名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="请输入 API 密钥名称"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
placeholder="例如:我的 DashScope"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>服务商</Label>
|
||||
<select
|
||||
value={form.provider}
|
||||
onChange={(event) => updateProvider(event.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{options.providers.map((provider) => (
|
||||
<option key={provider.provider} value={provider.provider}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="api-key">API 密钥</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
onChange={(event) => setForm({ ...form, api_key: event.target.value })}
|
||||
placeholder={editingConfig ? "留空则不修改" : "请输入 API 密钥"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="api-base">API Base</Label>
|
||||
<Input
|
||||
id="api-base"
|
||||
value={form.api_base}
|
||||
onChange={(event) => setForm({ ...form, api_base: event.target.value })}
|
||||
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat-model">语言模型</Label>
|
||||
<Input
|
||||
id="chat-model"
|
||||
list="chat-model-options"
|
||||
value={form.chat_model}
|
||||
onChange={(event) => setForm({ ...form, chat_model: event.target.value })}
|
||||
/>
|
||||
<datalist id="chat-model-options">
|
||||
{(selectedProvider?.chat_models || []).map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="embedding-model">Embedding 模型</Label>
|
||||
<Input
|
||||
id="embedding-model"
|
||||
list="embedding-model-options"
|
||||
value={form.embedding_model}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, embedding_model: event.target.value })
|
||||
}
|
||||
/>
|
||||
<datalist id="embedding-model-options">
|
||||
{(selectedProvider?.embedding_models || []).map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label htmlFor="active">启用为默认配置</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
checked={form.is_active}
|
||||
onCheckedChange={(value) => setForm({ ...form, is_active: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={createAPIKey}
|
||||
disabled={isCreating || !newKeyName.trim()}
|
||||
>
|
||||
{isCreating ? "创建中..." : "创建"}
|
||||
<Button onClick={saveConfig} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -310,60 +498,71 @@ export default function APIKeysPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>服务商</TableHead>
|
||||
<TableHead>语言模型</TableHead>
|
||||
<TableHead>Embedding 模型</TableHead>
|
||||
<TableHead>API 密钥</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>最近使用</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.id}>
|
||||
<TableCell>{apiKey.name}</TableCell>
|
||||
<TableCell className="flex items-center gap-2">
|
||||
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">
|
||||
{apiKey.key}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyAPIKey(apiKey.id, apiKey.key)}
|
||||
>
|
||||
{copiedId === apiKey.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={apiKey.is_active}
|
||||
onCheckedChange={() =>
|
||||
toggleAPIKeyStatus(apiKey.id, apiKey.is_active)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(apiKey.created_at).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{apiKey.last_used_at
|
||||
? new Date(apiKey.last_used_at).toLocaleDateString("zh-CN")
|
||||
: "从未"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteAPIKey(apiKey.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-slate-500">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : configs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-slate-500">
|
||||
暂无模型 API 配置
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
configs.map((config) => (
|
||||
<TableRow key={config.id}>
|
||||
<TableCell className="font-medium">{config.name}</TableCell>
|
||||
<TableCell>{providerLabel(config.provider)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{config.chat_model}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{config.embedding_model}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 font-mono text-sm">
|
||||
<KeyRound className="h-4 w-4 text-slate-400" />
|
||||
{config.api_key_masked || "本地服务"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.is_active}
|
||||
onCheckedChange={() => toggleActive(config)}
|
||||
/>
|
||||
{config.is_active && (
|
||||
<Badge className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
默认
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(config.last_used_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openEditDialog(config)}>
|
||||
<Edit className="mr-1 h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteConfig(config.id)}>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
@@ -91,8 +92,23 @@ const wait = (ms: number) =>
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const SRS_JOB_POLL_INTERVAL_MS = 2000;
|
||||
const SRS_JOB_MAX_POLL_COUNT = 900;
|
||||
|
||||
const EXTRACTION_JOB_KEY = "doc_processing_extraction_job_id";
|
||||
|
||||
const requirementTypeOptions: Array<{
|
||||
value: NonNullable<RequirementItem["requirementType"]>;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "functional", label: "功能需求" },
|
||||
{ value: "interface", label: "接口需求" },
|
||||
{ value: "performance", label: "性能需求" },
|
||||
{ value: "security", label: "安全需求" },
|
||||
{ value: "reliability", label: "可靠性需求" },
|
||||
{ value: "other", label: "其他需求" },
|
||||
];
|
||||
|
||||
const normalizeRequirementType = (value: unknown): RequirementItem["requirementType"] => {
|
||||
if (
|
||||
value === "functional" ||
|
||||
@@ -149,23 +165,34 @@ const normalizeRequirement = (item: RequirementItem, index: number): Requirement
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeExtractionResult = (
|
||||
extraction: RequirementExtractionResult
|
||||
const normalizeRequirementList = (requirements: RequirementItem[]) =>
|
||||
requirements.map((item, index) => ({
|
||||
...normalizeRequirement(item, index),
|
||||
sortOrder: index,
|
||||
}));
|
||||
|
||||
const buildExtractionWithRequirements = (
|
||||
extraction: RequirementExtractionResult,
|
||||
requirements: RequirementItem[]
|
||||
): RequirementExtractionResult => {
|
||||
const requirements = extraction.requirements.map((item, index) =>
|
||||
normalizeRequirement(item, index)
|
||||
);
|
||||
const normalized = normalizeRequirementList(requirements);
|
||||
return {
|
||||
...extraction,
|
||||
requirements,
|
||||
requirements: normalized,
|
||||
rawOutput: rebuildRawOutput(
|
||||
extraction.rawOutput as Record<string, unknown> | undefined,
|
||||
requirements,
|
||||
normalized,
|
||||
extraction.documentName
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeExtractionResult = (
|
||||
extraction: RequirementExtractionResult
|
||||
): RequirementExtractionResult => {
|
||||
return buildExtractionWithRequirements(extraction, extraction.requirements);
|
||||
};
|
||||
|
||||
const collectSectionKeys = (nodes: SectionTreeNode[]): string[] => {
|
||||
const keys: string[] = [];
|
||||
const walk = (node: SectionTreeNode) => {
|
||||
@@ -176,6 +203,38 @@ const collectSectionKeys = (nodes: SectionTreeNode[]): string[] => {
|
||||
return keys;
|
||||
};
|
||||
|
||||
const countSectionRequirements = (node: SectionTreeNode): number =>
|
||||
node.requirements.length +
|
||||
node.children.reduce((total, child) => total + countSectionRequirements(child), 0);
|
||||
|
||||
const createRequirementId = (requirements: RequirementItem[]) => {
|
||||
const usedIds = new Set(requirements.map((item) => item.id));
|
||||
const maxNumber = requirements.reduce((max, item) => {
|
||||
const match = item.id.match(/(\d+)$/);
|
||||
return match ? Math.max(max, Number(match[1])) : max;
|
||||
}, 0);
|
||||
|
||||
let nextNumber = maxNumber + 1;
|
||||
let candidate = `REQ-${String(nextNumber).padStart(3, "0")}`;
|
||||
while (usedIds.has(candidate)) {
|
||||
nextNumber += 1;
|
||||
candidate = `REQ-${String(nextNumber).padStart(3, "0")}`;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const findDuplicateRequirementId = (requirements: RequirementItem[]) => {
|
||||
const seen = new Set<string>();
|
||||
for (const item of requirements) {
|
||||
const id = item.id.trim();
|
||||
if (seen.has(id)) {
|
||||
return id;
|
||||
}
|
||||
seen.add(id);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function RequirementExtractionPage() {
|
||||
const [documentFile, setDocumentFile] = useState<File | null>(null);
|
||||
const [extraction, setExtraction] = useState<RequirementExtractionResult | null>(
|
||||
@@ -307,15 +366,7 @@ export default function RequirementExtractionPage() {
|
||||
sortOrder: index,
|
||||
};
|
||||
|
||||
const next: RequirementExtractionResult = {
|
||||
...prev,
|
||||
requirements,
|
||||
rawOutput: rebuildRawOutput(
|
||||
prev.rawOutput as Record<string, unknown> | undefined,
|
||||
requirements,
|
||||
prev.documentName
|
||||
),
|
||||
};
|
||||
const next = buildExtractionWithRequirements(prev, requirements);
|
||||
|
||||
saveExtractionDraft(next);
|
||||
|
||||
@@ -331,6 +382,73 @@ export default function RequirementExtractionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRequirement = () => {
|
||||
if (!extraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = selectedRequirement ?? extraction.requirements[extraction.requirements.length - 1];
|
||||
const newRequirement = normalizeRequirement(
|
||||
{
|
||||
id: createRequirementId(extraction.requirements),
|
||||
description: "",
|
||||
priority: "中",
|
||||
acceptanceCriteria: ["待补充验收标准"],
|
||||
sourceField: base?.sourceField || "手动新增",
|
||||
sectionUid: base?.sectionUid,
|
||||
sectionNumber: base?.sectionNumber || "",
|
||||
sectionTitle: base?.sectionTitle || "未归类章节",
|
||||
requirementType: base?.requirementType || "functional",
|
||||
interfaceName: "",
|
||||
interfaceType: "",
|
||||
dataSource: "",
|
||||
dataDestination: "",
|
||||
sortOrder: extraction.requirements.length,
|
||||
},
|
||||
extraction.requirements.length
|
||||
);
|
||||
const next = buildExtractionWithRequirements(extraction, [
|
||||
...extraction.requirements,
|
||||
newRequirement,
|
||||
]);
|
||||
|
||||
setExtractionAndPersist(next);
|
||||
setSelectedRequirementId(newRequirement.id);
|
||||
toast({
|
||||
title: "已新增需求",
|
||||
description: `已创建 ${newRequirement.id},可在右侧继续编辑。`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteRequirement = () => {
|
||||
if (!extraction || !selectedRequirement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`确认删除需求 ${selectedRequirement.id}?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = extraction.requirements.findIndex(
|
||||
(item) => item.id === selectedRequirement.id
|
||||
);
|
||||
if (currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const remaining = extraction.requirements.filter((_, index) => index !== currentIndex);
|
||||
const next = buildExtractionWithRequirements(extraction, remaining);
|
||||
const nextSelected =
|
||||
remaining[currentIndex]?.id || remaining[currentIndex - 1]?.id || remaining[0]?.id || null;
|
||||
|
||||
setExtractionAndPersist(next);
|
||||
setSelectedRequirementId(nextSelected);
|
||||
toast({
|
||||
title: "已删除需求",
|
||||
description: `${selectedRequirement.id} 已从当前需求文件中移除。`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!documentFile) {
|
||||
toast({
|
||||
@@ -348,8 +466,7 @@ export default function RequirementExtractionPage() {
|
||||
window.localStorage.setItem(EXTRACTION_JOB_KEY, String(job.job_id));
|
||||
|
||||
let finalResult: RequirementExtractionResult | null = null;
|
||||
const maxPollCount = 120;
|
||||
for (let i = 0; i < maxPollCount; i += 1) {
|
||||
for (let i = 0; i < SRS_JOB_MAX_POLL_COUNT; i += 1) {
|
||||
const status = await getSrsJobStatus(job.job_id);
|
||||
if (status.status === "completed") {
|
||||
const rawResult = await getSrsJobResult(job.job_id);
|
||||
@@ -359,11 +476,16 @@ export default function RequirementExtractionPage() {
|
||||
if (status.status === "failed") {
|
||||
throw new Error(status.error_message || "需求提取任务失败");
|
||||
}
|
||||
await wait(2000);
|
||||
await wait(SRS_JOB_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error("提取任务超时,请稍后重试");
|
||||
await loadHistory();
|
||||
toast({
|
||||
title: "提取仍在后台处理中",
|
||||
description: "当前文档较大,任务尚未完成。稍后可在历史记录中加载结果。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setExtractionAndPersist(finalResult);
|
||||
@@ -424,6 +546,25 @@ export default function RequirementExtractionPage() {
|
||||
}
|
||||
|
||||
const normalized = normalizeExtractionResult(extraction);
|
||||
const emptyId = normalized.requirements.some((item) => !item.id.trim());
|
||||
if (emptyId) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "需求编号不能为空,请补充后再保存。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateId = findDuplicateRequirementId(normalized.requirements);
|
||||
if (duplicateId) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: `需求编号 ${duplicateId} 重复,请调整后再保存。`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const persist = async () => {
|
||||
if (!activeJobId) {
|
||||
@@ -535,6 +676,7 @@ export default function RequirementExtractionPage() {
|
||||
|
||||
const renderSectionNode = (node: SectionTreeNode) => {
|
||||
const expanded = expandedSectionKeys.includes(node.key);
|
||||
const requirementCount = countSectionRequirements(node);
|
||||
const sectionLabel =
|
||||
`${node.sectionNumber ? `${node.sectionNumber} ` : ""}${node.sectionTitle}`.trim() ||
|
||||
"未归类章节";
|
||||
@@ -549,7 +691,7 @@ export default function RequirementExtractionPage() {
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{sectionLabel}</p>
|
||||
<Badge variant="outline">{node.requirements.length}</Badge>
|
||||
<Badge variant="outline">{requirementCount}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -751,8 +893,16 @@ export default function RequirementExtractionPage() {
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_1fr]">
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">需求项树</CardTitle>
|
||||
<CardDescription>按章节层级折叠展开,快速定位需求所属章节</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">需求项树</CardTitle>
|
||||
<CardDescription>按章节层级折叠展开,快速定位需求所属章节</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddRequirement} disabled={!extraction}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
新增需求
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!extraction && (
|
||||
@@ -775,8 +925,21 @@ export default function RequirementExtractionPage() {
|
||||
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">需求详情编辑</CardTitle>
|
||||
<CardDescription>支持接口需求开关与字段级编辑,保存后可覆盖历史记录</CardDescription>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">需求详情编辑</CardTitle>
|
||||
<CardDescription>支持接口需求开关与字段级编辑,保存后可覆盖历史记录</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteRequirement}
|
||||
disabled={!selectedRequirement}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
删除需求
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedRequirement && (
|
||||
@@ -801,6 +964,50 @@ export default function RequirementExtractionPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-section-number">章节编号</Label>
|
||||
<Input
|
||||
id="req-section-number"
|
||||
value={selectedRequirement.sectionNumber || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
sectionNumber: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-section-title">章节标题</Label>
|
||||
<Input
|
||||
id="req-section-title"
|
||||
value={selectedRequirement.sectionTitle || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
sectionTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-source-field">来源字段</Label>
|
||||
<Input
|
||||
id="req-source-field"
|
||||
value={selectedRequirement.sourceField || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
sourceField: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-priority">优先级</Label>
|
||||
<select
|
||||
@@ -820,6 +1027,35 @@ export default function RequirementExtractionPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-type">需求类型</Label>
|
||||
<select
|
||||
id="req-type"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedRequirement.requirementType || "functional"}
|
||||
onChange={(event) => {
|
||||
const nextType = event.target.value as NonNullable<
|
||||
RequirementItem["requirementType"]
|
||||
>;
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
requirementType: nextType,
|
||||
interfaceName: nextType === "interface" ? item.interfaceName || "" : "",
|
||||
interfaceType: nextType === "interface" ? item.interfaceType || "" : "",
|
||||
dataSource: nextType === "interface" ? item.dataSource || "" : "",
|
||||
dataDestination:
|
||||
nextType === "interface" ? item.dataDestination || "" : "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{requirementTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
|
||||
@@ -1,73 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Braces, FolderCog, Wrench } from "lucide-react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Braces, Database, Loader2, MessageSquare, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
CodeKnowledgeBase,
|
||||
CodeQuestionResponse,
|
||||
askCodeKnowledgeBase,
|
||||
deleteCodeKnowledgeBase,
|
||||
listCodeKnowledgeBases,
|
||||
uploadCodeKnowledgeBase,
|
||||
} from "@/lib/consistency-api";
|
||||
|
||||
const plannedFeatures = [
|
||||
{
|
||||
title: "仓库接入",
|
||||
description: "后续接入代码仓库、分支与目录范围管理能力。",
|
||||
icon: FolderCog,
|
||||
},
|
||||
{
|
||||
title: "代码索引",
|
||||
description: "后续支持代码解析、结构化索引与语义检索。",
|
||||
icon: Braces,
|
||||
},
|
||||
{
|
||||
title: "能力扩展",
|
||||
description: "后续补充问答、关联分析与工程化工具链集成。",
|
||||
icon: Wrench,
|
||||
},
|
||||
];
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
export default function CodeKnowledgeBasePage() {
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<CodeKnowledgeBase[]>([]);
|
||||
const [selectedKbId, setSelectedKbId] = useState<number | "">("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [useSemantic, setUseSemantic] = useState(true);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [isAsking, setIsAsking] = useState(false);
|
||||
const [deletingKbId, setDeletingKbId] = useState<number | null>(null);
|
||||
const [answer, setAnswer] = useState<CodeQuestionResponse | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const selectedKb = useMemo(
|
||||
() => knowledgeBases.find((item) => item.id === selectedKbId) ?? null,
|
||||
[knowledgeBases, selectedKbId]
|
||||
);
|
||||
|
||||
const loadKnowledgeBases = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const items = await listCodeKnowledgeBases();
|
||||
setKnowledgeBases(items);
|
||||
setSelectedKbId((current) => {
|
||||
if (current && items.some((item) => item.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return items.find((item) => item.status === "active")?.id || items[0]?.id || "";
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "加载失败";
|
||||
toast({ title: "加载失败", description: message, variant: "destructive" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadKnowledgeBases();
|
||||
}, [loadKnowledgeBases]);
|
||||
|
||||
const handleFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextFiles = Array.from(event.target.files || []);
|
||||
setFiles(nextFiles);
|
||||
if (!name && nextFiles[0]) {
|
||||
setName(nextFiles[0].name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
};
|
||||
|
||||
const pollCodeKb = async (id: number) => {
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
const items = await listCodeKnowledgeBases();
|
||||
setKnowledgeBases(items);
|
||||
const hit = items.find((item) => item.id === id);
|
||||
if (!hit) {
|
||||
throw new Error("代码知识库记录不存在");
|
||||
}
|
||||
if (hit.status === "active") {
|
||||
setSelectedKbId(hit.id);
|
||||
return;
|
||||
}
|
||||
if (hit.status === "failed") {
|
||||
const error = String(hit.metadata_summary?.error_message || "构建失败");
|
||||
throw new Error(error);
|
||||
}
|
||||
await wait(2000);
|
||||
}
|
||||
throw new Error("代码知识库仍在后台构建,请稍后刷新查看。");
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!name.trim() || files.length === 0) {
|
||||
toast({
|
||||
title: "缺少输入",
|
||||
description: "请填写名称并选择代码文件或 zip 包。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const created = await uploadCodeKnowledgeBase(name.trim(), files, useSemantic);
|
||||
toast({ title: "构建已启动", description: `代码知识库任务 #${created.id}` });
|
||||
await pollCodeKb(created.id);
|
||||
toast({ title: "构建完成", description: "现在可以针对代码内容提问。" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "上传或构建失败";
|
||||
toast({
|
||||
title: message.includes("后台构建") ? "构建仍在进行" : "构建失败",
|
||||
description: message,
|
||||
variant: message.includes("后台构建") ? "default" : "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectKb = (id: number) => {
|
||||
setSelectedKbId(id);
|
||||
setAnswer(null);
|
||||
};
|
||||
|
||||
const handleDeleteKb = async (item: CodeKnowledgeBase) => {
|
||||
const confirmed = window.confirm(`确认删除代码知识库 ${item.name}?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingKbId(item.id);
|
||||
try {
|
||||
await deleteCodeKnowledgeBase(item.id);
|
||||
const items = await listCodeKnowledgeBases();
|
||||
setKnowledgeBases(items);
|
||||
setSelectedKbId((current) => {
|
||||
if (current !== item.id && current && items.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return items.find((entry) => entry.status === "active")?.id || items[0]?.id || "";
|
||||
});
|
||||
if (selectedKbId === item.id) {
|
||||
setAnswer(null);
|
||||
}
|
||||
toast({ title: "删除成功", description: "代码知识库已删除。" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "删除失败";
|
||||
toast({ title: "删除失败", description: message, variant: "destructive" });
|
||||
} finally {
|
||||
setDeletingKbId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAsk = async () => {
|
||||
if (!selectedKb || !question.trim()) {
|
||||
toast({
|
||||
title: "缺少输入",
|
||||
description: "请选择已完成的代码知识库并输入问题。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedKb.status !== "active") {
|
||||
toast({
|
||||
title: "代码知识库未就绪",
|
||||
description: "请等待构建完成后再提问。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsAsking(true);
|
||||
try {
|
||||
const result = await askCodeKnowledgeBase(selectedKb.id, question.trim(), 6, true);
|
||||
setAnswer(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "问答失败";
|
||||
toast({ title: "问答失败", description: message, variant: "destructive" });
|
||||
} finally {
|
||||
setIsAsking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">代码知识库</h2>
|
||||
<p className="max-w-3xl text-muted-foreground">
|
||||
当前页面先提供前端结构,后续再逐步补充代码接入、索引、检索和问答能力。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{plannedFeatures.map((item) => (
|
||||
<section
|
||||
key={item.title}
|
||||
className="space-y-4 rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<item.icon className="h-5 w-5" />
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">代码知识库</CardTitle>
|
||||
<CardDescription>
|
||||
上传 C/C++ 源码文件或 zip 工程包,后台调用 RAG-TEST-TOOLS 构建函数级代码知识库。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>知识库名称</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
<Label>语义增强</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={useSemantic ? "true" : "false"}
|
||||
onChange={(event) => setUseSemantic(event.target.value === "true")}
|
||||
>
|
||||
<option value="true">生成函数摘要和 embedding</option>
|
||||
<option value="false">仅解析结构,跳过模型调用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>代码文件</Label>
|
||||
<Input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,.c,.cpp,.h,.hpp,.tcc"
|
||||
onChange={handleFiles}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {files.length} 个文件;上传 zip 时会自动解压后构建。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
上传并构建
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void loadKnowledgeBases()} disabled={isLoading}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-dashed bg-card p-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">当前状态</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
已完成页面入口、导航层级和基础占位内容,暂未接入后端接口和业务流程。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/dashboard/knowledge/document"
|
||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">代码知识库列表</CardTitle>
|
||||
<CardDescription>选择一个已完成构建的知识库用于问答或一致性比对。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>函数数</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{knowledgeBases.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
暂无代码知识库
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{knowledgeBases.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<p className="font-medium">{item.name}</p>
|
||||
{item.status === "failed" && Boolean(item.metadata_summary?.error_message) && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{String(item.metadata_summary?.error_message || "")}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === "failed"
|
||||
? "destructive"
|
||||
: item.status === "active"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{String(item.metadata_summary?.function_count ?? "-")}</TableCell>
|
||||
<TableCell>{formatDateTime(item.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedKbId === item.id ? "secondary" : "outline"}
|
||||
onClick={() => handleSelectKb(item.id)}
|
||||
disabled={item.status !== "active"}
|
||||
>
|
||||
<Database className="mr-1 h-3.5 w-3.5" />
|
||||
{selectedKbId === item.id ? "已选择" : "选择"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleDeleteKb(item)}
|
||||
disabled={deletingKbId === item.id}
|
||||
>
|
||||
{deletingKbId === item.id ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">代码问答</CardTitle>
|
||||
<CardDescription>问题会先检索函数级证据,再基于证据生成回答。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>当前知识库</Label>
|
||||
<p className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{selectedKb ? `${selectedKb.name} · ${selectedKb.status}` : "未选择"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>问题</Label>
|
||||
<textarea
|
||||
className="min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={question}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder="例如:姿态控制初始化逻辑在哪里实现?调用链是什么?"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAsk}
|
||||
disabled={isAsking || !selectedKb || selectedKb.status !== "active" || !question.trim()}
|
||||
>
|
||||
返回文档知识库
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
{isAsking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
提问
|
||||
</Button>
|
||||
|
||||
{answer && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Braces className="h-4 w-4" />
|
||||
回答
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{answer.answer}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">函数证据</h3>
|
||||
{answer.evidence.map((item, index) => (
|
||||
<div key={`${item.node_id}-${index}`} className="rounded-md border p-3 text-sm">
|
||||
<p className="font-medium">
|
||||
{item.name} · similarity {Number(item.similarity || 0).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.file}:{item.start_line}-{item.end_line}
|
||||
</p>
|
||||
<p className="mt-2 line-clamp-3 text-xs text-muted-foreground">
|
||||
{item.summary || item.evidence_summary || "-"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user