init. project
This commit is contained in:
373
rag-web-ui/frontend/src/app/dashboard/api-keys/page.tsx
Normal file
373
rag-web-ui/frontend/src/app/dashboard/api-keys/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Copy, Check, List } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export interface APIKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
is_active: boolean;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface APIKeyCreate {
|
||||
name: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface APIKeyUpdate {
|
||||
name?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export default function APIKeysPage() {
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||||
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 { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// 获取 API Keys 列表
|
||||
const fetchAPIKeys = async () => {
|
||||
try {
|
||||
const data = await api.get("/api/api-keys");
|
||||
setApiKeys(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取 API 密钥失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAPIKeys();
|
||||
}, []);
|
||||
|
||||
// 创建新的 API Key
|
||||
const createAPIKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入 API 密钥名称",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const data = await api.post("/api/api-keys", {
|
||||
name: newKeyName,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
setApiKeys([...apiKeys, data]);
|
||||
setNewKeyName("");
|
||||
setIsDialogOpen(false);
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥创建成功",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建 API 密钥失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除 API Key
|
||||
const deleteAPIKey = async (id: number) => {
|
||||
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 密钥删除成功",
|
||||
});
|
||||
} 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
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥状态更新成功",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "更新 API 密钥失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 复制 API Key
|
||||
const copyAPIKey = async (id: number, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
}, 3000);
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "API 密钥已复制到剪贴板",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "复制 API 密钥失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
API 列表
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>可用 API 端点</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看可用 API 端点及使用方式。
|
||||
</DialogDescription>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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="col-span-2">你的 API 密钥</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
创建 API 密钥
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新 API 密钥</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建新的 API 密钥,用于程序化访问接口。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="请输入 API 密钥名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={createAPIKey}
|
||||
disabled={isCreating || !newKeyName.trim()}
|
||||
>
|
||||
{isCreating ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
421
rag-web-ui/frontend/src/app/dashboard/chat/[id]/page.tsx
Normal file
421
rag-web-ui/frontend/src/app/dashboard/chat/[id]/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useChat } from "ai/react";
|
||||
import { Send, User, Bot } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Answer } from "@/components/chat/answer";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "assistant" | "user" | "system" | "data";
|
||||
content: string;
|
||||
citations?: Citation[];
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
role: "assistant" | "user";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
id: number;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
interface Citation {
|
||||
id: number;
|
||||
text: string;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ContextPayload {
|
||||
context?: Array<{
|
||||
page_content: string;
|
||||
metadata: Record<string, any>;
|
||||
}>;
|
||||
route?: Record<string, any>;
|
||||
intent?: string;
|
||||
selected_chain?: string;
|
||||
retrieval_preview?: Array<Record<string, any>>;
|
||||
}
|
||||
|
||||
// Extend the default useChat message type
|
||||
declare module "ai/react" {
|
||||
interface Message {
|
||||
citations?: Citation[];
|
||||
}
|
||||
}
|
||||
|
||||
const LLM_RESPONSE_SEPARATOR = "__LLM_RESPONSE__";
|
||||
const LEADING_BASE64_RE = /^[A-Za-z0-9+/=]+/;
|
||||
const CODE_FENCE_RE = /(```[\s\S]*?```)/g;
|
||||
const ANSWER_KEYWORDS = [
|
||||
"测试充分性要求",
|
||||
"人机交互界面测试",
|
||||
"外部接口测试",
|
||||
"测试用例",
|
||||
"测试项",
|
||||
"预期成果",
|
||||
"正常测试",
|
||||
"异常测试",
|
||||
"总体结论",
|
||||
"核心结论",
|
||||
"关键结论",
|
||||
"关键点",
|
||||
"关键词",
|
||||
"注意事项",
|
||||
"步骤",
|
||||
"建议",
|
||||
"原因",
|
||||
"风险",
|
||||
"结论",
|
||||
];
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const KEYWORD_PATTERN = new RegExp(
|
||||
ANSWER_KEYWORDS.map((keyword) => escapeRegExp(keyword))
|
||||
.sort((left, right) => right.length - left.length)
|
||||
.join("|"),
|
||||
"g"
|
||||
);
|
||||
|
||||
const normalizeCitationSyntax = (text: string, maxCitationId: number): string => {
|
||||
const normalized = text
|
||||
.replace(/\[\[([cC])itation/g, "[citation")
|
||||
.replace(/[cC]itation:(\d+)]]/g, "citation:$1]")
|
||||
.replace(/\[\[([cC]itation:\d+)]](?!])/g, "[$1]")
|
||||
.replace(/\[[cC]itation:(\d+)]/g, "[citation]($1)");
|
||||
|
||||
return normalized.replace(/\[(\d+)]/g, (raw, idText) => {
|
||||
const id = Number(idText);
|
||||
if (!Number.isFinite(id) || id <= 0 || id > maxCitationId) {
|
||||
return raw;
|
||||
}
|
||||
return `[citation](${id})`;
|
||||
});
|
||||
};
|
||||
|
||||
const extractCitationIds = (text: string, maxCitationId: number): number[] => {
|
||||
const ids: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
const pattern = /\[citation]\((\d+)\)/gi;
|
||||
|
||||
let matched: RegExpExecArray | null = pattern.exec(text);
|
||||
while (matched) {
|
||||
const id = Number(matched[1]);
|
||||
if (id > 0 && id <= maxCitationId && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
matched = pattern.exec(text);
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
const stripCitationMarkers = (text: string): string =>
|
||||
text
|
||||
.replace(/\s*\[citation]\((\d+)\)\s*/gi, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
const highlightAnswerKeywords = (text: string): string => {
|
||||
if (!text.trim()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text
|
||||
.split(CODE_FENCE_RE)
|
||||
.map((segment) => {
|
||||
if (segment.startsWith("```")) {
|
||||
return segment;
|
||||
}
|
||||
|
||||
const protectedBlocks: string[] = [];
|
||||
const protectedSegment = segment.replace(/\*\*[^*]+\*\*/g, (matched) => {
|
||||
const token = `__BOLD_BLOCK_${protectedBlocks.length}__`;
|
||||
protectedBlocks.push(matched);
|
||||
return token;
|
||||
});
|
||||
|
||||
const highlighted = protectedSegment.replace(KEYWORD_PATTERN, (matched) => `**${matched}**`);
|
||||
|
||||
return highlighted.replace(/__BOLD_BLOCK_(\d+)__/g, (_raw, indexText) => {
|
||||
const index = Number(indexText);
|
||||
return protectedBlocks[index] ?? "";
|
||||
});
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const formatAssistantMarkdown = (text: string, citations: Citation[]): string => {
|
||||
const maxCitationId = citations.length;
|
||||
const normalized = normalizeCitationSyntax(text, maxCitationId);
|
||||
const citationIds = extractCitationIds(normalized, maxCitationId);
|
||||
const fallbackCitationIds = citationIds.length
|
||||
? citationIds
|
||||
: citations.map((citation) => citation.id);
|
||||
|
||||
const body = stripCitationMarkers(normalized);
|
||||
const highlightedBody = highlightAnswerKeywords(body);
|
||||
|
||||
if (!fallbackCitationIds.length) {
|
||||
return highlightedBody;
|
||||
}
|
||||
|
||||
const refs = fallbackCitationIds.map((id) => `[citation](${id})`).join(" ");
|
||||
return `${highlightedBody}\n\n**引用**:${refs}`;
|
||||
};
|
||||
|
||||
const decodeBase64Json = (encoded: string): ContextPayload | null => {
|
||||
try {
|
||||
const binary = atob(encoded);
|
||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||
const decoded = new TextDecoder("utf-8").decode(bytes);
|
||||
return JSON.parse(decoded) as ContextPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isLikelyContextPayload = (payload: ContextPayload | null): boolean => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
payload.context ||
|
||||
payload.route ||
|
||||
payload.intent ||
|
||||
payload.selected_chain ||
|
||||
payload.retrieval_preview
|
||||
);
|
||||
};
|
||||
|
||||
const parseAssistantPayload = (rawContent: string): {
|
||||
content: string;
|
||||
citations: Citation[];
|
||||
} => {
|
||||
let contextPayload: ContextPayload | null = null;
|
||||
let responseText = rawContent;
|
||||
|
||||
const separatorIndex = rawContent.indexOf(LLM_RESPONSE_SEPARATOR);
|
||||
|
||||
if (separatorIndex >= 0) {
|
||||
const base64Part = rawContent.slice(0, separatorIndex).trim();
|
||||
contextPayload = decodeBase64Json(base64Part);
|
||||
responseText = rawContent.slice(separatorIndex + LLM_RESPONSE_SEPARATOR.length);
|
||||
} else {
|
||||
const base64Match = rawContent.match(LEADING_BASE64_RE);
|
||||
if (base64Match && base64Match[0].length > 80) {
|
||||
const candidate = base64Match[0];
|
||||
const candidatePayload = decodeBase64Json(candidate);
|
||||
if (isLikelyContextPayload(candidatePayload)) {
|
||||
contextPayload = candidatePayload;
|
||||
responseText = rawContent.slice(candidate.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
responseText = responseText.replace(/^_+LLM_RESPONSE_+/, "").trimStart();
|
||||
|
||||
const citations: Citation[] =
|
||||
contextPayload?.context?.map((citation, index) => ({
|
||||
id: index + 1,
|
||||
text: citation.page_content,
|
||||
metadata: citation.metadata,
|
||||
})) || [];
|
||||
|
||||
return { content: responseText, citations };
|
||||
};
|
||||
|
||||
export default function ChatPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
const {
|
||||
messages,
|
||||
data,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
setMessages,
|
||||
} = useChat({
|
||||
api: `/api/chat/${params.id}/messages`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem("token")
|
||||
: ""
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoad) {
|
||||
fetchChat();
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
}, [isInitialLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialLoad) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, isInitialLoad]);
|
||||
|
||||
const fetchChat = async () => {
|
||||
try {
|
||||
const data: Chat = await api.get(`/api/chat/${params.id}`);
|
||||
const formattedMessages = data.messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || !msg.content)
|
||||
return {
|
||||
id: msg.id.toString(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = parseAssistantPayload(msg.content);
|
||||
|
||||
return {
|
||||
id: msg.id.toString(),
|
||||
role: msg.role,
|
||||
content: parsed.content,
|
||||
citations: parsed.citations,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to process message:", e);
|
||||
return {
|
||||
id: msg.id.toString(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
};
|
||||
}
|
||||
});
|
||||
setMessages(formattedMessages);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch chat:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
router.push("/dashboard/chat");
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const processedMessages = useMemo(() => {
|
||||
return messages.map((message) => {
|
||||
if (message.role !== "assistant" || !message.content) return message;
|
||||
|
||||
try {
|
||||
const parsed = parseAssistantPayload(message.content);
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: formatAssistantMarkdown(parsed.content, parsed.citations),
|
||||
citations: parsed.citations,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to process message:", e);
|
||||
return message;
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col h-[calc(100vh-5rem)] relative">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-[80px]">
|
||||
{processedMessages.map((message) =>
|
||||
message.role === "assistant" ? (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex justify-start items-start space-x-2"
|
||||
>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<img
|
||||
src="/logo.png"
|
||||
className="h-8 w-8 rounded-full"
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg px-4 py-2 text-accent-foreground">
|
||||
<Answer
|
||||
key={message.id}
|
||||
markdown={message.content}
|
||||
citations={message.citations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex justify-end items-start space-x-2"
|
||||
>
|
||||
<div className="max-w-[80%] rounded-lg px-4 py-2 bg-primary text-primary-foreground">
|
||||
{message.content}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="flex justify-start">
|
||||
{isLoading &&
|
||||
processedMessages[processedMessages.length - 1]?.role !=
|
||||
"assistant" && (
|
||||
<div className="max-w-[80%] rounded-lg px-4 py-2 text-accent-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-bounce" />
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-bounce [animation-delay:0.2s]" />
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-bounce [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="border-t p-4 flex items-center space-x-4 bg-background absolute bottom-0 left-0 right-0"
|
||||
>
|
||||
<input
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入消息..."
|
||||
className="flex-1 min-w-0 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
204
rag-web-ui/frontend/src/app/dashboard/chat/new/page.tsx
Normal file
204
rag-web-ui/frontend/src/app/dashboard/chat/new/page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const router = useRouter();
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [selectedKB, setSelectedKB] = useState<number | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
const fetchKnowledgeBases = async () => {
|
||||
try {
|
||||
const data = await api.get("/api/knowledge-base");
|
||||
setKnowledgeBases(data);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge bases:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!selectedKB) {
|
||||
setError("请选择一个知识库");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const data = await api.post("/api/chat", {
|
||||
title,
|
||||
knowledge_base_ids: [selectedKB],
|
||||
});
|
||||
|
||||
router.push(`/dashboard/chat/${data.id}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to create chat:", error);
|
||||
if (error instanceof ApiError) {
|
||||
setError(error.message);
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
setError("创建对话失败");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectKnowledgeBase = (id: number) => {
|
||||
setSelectedKB(id);
|
||||
};
|
||||
|
||||
if (!isLoading && knowledgeBases.length === 0) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-2xl mx-auto text-center py-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-4">
|
||||
未找到知识库
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
开始对话前,请先创建至少一个知识库。
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/knowledge"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
创建知识库
|
||||
</Link>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">发起新对话</h2>
|
||||
<p className="text-muted-foreground">
|
||||
选择要对话的知识库
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
对话标题
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
type="text"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="请输入对话标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
知识库
|
||||
</label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
即将支持多选...
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{isLoading ? (
|
||||
<div className="col-span-2 flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
knowledgeBases.map((kb) => (
|
||||
<label
|
||||
key={kb.id}
|
||||
className={`group flex items-center space-x-3 rounded-lg border p-4 cursor-pointer transition-all duration-200 hover:shadow-md ${
|
||||
selectedKB === kb.id
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="knowledge-base"
|
||||
className="peer h-4 w-4 shrink-0 rounded-full border border-primary text-primary focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
checked={selectedKB === kb.id}
|
||||
onChange={() => selectKnowledgeBase(kb.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="font-medium group-hover:text-primary transition-colors">
|
||||
{kb.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{kb.description || "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !selectedKB}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
{isSubmitting ? "创建中..." : "开始对话"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
197
rag-web-ui/frontend/src/app/dashboard/chat/page.tsx
Normal file
197
rag-web-ui/frontend/src/app/dashboard/chat/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, MessageSquare, Trash2, Search } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
interface Chat {
|
||||
id: number;
|
||||
title: string;
|
||||
created_at: string;
|
||||
messages: Message[];
|
||||
knowledge_base_ids: number[];
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
content: string;
|
||||
is_bot: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const LLM_RESPONSE_SEPARATOR = "__LLM_RESPONSE__";
|
||||
|
||||
const cleanAssistantPreview = (content: string): string => {
|
||||
const separatorIndex = content.indexOf(LLM_RESPONSE_SEPARATOR);
|
||||
if (separatorIndex >= 0) {
|
||||
return content
|
||||
.slice(separatorIndex + LLM_RESPONSE_SEPARATOR.length)
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
const base64Match = content.match(/^[A-Za-z0-9+/=]+/);
|
||||
if (base64Match && base64Match[0].length > 80) {
|
||||
return content
|
||||
.slice(base64Match[0].length)
|
||||
.replace(/^_+LLM_RESPONSE_+/, "")
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default function ChatPage() {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats();
|
||||
}, []);
|
||||
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
const data = await api.get("/api/chat");
|
||||
setChats(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch chats:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定要删除这个对话吗?")) return;
|
||||
try {
|
||||
await api.delete(`/api/chat/${id}`);
|
||||
setChats((prev) => prev.filter((chat) => chat.id !== id));
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "对话删除成功",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete chat:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredChats = chats.filter((chat) =>
|
||||
chat.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card rounded-lg shadow-sm p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
我的对话
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
浏览并管理你的对话历史
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/chat/new"
|
||||
className="inline-flex items-center justify-center rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
发起新对话
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索对话..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-full border bg-background/50 focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredChats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className="group relative bg-card rounded-xl border shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden"
|
||||
>
|
||||
<Link href={`/dashboard/chat/${chat.id}`}>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<MessageSquare className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">
|
||||
{chat.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{chat.messages.length} 条消息 •{" "}
|
||||
{new Date(chat.created_at).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{chat.messages.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-4 line-clamp-2">
|
||||
{cleanAssistantPreview(
|
||||
chat.messages[chat.messages.length - 1].content
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete(chat.id);
|
||||
}}
|
||||
className="absolute top-4 right-4 p-2 rounded-full hover:bg-destructive/10 group/delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground group-hover/delete:text-destructive transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{chats.length === 0 && (
|
||||
<div className="text-center py-16 bg-card rounded-lg border">
|
||||
<MessageSquare className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
暂无对话
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
发起新对话,开始探索你的知识库
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/chat/new"
|
||||
className="mt-6 inline-flex items-center justify-center rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors duration-200"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
开始第一场对话
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileCode,
|
||||
FileText,
|
||||
Loader2,
|
||||
Play,
|
||||
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 { Progress } from "@/components/ui/progress";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
ConsistencyReport,
|
||||
downloadJson,
|
||||
loadConsistencyDraft,
|
||||
mockAnalyzeConsistency,
|
||||
saveConsistencyDraft,
|
||||
} from "@/lib/document-mock";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const severityVariant: Record<string, "default" | "secondary" | "destructive"> =
|
||||
{
|
||||
高: "destructive",
|
||||
中: "default",
|
||||
低: "secondary",
|
||||
};
|
||||
|
||||
export default function ConsistencyAnalysisPage() {
|
||||
const [requirementFile, setRequirementFile] = useState<File | null>(null);
|
||||
const [codeFile, setCodeFile] = useState<File | null>(null);
|
||||
const [report, setReport] = useState<ConsistencyReport | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadConsistencyDraft();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setReport(draft);
|
||||
}, []);
|
||||
|
||||
const requirementDropzone = useDropzone({
|
||||
onDrop: useCallback((acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setRequirementFile(file);
|
||||
}, []),
|
||||
maxFiles: 1,
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
"application/msword": [".doc"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
|
||||
".docx",
|
||||
],
|
||||
"application/json": [".json"],
|
||||
"text/plain": [".txt", ".md"],
|
||||
},
|
||||
});
|
||||
|
||||
const codeDropzone = useDropzone({
|
||||
onDrop: useCallback((acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setCodeFile(file);
|
||||
}, []),
|
||||
maxFiles: 1,
|
||||
accept: {
|
||||
"text/plain": [
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".tsx",
|
||||
".java",
|
||||
".go",
|
||||
".cs",
|
||||
".cpp",
|
||||
".c",
|
||||
".md",
|
||||
".json",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!requirementFile || !codeFile) {
|
||||
toast({
|
||||
title: "缺少输入文件",
|
||||
description: "请同时上传需求文档和代码文件。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
const result = await mockAnalyzeConsistency(requirementFile, codeFile);
|
||||
setReport(result);
|
||||
saveConsistencyDraft(result);
|
||||
toast({
|
||||
title: "分析完成",
|
||||
description: `发现 ${result.issues.length} 项待处理问题。`,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: "分析失败",
|
||||
description: "前端模拟分析失败,请重试。",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportReport = () => {
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
downloadJson(`consistency-report-${timestamp}.json`, report);
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "一致性分析报告已下载。",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">一致性分析</CardTitle>
|
||||
<CardDescription>
|
||||
上传需求文档与代码文件,比较需求-代码一致性并输出问题清单。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
<div
|
||||
{...requirementDropzone.getRootProps()}
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-dashed p-6 transition-colors",
|
||||
requirementDropzone.isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<input {...requirementDropzone.getInputProps()} />
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-5 w-5 mt-0.5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">需求文档</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
支持 Word、PDF、JSON、TXT/MD
|
||||
</p>
|
||||
{requirementFile && (
|
||||
<p className="text-xs mt-3 inline-flex items-center gap-1 rounded-full border px-3 py-1">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{requirementFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...codeDropzone.getRootProps()}
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-dashed p-6 transition-colors",
|
||||
codeDropzone.isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<input {...codeDropzone.getInputProps()} />
|
||||
<div className="flex items-start gap-3">
|
||||
<FileCode className="h-5 w-5 mt-0.5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">代码文件</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
支持 py/js/ts/tsx/java/go/cs/cpp/c 等文本代码文件
|
||||
</p>
|
||||
{codeFile && (
|
||||
<p className="text-xs mt-3 inline-flex items-center gap-1 rounded-full border px-3 py-1">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{codeFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 flex flex-wrap gap-3">
|
||||
<Button onClick={handleAnalyze} disabled={isAnalyzing}>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始分析
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportReport} disabled={!report}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出分析 JSON
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">总体一致性</p>
|
||||
<p className="mt-2 text-3xl font-bold">{report.consistencyScore}%</p>
|
||||
<Progress value={report.consistencyScore} className="mt-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">需求覆盖率</p>
|
||||
<p className="mt-2 text-3xl font-bold">{report.coverage}%</p>
|
||||
<Progress value={report.coverage} className="mt-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">问题数量</p>
|
||||
<p className="mt-2 text-3xl font-bold">{report.issues.length}</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
需优先处理高严重度问题
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">问题清单</CardTitle>
|
||||
<CardDescription>
|
||||
来源: {report.requirementFileName} vs {report.codeFileName}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1">
|
||||
{report.issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="rounded-lg border p-4 space-y-3 bg-card"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">
|
||||
{issue.id} · {issue.type}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={severityVariant[issue.severity]}>
|
||||
严重度 {issue.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{issue.summary}</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-md border p-2 text-xs text-muted-foreground">
|
||||
关联需求: {issue.requirementRef}
|
||||
</div>
|
||||
<div className="rounded-md border p-2 text-xs text-muted-foreground">
|
||||
关联代码: {issue.codeRef}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
|
||||
建议处理: {issue.suggestion}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
Loader2,
|
||||
Save,
|
||||
Sparkles,
|
||||
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 { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
downloadJson,
|
||||
loadExtractionDraft,
|
||||
parseRequirementJson,
|
||||
PriorityLevel,
|
||||
RequirementExtractionResult,
|
||||
RequirementItem,
|
||||
saveExtractionDraft,
|
||||
} from "@/lib/document-mock";
|
||||
import {
|
||||
createSrsJob,
|
||||
getSrsJobResult,
|
||||
getSrsJobStatus,
|
||||
saveSrsRequirements,
|
||||
toExtractionResult,
|
||||
} from "@/lib/srs-tools-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
const priorityVariant: Record<
|
||||
PriorityLevel,
|
||||
"default" | "secondary" | "outline"
|
||||
> = {
|
||||
高: "default",
|
||||
中: "secondary",
|
||||
低: "outline",
|
||||
};
|
||||
|
||||
const normalizeMultiline = (value: string) => {
|
||||
return value
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
};
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const EXTRACTION_JOB_KEY = "doc_processing_extraction_job_id";
|
||||
|
||||
export default function RequirementExtractionPage() {
|
||||
const [documentFile, setDocumentFile] = useState<File | null>(null);
|
||||
const [extraction, setExtraction] = useState<RequirementExtractionResult | null>(
|
||||
null
|
||||
);
|
||||
const [selectedRequirementId, setSelectedRequirementId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeJobId, setActiveJobId] = useState<number | null>(null);
|
||||
const [isImportingJson, setIsImportingJson] = useState(false);
|
||||
const jsonInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const storedJobId = window.localStorage.getItem(EXTRACTION_JOB_KEY);
|
||||
if (storedJobId) {
|
||||
const parsed = Number(storedJobId);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
setActiveJobId(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
const draft = loadExtractionDraft();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExtraction(draft);
|
||||
setSelectedRequirementId(draft.requirements[0]?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const first = acceptedFiles[0];
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
setDocumentFile(first);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxFiles: 1,
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
|
||||
".docx",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRequirement = extraction?.requirements.find(
|
||||
(item) => item.id === selectedRequirementId
|
||||
);
|
||||
|
||||
const updateRequirement = (
|
||||
requirementId: string,
|
||||
updater: (item: RequirementItem) => RequirementItem
|
||||
) => {
|
||||
setExtraction((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next: RequirementExtractionResult = {
|
||||
...prev,
|
||||
requirements: prev.requirements.map((item) =>
|
||||
item.id === requirementId ? updater(item) : item
|
||||
),
|
||||
};
|
||||
saveExtractionDraft(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!documentFile) {
|
||||
toast({
|
||||
title: "请先上传文档",
|
||||
description: "支持 Word/PDF 文件上传后再执行需求提取。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExtracting(true);
|
||||
try {
|
||||
const job = await createSrsJob(documentFile);
|
||||
setActiveJobId(job.job_id);
|
||||
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) {
|
||||
const status = await getSrsJobStatus(job.job_id);
|
||||
if (status.status === "completed") {
|
||||
const rawResult = await getSrsJobResult(job.job_id);
|
||||
finalResult = toExtractionResult(rawResult);
|
||||
break;
|
||||
}
|
||||
if (status.status === "failed") {
|
||||
throw new Error(status.error_message || "需求提取任务失败");
|
||||
}
|
||||
await wait(2000);
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error("提取任务超时,请稍后重试");
|
||||
}
|
||||
|
||||
setExtraction(finalResult);
|
||||
setSelectedRequirementId(finalResult.requirements[0]?.id ?? null);
|
||||
saveExtractionDraft(finalResult);
|
||||
toast({
|
||||
title: "提取完成",
|
||||
description: `已生成 ${finalResult.requirements.length} 条需求项。`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "提取失败,请重试";
|
||||
toast({
|
||||
title: "提取失败",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportJson = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImportingJson(true);
|
||||
try {
|
||||
const content = await file.text();
|
||||
const parsed = parseRequirementJson(content);
|
||||
setExtraction(parsed);
|
||||
setSelectedRequirementId(parsed.requirements[0]?.id ?? null);
|
||||
setActiveJobId(null);
|
||||
saveExtractionDraft(parsed);
|
||||
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
|
||||
toast({
|
||||
title: "导入成功",
|
||||
description: `已加载 ${parsed.requirements.length} 条需求项。`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "JSON 文件解析失败";
|
||||
toast({
|
||||
title: "导入失败",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsImportingJson(false);
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
if (!extraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persist = async () => {
|
||||
if (!activeJobId) {
|
||||
saveExtractionDraft(extraction);
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "当前需求编辑结果已保存到本地。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = await saveSrsRequirements(activeJobId, extraction);
|
||||
const next = toExtractionResult(saved);
|
||||
setExtraction(next);
|
||||
saveExtractionDraft(next);
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "修改内容已保存到服务端。",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "保存失败,请稍后重试";
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
void persist();
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (!extraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
downloadJson(`requirements-${timestamp}.json`, extraction);
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "JSON 文件已下载。",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">文档处理 · 需求提取</CardTitle>
|
||||
<CardDescription>
|
||||
上传 Word/PDF 文档后执行需求提取,可按字段查看、修改并保存。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
拖拽上传 Word/PDF,或点击选择文件
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
支持 .docx / .pdf
|
||||
</p>
|
||||
{documentFile && (
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{documentFile.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button onClick={handleExtract} disabled={isExtracting || !documentFile}>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始提取
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => jsonInputRef.current?.click()}
|
||||
disabled={isImportingJson}
|
||||
>
|
||||
{isImportingJson ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileJson className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
导入 JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!extraction || isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
保存修改
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportJson} disabled={!extraction}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 JSON
|
||||
</Button>
|
||||
<input
|
||||
ref={jsonInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={handleImportJson}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{extraction && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
文档: {extraction.documentName} · 需求数: {extraction.requirements.length} ·
|
||||
更新时间: {formatDateTime(extraction.generatedAt)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_1fr]">
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">需求项列表</CardTitle>
|
||||
<CardDescription>点击左侧条目可在右侧编辑详情</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!extraction && (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
|
||||
暂无需求项,请先上传文档提取或导入 JSON。
|
||||
</div>
|
||||
)}
|
||||
{extraction && (
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1">
|
||||
{extraction.requirements.map((item) => {
|
||||
const active = item.id === selectedRequirementId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setSelectedRequirementId(item.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm line-clamp-1">{item.title}</p>
|
||||
<Badge variant={priorityVariant[item.priority]}>
|
||||
{item.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{item.id} · {item.sourceField}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">需求详情编辑</CardTitle>
|
||||
<CardDescription>字段级编辑后可保存到本地并导出 JSON</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedRequirement && (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
|
||||
请选择左侧需求项。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequirement && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-id">需求编号</Label>
|
||||
<Input
|
||||
id="req-id"
|
||||
value={selectedRequirement.id}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
id: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-title">需求标题</Label>
|
||||
<Input
|
||||
id="req-title"
|
||||
value={selectedRequirement.title}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-priority">优先级</Label>
|
||||
<select
|
||||
id="req-priority"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedRequirement.priority}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
priority: event.target.value as PriorityLevel,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="高">高</option>
|
||||
<option value="中">中</option>
|
||||
<option value="低">低</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-source">来源字段</Label>
|
||||
<Input
|
||||
id="req-source"
|
||||
value={selectedRequirement.sourceField}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
sourceField: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-description">需求描述</Label>
|
||||
<textarea
|
||||
id="req-description"
|
||||
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedRequirement.description}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-acceptance">验收标准(每行一条)</Label>
|
||||
<textarea
|
||||
id="req-acceptance"
|
||||
className="min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedRequirement.acceptanceCriteria.join("\n")}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
acceptanceCriteria: normalizeMultiline(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function DocumentProcessingIndexPage() {
|
||||
redirect("/dashboard/doc-processing/extract");
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
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 { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
downloadJson,
|
||||
downloadWord,
|
||||
loadTestCaseDraft,
|
||||
mockGenerateTestCases,
|
||||
parseRequirementJson,
|
||||
RequirementExtractionResult,
|
||||
saveTestCaseDraft,
|
||||
TestCaseGenerationResult,
|
||||
toWordContent,
|
||||
} from "@/lib/document-mock";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function TestCaseGenerationPage() {
|
||||
const [inputFile, setInputFile] = useState<File | null>(null);
|
||||
const [requirementData, setRequirementData] =
|
||||
useState<RequirementExtractionResult | null>(null);
|
||||
const [generation, setGeneration] = useState<TestCaseGenerationResult | null>(
|
||||
null
|
||||
);
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadTestCaseDraft();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneration(draft);
|
||||
setSelectedCaseId(draft.testCases[0]?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const parseRequirementFile = async (file: File) => {
|
||||
setIsParsing(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = parseRequirementJson(text);
|
||||
setRequirementData(parsed);
|
||||
toast({
|
||||
title: "解析成功",
|
||||
description: `已识别 ${parsed.requirements.length} 条需求。`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "JSON 解析失败";
|
||||
toast({
|
||||
title: "解析失败",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setInputFile(file);
|
||||
setGeneration(null);
|
||||
setSelectedCaseId(null);
|
||||
await parseRequirementFile(file);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxFiles: 1,
|
||||
accept: {
|
||||
"application/json": [".json"],
|
||||
"text/json": [".json"],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCase = generation?.testCases.find(
|
||||
(testCase) => testCase.id === selectedCaseId
|
||||
);
|
||||
|
||||
const handleChooseFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInput = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInputFile(file);
|
||||
setGeneration(null);
|
||||
setSelectedCaseId(null);
|
||||
await parseRequirementFile(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!requirementData) {
|
||||
toast({
|
||||
title: "缺少输入",
|
||||
description: "请先上传并解析需求 JSON 文件。",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await mockGenerateTestCases(requirementData);
|
||||
setGeneration(result);
|
||||
setSelectedCaseId(result.testCases[0]?.id ?? null);
|
||||
saveTestCaseDraft(result);
|
||||
toast({
|
||||
title: "生成完成",
|
||||
description: `已生成 ${result.testCases.length} 条测试用例。`,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: "生成失败",
|
||||
description: "前端模拟生成失败,请重试。",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportWord = () => {
|
||||
if (!generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = toWordContent(generation);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
downloadWord(`test-cases-${timestamp}.doc`, content);
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "Word 文件已下载。",
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (!generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
downloadJson(`test-cases-${timestamp}.json`, generation);
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "测试用例 JSON 已下载。",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">文档处理 · 测试用例生成</CardTitle>
|
||||
<CardDescription>
|
||||
上传需求 JSON 后生成完整测试用例,并支持导出 Word 文件。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">拖拽上传需求 JSON 文件</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">支持 .json</p>
|
||||
{inputFile && (
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs">
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
{inputFile.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="outline" onClick={handleChooseFile} disabled={isParsing}>
|
||||
{isParsing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileJson className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
选择 JSON 文件
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={!requirementData || isGenerating}>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
生成测试用例
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleExportWord} disabled={!generation}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
导出 Word
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportJson} disabled={!generation}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 JSON
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{requirementData && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
来源: {requirementData.documentName} · 需求数: {requirementData.requirements.length}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">生成结果列表</CardTitle>
|
||||
<CardDescription>每条测试用例均映射到对应需求</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!generation && (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
|
||||
暂无生成结果,请先上传 JSON 并执行生成。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generation && (
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1">
|
||||
{generation.testCases.map((testCase) => {
|
||||
const active = testCase.id === selectedCaseId;
|
||||
return (
|
||||
<button
|
||||
key={testCase.id}
|
||||
onClick={() => setSelectedCaseId(testCase.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium line-clamp-1">{testCase.title}</p>
|
||||
<Badge>{testCase.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{testCase.requirementId} · {testCase.requirementTitle}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">测试用例详情</CardTitle>
|
||||
<CardDescription>查看前置条件、步骤与预期结果</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedCase && (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
|
||||
请选择左侧测试用例。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCase && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{selectedCase.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用例编号: {selectedCase.id} · 需求映射: {selectedCase.requirementId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">前置条件</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{selectedCase.preconditions.map((line, index) => (
|
||||
<li key={`${selectedCase.id}-pre-${index}`} className="rounded-md border p-2">
|
||||
{index + 1}. {line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">测试步骤</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{selectedCase.steps.map((line, index) => (
|
||||
<li key={`${selectedCase.id}-step-${index}`} className="rounded-md border p-2">
|
||||
{index + 1}. {line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">预期结果</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{selectedCase.expectedResults.map((line, index) => (
|
||||
<li key={`${selectedCase.id}-exp-${index}`} className="rounded-md border p-2">
|
||||
{index + 1}. {line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState, useCallback } from "react";
|
||||
import { DocumentUploadSteps } from "@/components/knowledge-base/document-upload-steps";
|
||||
import { DocumentList } from "@/components/knowledge-base/document-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const params = useParams();
|
||||
const knowledgeBaseId = parseInt(params.id as string);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const handleUploadComplete = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">知识库</h1>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
添加文档
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加文档</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传文档到知识库。支持格式:PDF、DOCX、Markdown、TXT。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DocumentUploadSteps
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
onComplete={handleUploadComplete}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<DocumentList key={refreshKey} knowledgeBaseId={knowledgeBaseId} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status:
|
||||
| "pending"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "error";
|
||||
error?: string;
|
||||
uploadId?: number;
|
||||
taskId?: number;
|
||||
documentId?: number;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
upload_id?: number;
|
||||
document_id?: number;
|
||||
file_name: string;
|
||||
status: string;
|
||||
skip_processing: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ProcessingTask {
|
||||
upload_id: number;
|
||||
task_id: number;
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
document_id: number | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
upload_id: number;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
export default function UploadPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [processingTasks, setProcessingTasks] = useState<ProcessingTask[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const newFiles = acceptedFiles.map((file) => ({
|
||||
file,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
|
||||
// Upload each file
|
||||
newFiles.forEach(async (fileStatus) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.file === fileStatus.file ? { ...f, status: "uploading" } : f
|
||||
)
|
||||
);
|
||||
await handleUpload(fileStatus.file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"text/plain": [".txt"],
|
||||
"application/pdf": [".pdf"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
[".docx"],
|
||||
"text/markdown": [".md"],
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const result: UploadResult = await api.post(
|
||||
`/api/knowledge-base/${params.id}/documents/upload`,
|
||||
formData
|
||||
);
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.file.name === result.file_name
|
||||
? {
|
||||
...f,
|
||||
status: result.skip_processing ? "completed" : "uploaded",
|
||||
uploadId: result.upload_id,
|
||||
documentId: result.document_id,
|
||||
}
|
||||
: f
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: result.message || "文件上传成功",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to upload file:", error);
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.file === file
|
||||
? {
|
||||
...f,
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof ApiError ? error.message : "上传失败",
|
||||
}
|
||||
: f
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startProcessing = async () => {
|
||||
const uploadedFiles = files.filter((f) => f.status === "uploaded");
|
||||
if (uploadedFiles.length === 0) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const uploadResults = uploadedFiles.map((f) => ({
|
||||
upload_id: f.uploadId,
|
||||
file_name: f.file.name,
|
||||
skip_processing: false,
|
||||
}));
|
||||
|
||||
const response = await api.post(
|
||||
`/api/knowledge-base/${params.id}/documents/process`,
|
||||
uploadResults
|
||||
);
|
||||
|
||||
setProcessingTasks(response.tasks);
|
||||
|
||||
// Update file statuses to processing
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => {
|
||||
const task = response.tasks.find(
|
||||
(t: ProcessingTask) => t.upload_id === f.uploadId
|
||||
);
|
||||
if (task) {
|
||||
return {
|
||||
...f,
|
||||
status: "processing",
|
||||
taskId: task.task_id,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to start processing:", error);
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "启动文件处理失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkProcessingStatus = async () => {
|
||||
if (processingTasks.length === 0) return;
|
||||
|
||||
try {
|
||||
const taskIds = processingTasks.map((t) => t.task_id).join(",");
|
||||
const status: Record<string, TaskStatus> = await api.get(
|
||||
`/api/knowledge-base/${params.id}/documents/tasks?task_ids=${taskIds}`
|
||||
);
|
||||
|
||||
let allCompleted = true;
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => {
|
||||
const task = processingTasks.find((t) => t.upload_id === f.uploadId);
|
||||
if (task && status[task.task_id]) {
|
||||
const taskStatus = status[task.task_id];
|
||||
if (taskStatus.status !== "completed") {
|
||||
allCompleted = false;
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
status:
|
||||
taskStatus.status === "completed"
|
||||
? "completed"
|
||||
: taskStatus.status === "failed"
|
||||
? "error"
|
||||
: "processing",
|
||||
documentId: taskStatus.document_id || undefined,
|
||||
error: taskStatus.error_message || undefined,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
})
|
||||
);
|
||||
|
||||
if (allCompleted) {
|
||||
setIsProcessing(false);
|
||||
setShowSuccessModal(true);
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "所有文件处理完成",
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check processing status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isProcessing) {
|
||||
interval = setInterval(checkProcessingStatus, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isProcessing, processingTasks]);
|
||||
|
||||
const removeFile = (file: File) => {
|
||||
setFiles((prev) => prev.filter((f) => f.file !== file));
|
||||
};
|
||||
|
||||
const allCompleted =
|
||||
files.length > 0 &&
|
||||
files.every((f) => f.status === "completed" || f.status === "error");
|
||||
|
||||
const hasUploadedFiles = files.some((f) => f.status === "uploaded");
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
上传文档
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
将文档上传到知识库
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
将文件拖拽到此处,或点击选择文件
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
支持格式:PDF、DOCX、TXT、MD
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">文件列表</h3>
|
||||
{hasUploadedFiles && !isProcessing && (
|
||||
<button
|
||||
onClick={startProcessing}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
开始处理
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto rounded-lg">
|
||||
{files.map((fileStatus) => (
|
||||
<div
|
||||
key={fileStatus.file.name}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<FileText className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium">{fileStatus.file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(fileStatus.file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{fileStatus.status === "uploading" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground">
|
||||
上传中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fileStatus.status === "processing" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground">
|
||||
处理中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fileStatus.status === "completed" && (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
{fileStatus.status === "error" && (
|
||||
<div className="flex items-center space-x-2 text-red-500">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="text-sm">{fileStatus.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFile(fileStatus.file)}
|
||||
className="p-1 hover:bg-accent rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
{showSuccessModal ? (
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/knowledge/${params.id}`)}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/knowledge/${params.id}`)}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
123
rag-web-ui/frontend/src/app/dashboard/knowledge/new/page.tsx
Normal file
123
rag-web-ui/frontend/src/app/dashboard/knowledge/new/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
documents: any[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function NewKnowledgeBasePage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
|
||||
const data = await api.post("/api/knowledge-base", {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
router.push(`/dashboard/knowledge/${data.id}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to create knowledge base:", error);
|
||||
if (error instanceof ApiError) {
|
||||
setError(error.message);
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
setError("创建知识库失败");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
创建知识库
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
创建一个新知识库来存储你的文档
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="请输入知识库名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="请输入知识库描述"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
{isSubmitting ? "创建中..." : "创建"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
220
rag-web-ui/frontend/src/app/dashboard/knowledge/page.tsx
Normal file
220
rag-web-ui/frontend/src/app/dashboard/knowledge/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileIcon, defaultStyles } from "react-file-icon";
|
||||
import { ArrowRight, Plus, Settings, Trash2, Search } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
documents: Document[];
|
||||
created_at: string;
|
||||
}
|
||||
interface Document {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
knowledge_base_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
processing_tasks: any[];
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
const fetchKnowledgeBases = async () => {
|
||||
try {
|
||||
const data = await api.get("/api/knowledge-base");
|
||||
setKnowledgeBases(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge bases:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定要删除这个知识库吗?"))
|
||||
return;
|
||||
try {
|
||||
await api.delete(`/api/knowledge-base/${id}`);
|
||||
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "知识库删除成功",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete knowledge base:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
知识库
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
管理你的知识库与文档
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/knowledge/new"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建知识库
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{knowledgeBases.map((kb) => (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="rounded-lg border bg-card p-6 space-y-4"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{kb.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{kb.description || "暂无描述"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{kb.documents.length} 个文档 •{" "}
|
||||
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/dashboard/knowledge/${kb.id}`}
|
||||
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/test-retrieval/${kb.id}`}
|
||||
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
className="inline-flex items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20 w-8 h-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kb.documents.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-medium mb-2">文档</h4>
|
||||
<div className="flex flex-wrap gap-2 max-h-[400px] overflow-y-auto">
|
||||
{kb.documents.slice(0, 9).map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-col items-center gap-2 p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
|
||||
>
|
||||
<div className="w-8 h-8 mb-2">
|
||||
{doc.content_type.toLowerCase().includes("pdf") ? (
|
||||
<FileIcon extension="pdf" {...defaultStyles.pdf} />
|
||||
) : doc.content_type.toLowerCase().includes("doc") ? (
|
||||
<FileIcon extension="doc" {...defaultStyles.docx} />
|
||||
) : doc.content_type.toLowerCase().includes("txt") ? (
|
||||
<FileIcon extension="txt" {...defaultStyles.txt} />
|
||||
) : doc.content_type.toLowerCase().includes("md") ? (
|
||||
<FileIcon extension="md" {...defaultStyles.md} />
|
||||
) : (
|
||||
<FileIcon
|
||||
extension={doc.file_name.split(".").pop() || ""}
|
||||
color="#E2E8F0"
|
||||
labelColor="#94A3B8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-center max-w-[100px]">
|
||||
<div className="line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{doc.file_name}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{kb.documents.length > 9 && (
|
||||
<Link
|
||||
href={`/dashboard/knowledge/${kb.id}`}
|
||||
className="flex flex-col items-center p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
|
||||
>
|
||||
<div className="w-8 h-8 mb-2 flex items-center justify-center">
|
||||
<ArrowRight className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-center">
|
||||
查看全部文档
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
共 {kb.documents.length} 个
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && knowledgeBases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
暂无知识库,请先创建一个。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="space-y-4">
|
||||
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto"></div>
|
||||
<p className="text-muted-foreground animate-pulse">
|
||||
正在加载知识库...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
259
rag-web-ui/frontend/src/app/dashboard/page.tsx
Normal file
259
rag-web-ui/frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import {
|
||||
Book,
|
||||
MessageSquare,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Upload,
|
||||
Brain,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
documents: any[];
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
id: number;
|
||||
title: string;
|
||||
messages: any[];
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
knowledgeBases: number;
|
||||
chats: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({ knowledgeBases: 0, chats: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [kbData, chatData] = await Promise.all([
|
||||
api.get("/api/knowledge-base"),
|
||||
api.get("/api/chat"),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
knowledgeBases: kbData.length,
|
||||
chats: chatData.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats:", error);
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{/* Hero Section */}
|
||||
<div className="mb-12 rounded-2xl bg-gradient-to-r from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-8 shadow-sm">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-indigo-500 bg-clip-text text-transparent">
|
||||
知识助手
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-300 max-w-xl">
|
||||
你的 AI 知识中枢。上传文档、构建知识库,并通过自然对话快速获得答案。
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/dashboard/knowledge/new"
|
||||
className="inline-flex items-center justify-center rounded-full bg-blue-600 px-6 py-3 text-sm font-medium text-white hover:bg-blue-700 transition-all shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建知识库
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-12">
|
||||
<div className="rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="rounded-full bg-blue-100 dark:bg-blue-900/30 p-4">
|
||||
<Book className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-4xl font-bold text-slate-900 dark:text-white">
|
||||
{stats.knowledgeBases}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||
知识库数量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/dashboard/knowledge"
|
||||
className="mt-6 flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
查看全部知识库
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="rounded-full bg-indigo-100 dark:bg-indigo-900/30 p-4">
|
||||
<MessageSquare className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-4xl font-bold text-slate-900 dark:text-white">
|
||||
{stats.chats}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||
对话会话数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/dashboard/chat"
|
||||
className="mt-6 flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 text-sm font-medium"
|
||||
>
|
||||
查看全部会话
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white mb-6">
|
||||
快捷操作
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-12">
|
||||
<a
|
||||
href="/dashboard/knowledge/new"
|
||||
className="flex flex-col items-center justify-center rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all hover:border-blue-500 dark:hover:border-blue-500"
|
||||
>
|
||||
<div className="rounded-full bg-blue-100 dark:bg-blue-900/30 p-4 mb-4">
|
||||
<Brain className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">
|
||||
创建知识库
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 text-center">
|
||||
创建新的 AI 知识仓库
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard/knowledge"
|
||||
className="flex flex-col items-center justify-center rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all hover:border-indigo-500 dark:hover:border-indigo-500"
|
||||
>
|
||||
<div className="rounded-full bg-indigo-100 dark:bg-indigo-900/30 p-4 mb-4">
|
||||
<Upload className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">
|
||||
上传文档
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 text-center">
|
||||
为知识库添加 PDF、DOCX、MD 或 TXT 文件
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard/chat/new"
|
||||
className="flex flex-col items-center justify-center rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all hover:border-purple-500 dark:hover:border-purple-500"
|
||||
>
|
||||
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-4 mb-4">
|
||||
<Sparkles className="h-8 w-8 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">
|
||||
开始对话
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 text-center">
|
||||
基于知识库即时获得 AI 回答
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Getting Started Guide */}
|
||||
<div className="rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white mb-6 flex items-center">
|
||||
<Search className="mr-3 h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
使用流程
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-6 p-6 rounded-xl bg-slate-50 dark:bg-slate-700/30">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white font-semibold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg text-slate-900 dark:text-white mb-2">
|
||||
创建知识库
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
先创建一个新知识库来组织信息,并设置易于识别用途的名称与描述。
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard/knowledge/new"
|
||||
className="mt-4 inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
立即创建
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-6 p-6 rounded-xl bg-slate-50 dark:bg-slate-700/30">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-white font-semibold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg text-slate-900 dark:text-white mb-2">
|
||||
上传文档
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
向知识库上传 PDF、DOCX、MD 或 TXT 文件。系统会自动完成处理与索引,支持 AI 检索。
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard/knowledge"
|
||||
className="mt-4 inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 text-sm font-medium"
|
||||
>
|
||||
去上传
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-6 p-6 rounded-xl bg-slate-50 dark:bg-slate-700/30">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-purple-600 text-white font-semibold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg text-slate-900 dark:text-white mb-2">
|
||||
与知识对话
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
围绕知识库发起对话,用自然语言提问,并基于文档内容获得准确回答。
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard/chat/new"
|
||||
className="mt-4 inline-flex items-center text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 text-sm font-medium"
|
||||
>
|
||||
开始对话
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
import { Search, ArrowRight, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function TestPage({ params }: { params: { id: string } }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(
|
||||
null
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [topK, setTopK] = useState("3");
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKnowledgeBase = async () => {
|
||||
try {
|
||||
const data = await api.get(`/api/knowledge-base/${params.id}`);
|
||||
setKnowledgeBase(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge base:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchKnowledgeBase();
|
||||
}, [params.id]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!query) {
|
||||
toast({
|
||||
title: "请完善输入",
|
||||
description: "请输入查询内容",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.post("/api/knowledge-base/test-retrieval", {
|
||||
query,
|
||||
kb_id: parseInt(params.id),
|
||||
top_k: parseInt(topK),
|
||||
});
|
||||
|
||||
setResults(data.results);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "测试失败",
|
||||
description: error instanceof Error ? error.message : "未知错误",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-background/50">
|
||||
<div className="max-w-6xl mx-auto py-12 px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
|
||||
知识库检索测试
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{knowledgeBase.name}
|
||||
</span>
|
||||
{knowledgeBase.description && <span className="mx-2">•</span>}
|
||||
<span className="italic">{knowledgeBase.description}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="backdrop-blur-sm bg-card/50 border-primary/20">
|
||||
<CardContent className="p-8">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="输入您想要查询的内容..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-12 h-14 text-lg bg-background/50 border-primary/20 focus:border-primary"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleTest()}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
size="lg"
|
||||
className="absolute right-0 top-0 h-14 px-8 bg-primary hover:bg-primary/90"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<Sparkles className="animate-spin mr-2 h-4 w-4" />
|
||||
搜索中...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center">
|
||||
搜索
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select value={topK} onValueChange={setTopK}>
|
||||
<SelectTrigger className="w-[140px] h-14 bg-background/50 border-primary/20">
|
||||
<SelectValue placeholder="返回数量" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">前 1 条</SelectItem>
|
||||
<SelectItem value="3">前 3 条</SelectItem>
|
||||
<SelectItem value="5">前 5 条</SelectItem>
|
||||
<SelectItem value="10">前 10 条</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="mt-12 space-y-8">
|
||||
<h2 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
搜索结果
|
||||
</h2>
|
||||
<div className="grid gap-6">
|
||||
{results.map((result, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="overflow-hidden border-0 shadow-lg hover:shadow-xl transition-shadow duration-300 bg-card/50 backdrop-blur-sm"
|
||||
>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-4 py-2 rounded-full bg-primary/10 text-primary font-medium">
|
||||
相关度: {(result.score * 100).toFixed(2)}%
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
来源: {result.metadata.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed whitespace-pre-wrap prose prose-gray max-w-none">
|
||||
{result.content}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
BIN
rag-web-ui/frontend/src/app/favicon.ico
Normal file
BIN
rag-web-ui/frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
80
rag-web-ui/frontend/src/app/globals.css
Normal file
80
rag-web-ui/frontend/src/app/globals.css
Normal file
@@ -0,0 +1,80 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.language-think {
|
||||
@apply text-[#8b8b8b] whitespace-pre-wrap m-0 relative border-l-2 border-l-[#e5e5e5] bg-transparent rounded-none py-0 my-2 !important;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
@apply rounded-lg;
|
||||
@apply my-2;
|
||||
@apply leading-[26px];
|
||||
@apply text-[#404040];
|
||||
}
|
||||
28
rag-web-ui/frontend/src/app/layout.tsx
Normal file
28
rag-web-ui/frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import "highlight.js/styles/github.css";
|
||||
// 如果使用 App Router
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RAG 知识助手",
|
||||
description: "面向 RAG 应用的可视化 Web 界面",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
130
rag-web-ui/frontend/src/app/login/page.tsx
Normal file
130
rag-web-ui/frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username");
|
||||
const password = formData.get("password");
|
||||
|
||||
try {
|
||||
const formUrlEncoded = new URLSearchParams();
|
||||
formUrlEncoded.append("username", username as string);
|
||||
formUrlEncoded.append("password", password as string);
|
||||
|
||||
const data = await api.post("/api/auth/token", formUrlEncoded, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
localStorage.setItem("token", data.access_token);
|
||||
router.push("/dashboard");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("登录失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
欢迎使用 RAG Web UI
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
请登录后继续
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full px-3 py-2 rounded-md border border-gray-300 shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full px-3 py-2 rounded-md border border-gray-300 shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-sm font-medium text-gray-600 hover:text-gray-500"
|
||||
>
|
||||
还没有账号?立即注册
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
111
rag-web-ui/frontend/src/app/page.tsx
Normal file
111
rag-web-ui/frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white text-black">
|
||||
<div className="max-w-7xl mx-auto px-4 py-24">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center space-y-8 mb-24">
|
||||
<h1 className="text-6xl sm:text-7xl font-bold tracking-tight text-black">
|
||||
RAG 知识助手
|
||||
</h1>
|
||||
<p className="text-xl sm:text-2xl text-gray-500 max-w-3xl mx-auto font-light leading-relaxed">
|
||||
体验新一代 AI 交互方式。
|
||||
<br />
|
||||
强大、直观、革新。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center mt-12">
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-8 py-4 bg-blue-600 text-white rounded-full text-lg font-medium transition-all duration-300 hover:bg-blue-700 w-full sm:w-auto"
|
||||
>
|
||||
开始使用
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-4 bg-gray-200 text-gray-800 rounded-full text-lg font-medium transition-all duration-300 hover:bg-gray-300 w-full sm:w-auto"
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 mb-24">
|
||||
<div className="text-center">
|
||||
<div className="h-20 w-20 mx-auto rounded-full bg-blue-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-black mb-4">
|
||||
强大的 RAG 引擎
|
||||
</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
借助先进检索与生成系统,充分发挥前沿 AI 模型能力。
|
||||
为高性能与可扩展场景而设计。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="h-20 w-20 mx-auto rounded-full bg-blue-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-black mb-4">
|
||||
无缝集成
|
||||
</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
轻松对接现有技术栈,灵活 API 与完善 SDK 让接入更简单。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="h-20 w-20 mx-auto rounded-full bg-blue-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-black mb-4">
|
||||
实时分析
|
||||
</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
通过完善的数据看板与监控工具,深入洞察 RAG 系统表现。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
246
rag-web-ui/frontend/src/app/register/page.tsx
Normal file
246
rag-web-ui/frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [validationErrors, setValidationErrors] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
email: "请输入有效的邮箱地址",
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
setValidationErrors((prev) => ({ ...prev, email: "" }));
|
||||
return true;
|
||||
};
|
||||
|
||||
const validatePassword = (password: string) => {
|
||||
if (password.length < 8) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
password: "密码长度至少为 8 位",
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
password: "密码需至少包含一个大写字母",
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
password: "密码需至少包含一个小写字母",
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
password: "密码需至少包含一个数字",
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
setValidationErrors((prev) => ({ ...prev, password: "" }));
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setValidationErrors({ email: "", password: "", confirmPassword: "" });
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const confirmPassword = formData.get("confirmPassword") as string;
|
||||
|
||||
// Validate email and password
|
||||
const isEmailValid = validateEmail(email);
|
||||
const isPasswordValid = validatePassword(password);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
confirmPassword: "两次输入的密码不一致",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEmailValid || !isPasswordValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post("/api/auth/register", {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
router.push("/login");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("注册失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
欢迎使用 RAG Web UI
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
创建账号以开始使用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 rounded-md border border-gray-300 shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className={`mt-1 block w-full px-3 py-2 rounded-md border ${
|
||||
validationErrors.email
|
||||
? "border-red-300"
|
||||
: "border-gray-300"
|
||||
} shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="请输入邮箱"
|
||||
onChange={(e) => validateEmail(e.target.value)}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{validationErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className={`mt-1 block w-full px-3 py-2 rounded-md border ${
|
||||
validationErrors.password
|
||||
? "border-red-300"
|
||||
: "border-gray-300"
|
||||
} shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`}
|
||||
placeholder="请设置密码"
|
||||
onChange={(e) => validatePassword(e.target.value)}
|
||||
/>
|
||||
{validationErrors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{validationErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
className={`mt-1 block w-full px-3 py-2 rounded-md border ${
|
||||
validationErrors.confirmPassword
|
||||
? "border-red-300"
|
||||
: "border-gray-300"
|
||||
} shadow-sm focus:ring-2 focus:ring-gray-500 focus:border-gray-500`}
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
{validationErrors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{validationErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
创建账号
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-gray-600 hover:text-gray-500"
|
||||
>
|
||||
已有账号?去登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user