增加代码知识库;修复文档处理内容;增加API设置

This commit is contained in:
2026-05-16 20:20:10 +08:00
parent 69b49d28b2
commit 7aa3ce3294
119 changed files with 182273 additions and 793 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);