init. project

This commit is contained in:
2026-04-13 11:34:23 +08:00
commit c7c0659a85
202 changed files with 31196 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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">
WordPDFJSONTXT/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>
);
}

View File

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

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function DocumentProcessingIndexPage() {
redirect("/dashboard/doc-processing/extract");
}

View File

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

View File

@@ -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>
PDFDOCXMarkdownTXT
</DialogDescription>
</DialogHeader>
<DocumentUploadSteps
knowledgeBaseId={knowledgeBaseId}
onComplete={handleUploadComplete}
/>
</DialogContent>
</Dialog>
</div>
<div className="mt-8">
<DocumentList key={refreshKey} knowledgeBaseId={knowledgeBaseId} />
</div>
</DashboardLayout>
);
}

View File

@@ -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">
PDFDOCXTXTMD
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
PDFDOCXMD 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">
PDFDOCXMD 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>
);
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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];
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}