Initial commit: 国土空间规划课程智能体 v1.0
单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Send, Loader2, Bot, User, StopCircle, ArrowDown, Paperclip } from "lucide-react";
|
||||
import MessageList from "./message-list";
|
||||
import QuickQuestions from "./quick-questions";
|
||||
import ModeSelector, { ChatMode } from "./mode-selector";
|
||||
import ModelSelector from "./model-selector";
|
||||
import KnowledgeSelector, { KnowledgeBase } from "./knowledge-selector";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { knowledgeBaseAPI } from "@/lib/api";
|
||||
|
||||
export default function ChatInterface() {
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [chatMode, setChatMode] = useState<ChatMode>("normal");
|
||||
const [selectedModel, setSelectedModel] = useState("deepseek-ai/DeepSeek-V3");
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
||||
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [userKnowledgeBases, setUserKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
currentSession,
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
sendMessage,
|
||||
streamMessage,
|
||||
stopGeneration,
|
||||
createSession,
|
||||
selectSession,
|
||||
} = useChatStore();
|
||||
|
||||
// 调试日志
|
||||
useEffect(() => {
|
||||
console.log("[DEBUG-CHAT-INTERFACE] 组件状态:", {
|
||||
currentSession: currentSession?.id,
|
||||
messagesCount: messages.length,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
messages: messages.map(m => ({ id: m.id, role: m.role, contentLength: m.content.length }))
|
||||
});
|
||||
}, [currentSession, messages, isLoading, isStreaming]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: isStreaming ? "auto" : "smooth"
|
||||
});
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
// 加载知识库(区分系统知识库和用户知识库)
|
||||
useEffect(() => {
|
||||
const loadKnowledgeBases = async () => {
|
||||
try {
|
||||
const bases = await knowledgeBaseAPI.getKnowledgeBases();
|
||||
|
||||
console.log("[DEBUG] 加载的知识库:", bases.map(kb => ({
|
||||
id: kb.id,
|
||||
name: kb.name,
|
||||
is_system: kb.is_system
|
||||
})));
|
||||
|
||||
// 转换为KnowledgeSelector需要的格式
|
||||
const formattedBases: KnowledgeBase[] = bases.map(kb => ({
|
||||
id: kb.id.toString(),
|
||||
name: kb.name,
|
||||
description: kb.description || "",
|
||||
documentCount: kb.document_count,
|
||||
enabled: true, // 默认启用
|
||||
isSystem: kb.is_system === true, // 明确检查是否为 true
|
||||
}));
|
||||
|
||||
// 分离系统知识库和用户知识库
|
||||
const systemBases = formattedBases.filter(kb => kb.isSystem === true);
|
||||
const userBases = formattedBases.filter(kb => kb.isSystem !== true);
|
||||
|
||||
console.log("[DEBUG] 系统知识库:", systemBases.map(kb => ({ id: kb.id, name: kb.name })));
|
||||
console.log("[DEBUG] 用户知识库:", userBases.map(kb => ({ id: kb.id, name: kb.name })));
|
||||
|
||||
setSystemKnowledgeBases(systemBases);
|
||||
setUserKnowledgeBases(userBases);
|
||||
} catch (error) {
|
||||
console.error("加载知识库失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputMessage.trim() || isLoading || isStreaming) return;
|
||||
|
||||
const message = inputMessage.trim();
|
||||
setInputMessage("");
|
||||
|
||||
console.log("[DEBUG-CHAT] 发送消息:", {
|
||||
message: message,
|
||||
chatMode: chatMode,
|
||||
selectedKnowledgeBases: selectedKnowledgeBases,
|
||||
selectedModel: selectedModel,
|
||||
currentSession: currentSession?.id
|
||||
});
|
||||
|
||||
// 如果没有当前会话,先创建一个新会话
|
||||
if (!currentSession) {
|
||||
const newSession = await createSession("新对话");
|
||||
if (newSession) {
|
||||
await selectSession(newSession.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用流式发送,传递聊天模式、选中的知识库ID和模型ID
|
||||
await streamMessage(message, chatMode, selectedKnowledgeBases, selectedModel);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickQuestion = async (question: string) => {
|
||||
setInputMessage(question);
|
||||
|
||||
// 聚焦到输入框
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
console.log("[CHAT-INTERFACE] Current state:", {
|
||||
hasSession: !!currentSession,
|
||||
sessionId: currentSession?.id,
|
||||
messagesCount: messages.length
|
||||
});
|
||||
|
||||
if (!currentSession) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-background h-full">
|
||||
{/* 上部:欢迎内容区域 - 使用 flex-1 + 可滚动 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="text-center max-w-2xl px-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Bot className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-foreground mb-2">
|
||||
欢迎使用国土空间规划课程智能体
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
基于大模型的智能问答系统,为您提供专业的国土空间规划知识服务
|
||||
</p>
|
||||
<QuickQuestions onSelect={handleQuickQuestion} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:输入区域 - 不使用 sticky,直接作为 flex 子元素 */}
|
||||
<div className="border-t border-border bg-card p-4 lg:p-6 flex-shrink-0">
|
||||
{/* 功能选择器 */}
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ModeSelector mode={chatMode} onModeChange={setChatMode} />
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<KnowledgeSelector
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onBasesChange={setSelectedKnowledgeBases}
|
||||
systemKnowledgeBases={systemKnowledgeBases}
|
||||
userKnowledgeBases={userKnowledgeBases}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3 flex-shrink-0"
|
||||
title="上传附件"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输入框和发送按钮 */}
|
||||
<div className="flex items-end space-x-3">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
placeholder="输入您的问题..."
|
||||
disabled={isLoading || isStreaming}
|
||||
className={cn(
|
||||
"min-h-[48px] resize-none touch-manipulation pr-12 rounded-xl",
|
||||
"border-border/50 bg-background/80 backdrop-blur-sm",
|
||||
"focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50",
|
||||
"transition-all duration-200"
|
||||
)}
|
||||
/>
|
||||
{isStreaming && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputMessage.trim() || isLoading || isStreaming}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-12 w-12 flex-shrink-0 touch-manipulation rounded-xl",
|
||||
"bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700",
|
||||
"shadow-lg hover:shadow-xl transition-all duration-200",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLoading || isStreaming ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 输入提示 */}
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="hidden sm:block">按 Enter 发送,Shift + Enter 换行</span>
|
||||
<span className="sm:hidden">点击发送按钮发送消息</span>
|
||||
{isStreaming && (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||
正在生成回复...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-background h-full">
|
||||
{/* 聊天头部 - 不需要 sticky,作为 flex 子元素自然在顶部 */}
|
||||
<div className="border-b border-border bg-card p-4 lg:p-6 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Bot className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold text-foreground truncate">
|
||||
{currentSession.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isStreaming && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={stopGeneration}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
停止生成
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 - 使用 flex-1 占据剩余空间 + 可滚动 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="text-center max-w-2xl">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Bot className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-foreground mb-2">
|
||||
开始对话
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-lg mx-auto">
|
||||
输入您的问题,我将为您提供专业的国土空间规划知识解答
|
||||
</p>
|
||||
<QuickQuestions onSelect={handleQuickQuestion} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MessageList messages={messages} />
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区域 - 不需要 sticky,作为 flex 子元素自然在底部 */}
|
||||
<div className="border-t border-border bg-card p-4 lg:p-6 flex-shrink-0">
|
||||
{/* 功能选择器 */}
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ModeSelector mode={chatMode} onModeChange={setChatMode} />
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<KnowledgeSelector
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onBasesChange={setSelectedKnowledgeBases}
|
||||
systemKnowledgeBases={systemKnowledgeBases}
|
||||
userKnowledgeBases={userKnowledgeBases}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3 flex-shrink-0"
|
||||
title="上传附件"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输入框和发送按钮 */}
|
||||
<div className="flex items-end space-x-3">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
placeholder="输入您的问题..."
|
||||
disabled={isLoading || isStreaming}
|
||||
className={cn(
|
||||
"min-h-[48px] resize-none touch-manipulation pr-12 rounded-xl",
|
||||
"border-border/50 bg-background/80 backdrop-blur-sm",
|
||||
"focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50",
|
||||
"transition-all duration-200"
|
||||
)}
|
||||
/>
|
||||
{isStreaming && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputMessage.trim() || isLoading || isStreaming}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-12 w-12 flex-shrink-0 touch-manipulation rounded-xl",
|
||||
"bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700",
|
||||
"shadow-lg hover:shadow-xl transition-all duration-200",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLoading || isStreaming ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 输入提示 */}
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="hidden sm:block">按 Enter 发送,Shift + Enter 换行</span>
|
||||
<span className="sm:hidden">点击发送按钮发送消息</span>
|
||||
{isStreaming && (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||
正在生成回复...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileText, FileJson, File } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ExportDialogProps {
|
||||
sessionId: number;
|
||||
sessionTitle: string;
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function ExportDialog({ sessionId, sessionTitle, children, onClose }: ExportDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [format, setFormat] = useState("json");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const { exportSession } = useChatStore();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportSession(sessionId, format);
|
||||
handleClose();
|
||||
toast.success("导出成功");
|
||||
} catch (error) {
|
||||
toast.error("导出失败");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatOptions = [
|
||||
{
|
||||
value: "json",
|
||||
label: "JSON 格式",
|
||||
description: "完整的结构化数据,包含所有元数据",
|
||||
icon: FileJson,
|
||||
},
|
||||
{
|
||||
value: "markdown",
|
||||
label: "Markdown 格式",
|
||||
description: "可读性好的文本格式,适合分享",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF 格式",
|
||||
description: "适合打印和正式文档",
|
||||
icon: File,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出会话</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出格式来下载 "{sessionTitle}" 会话
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<RadioGroup value={format} onValueChange={setFormat}>
|
||||
{formatOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div key={option.value} className="flex items-start space-x-3">
|
||||
<RadioGroupItem value={option.value} id={option.value} />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={option.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isExporting}>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronDown, Database, FileText, BookOpen, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 系统知识库图标映射
|
||||
const getSystemKnowledgeBaseIcon = (name: string) => {
|
||||
if (name.includes("法律法规")) return Scale;
|
||||
if (name.includes("原理")) return BookOpen;
|
||||
if (name.includes("案例")) return FileText;
|
||||
return Database;
|
||||
};
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
documentCount: number;
|
||||
enabled: boolean;
|
||||
isSystem?: boolean; // 是否为系统知识库
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
interface KnowledgeSelectorProps {
|
||||
selectedBases: string[];
|
||||
onBasesChange: (baseIds: string[]) => void;
|
||||
systemKnowledgeBases?: KnowledgeBase[]; // 系统知识库列表(从后端动态加载)
|
||||
userKnowledgeBases?: KnowledgeBase[]; // 用户的知识库列表
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function KnowledgeSelector({
|
||||
selectedBases,
|
||||
onBasesChange,
|
||||
systemKnowledgeBases = [],
|
||||
userKnowledgeBases = [],
|
||||
className
|
||||
}: KnowledgeSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// 为系统知识库添加图标(如果还没有的话)
|
||||
const systemBasesWithIcons = systemKnowledgeBases.map(kb => ({
|
||||
...kb,
|
||||
icon: kb.icon || getSystemKnowledgeBaseIcon(kb.name),
|
||||
isSystem: true,
|
||||
}));
|
||||
|
||||
// 合并系统知识库和用户知识库
|
||||
const allKnowledgeBases = [
|
||||
...systemBasesWithIcons,
|
||||
...userKnowledgeBases.map(kb => ({
|
||||
...kb,
|
||||
isSystem: false, // 用户知识库标记为非系统
|
||||
}))
|
||||
];
|
||||
|
||||
const enabledBases = allKnowledgeBases.filter(base => base.enabled);
|
||||
const selectedCount = selectedBases.length;
|
||||
const totalEnabled = enabledBases.length;
|
||||
|
||||
const handleToggleBase = (baseId: string) => {
|
||||
const newSelectedBases = selectedBases.includes(baseId)
|
||||
? selectedBases.filter(id => id !== baseId)
|
||||
: [...selectedBases, baseId];
|
||||
onBasesChange(newSelectedBases);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allEnabledIds = enabledBases.map(base => base.id);
|
||||
onBasesChange(allEnabledIds);
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onBasesChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9 px-3 text-sm font-medium",
|
||||
"border-border/50 bg-background/80 hover:bg-muted/50",
|
||||
"transition-all duration-200",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
<span className="hidden sm:inline">
|
||||
知识库 {selectedCount > 0 && `(${selectedCount})`}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
知识库 {selectedCount > 0 && `(${selectedCount})`}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
选择知识库
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* 快速操作 */}
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectNone}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* 系统知识库 */}
|
||||
{systemBasesWithIcons.length > 0 && (
|
||||
<div className="px-2 py-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 flex items-center">
|
||||
<Database className="w-3 h-3 mr-1" />
|
||||
系统知识库
|
||||
</div>
|
||||
{systemBasesWithIcons.map((base) => {
|
||||
const BaseIcon = base.icon || Database;
|
||||
const isSelected = selectedBases.includes(base.id);
|
||||
const isDisabled = !base.enabled;
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={base.id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleBase(base.id)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-3 cursor-pointer",
|
||||
isDisabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<BaseIcon className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-sm">{base.name}</span>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已选
|
||||
</Badge>
|
||||
)}
|
||||
{isDisabled && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{base.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{base.documentCount} 个文档
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户知识库 */}
|
||||
{userKnowledgeBases.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 flex items-center">
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
我的知识库
|
||||
</div>
|
||||
{userKnowledgeBases.map((base) => {
|
||||
const isSelected = selectedBases.includes(base.id);
|
||||
const isDisabled = !base.enabled;
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={base.id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleBase(base.id)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-3 cursor-pointer",
|
||||
isDisabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<BookOpen className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-sm">{base.name}</span>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已选
|
||||
</Badge>
|
||||
)}
|
||||
{isDisabled && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{base.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{base.documentCount} 个文档
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground text-center">
|
||||
已选择 {selectedCount} 个知识库
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { ChatMessage, ThinkingStep } from "@/types";
|
||||
import { User, Bot, MoreVertical, ThumbsUp, ThumbsDown, Copy, Edit, RotateCcw, Trash2, Loader2, CheckCircle2, FileSearch, Brain, Sparkles } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import SourceReferences from "./source-references";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
// 思考过程组件
|
||||
const ThinkingProcess = ({ thinking }: { thinking: ThinkingStep[] }) => {
|
||||
if (!thinking || thinking.length === 0) return null;
|
||||
|
||||
const getStageIcon = (stage: string) => {
|
||||
switch (stage) {
|
||||
case 'understanding': return <Brain className="h-4 w-4" />;
|
||||
case 'retrieving': return <FileSearch className="h-4 w-4 animate-spin" />;
|
||||
case 'retrieved': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'generating': return <Sparkles className="h-4 w-4 animate-pulse" />;
|
||||
default: return <Loader2 className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
|
||||
{thinking.map((step, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{getStageIcon(step.stage)}
|
||||
<span>{step.message}</span>
|
||||
{step.time && (
|
||||
<span className="text-xs">({step.time}s)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MessageItem({ message }: MessageItemProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isAssistant = message.role === "assistant";
|
||||
const { editMessage, regenerateMessage, feedbackMessage, isStreaming } = useChatStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(message.content);
|
||||
|
||||
// 添加调试信息
|
||||
console.log(`[DEBUG-MESSAGE] 渲染消息 ${message.id}:`, {
|
||||
role: message.role,
|
||||
contentLength: message.content.length,
|
||||
contentPreview: message.content.substring(0, 50) + "..."
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
toast.success("已复制到剪贴板");
|
||||
} catch (error) {
|
||||
toast.error("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
try {
|
||||
await editMessage(message.id, editContent);
|
||||
setIsEditing(false);
|
||||
toast.success("消息已更新");
|
||||
} catch (error) {
|
||||
toast.error("编辑失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditContent(message.content);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
try {
|
||||
await regenerateMessage(message.id);
|
||||
toast.success("正在重新生成回复");
|
||||
} catch (error) {
|
||||
toast.error("重新生成失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedback = async (feedback: "like" | "dislike") => {
|
||||
try {
|
||||
await feedbackMessage(message.id, feedback);
|
||||
toast.success("感谢您的反馈");
|
||||
} catch (error) {
|
||||
toast.error("反馈提交失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 p-4 ${isAssistant ? 'justify-start' : 'justify-end'}`}>
|
||||
{isAssistant && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-blue-800 rounded-lg flex items-center justify-center shadow-sm">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 max-w-[80%] ${isAssistant ? '' : 'flex justify-end'}`}>
|
||||
<div className={`mit-card ${isAssistant ? 'bg-card' : 'bg-primary text-primary-foreground'}`}>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full p-2 border rounded resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSaveEdit}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCancelEdit}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 思考过程组件 */}
|
||||
{isAssistant && message.thinking && (
|
||||
<ThinkingProcess thinking={message.thinking} />
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={tomorrow}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-md"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse border border-border">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border bg-muted px-3 py-2 text-left font-medium">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-3 py-2">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* 知识来源 */}
|
||||
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<SourceReferences sources={message.metadata.sources} maxSources={5} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={`flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
isAssistant ? "flex-row" : "flex-row-reverse"
|
||||
}`}>
|
||||
{/* 复制按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="h-7 w-7 p-0 hover:bg-muted/50"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* 用户消息操作 */}
|
||||
{isUser && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleEdit}
|
||||
className="h-7 w-7 p-0 hover:bg-muted/50"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 助手消息操作 */}
|
||||
{isAssistant && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRegenerate}
|
||||
className="h-7 w-7 p-0 hover:bg-muted/50"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* 反馈按钮 */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleFeedback("like")}
|
||||
className={`h-7 w-7 p-0 hover:bg-muted/50 ${
|
||||
message.feedback === "like" ? "text-green-600" : ""
|
||||
}`}
|
||||
>
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleFeedback("dislike")}
|
||||
className={`h-7 w-7 p-0 hover:bg-muted/50 ${
|
||||
message.feedback === "dislike" ? "text-red-600" : ""
|
||||
}`}
|
||||
>
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 时间戳和编辑标记 */}
|
||||
<div className={`text-xs text-muted-foreground mt-1 ${
|
||||
isUser ? "text-right" : "text-left"
|
||||
}`}>
|
||||
{message.created_at ? formatDistanceToNow(
|
||||
new Date(new Date(message.created_at).getTime() + 8 * 60 * 60 * 1000),
|
||||
{
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
}) : '未知时间'}
|
||||
{message.edited && (
|
||||
<span className="ml-1 text-muted-foreground">(已编辑)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAssistant && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-gray-600 to-gray-800 rounded-lg flex items-center justify-center shadow-sm">
|
||||
<User className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ChatMessage } from "@/types";
|
||||
import MessageItem from "./message-item";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export default function MessageList({ messages }: MessageListProps) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, MessageSquare, Database } from "lucide-react";
|
||||
|
||||
export type ChatMode = "normal" | "rag";
|
||||
|
||||
interface ModeSelectorProps {
|
||||
mode: ChatMode;
|
||||
onModeChange: (mode: ChatMode) => void;
|
||||
}
|
||||
|
||||
const modeConfig = {
|
||||
normal: {
|
||||
label: "普通对话",
|
||||
icon: MessageSquare,
|
||||
description: "基础AI对话"
|
||||
},
|
||||
rag: {
|
||||
label: "知识库检索",
|
||||
icon: Database,
|
||||
description: "基于国土空间规划知识库"
|
||||
}
|
||||
};
|
||||
|
||||
export default function ModeSelector({ mode, onModeChange }: ModeSelectorProps) {
|
||||
const currentMode = modeConfig[mode];
|
||||
const Icon = currentMode.icon;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{currentMode.label}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.entries(modeConfig).map(([key, config]) => {
|
||||
const ModeIcon = config.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => onModeChange(key as ChatMode)}
|
||||
className="gap-2"
|
||||
>
|
||||
<ModeIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronDown, Cpu, Zap, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
provider: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const models: ModelOption[] = [
|
||||
{
|
||||
id: "deepseek-ai/DeepSeek-V3",
|
||||
name: "DeepSeek-V3",
|
||||
description: "DeepSeek 最新版本,强大的推理能力",
|
||||
provider: "DeepSeek",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: "Qwen/QwQ-32B",
|
||||
name: "QwQ-32B",
|
||||
description: "Qwen 量子化模型,高效推理",
|
||||
provider: "Qwen",
|
||||
icon: Zap,
|
||||
},
|
||||
];
|
||||
|
||||
export default function ModelSelector({
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
className
|
||||
}: ModelSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const selectedModelData = models.find(model => model.id === selectedModel) || models[0];
|
||||
const IconComponent = selectedModelData.icon || Cpu;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9 px-3 text-sm font-medium",
|
||||
"border-border/50 bg-background/80 hover:bg-muted/50",
|
||||
"transition-all duration-200",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconComponent className="w-4 h-4 mr-2" />
|
||||
<span className="hidden sm:inline">{selectedModelData.name}</span>
|
||||
<span className="sm:hidden">{selectedModelData.name.split(' ')[0]}</span>
|
||||
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
选择AI模型
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{models.map((model) => {
|
||||
const ModelIcon = model.icon || Cpu;
|
||||
const isSelected = model.id === selectedModel;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
onModelChange(model.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-3 cursor-pointer",
|
||||
isSelected && "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<ModelIcon className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-sm">{model.name}</span>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已选择
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{model.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.provider}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface QuickQuestionsProps {
|
||||
onSelect: (question: string) => void;
|
||||
}
|
||||
|
||||
const quickQuestions = [
|
||||
"什么是国土空间规划?",
|
||||
"国土空间规划的主要原则是什么?",
|
||||
"如何进行国土空间规划编制?",
|
||||
"国土空间规划与城市规划的区别?",
|
||||
"国土空间规划中的三区三线是什么?",
|
||||
"如何评价国土空间规划的合理性?",
|
||||
];
|
||||
|
||||
export default function QuickQuestions({ onSelect }: QuickQuestionsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
快速开始,选择一个问题:
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-4xl mx-auto">
|
||||
{quickQuestions.map((question, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-left h-auto p-4 hover:bg-muted/50 transition-colors"
|
||||
onClick={() => onSelect(question)}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-3 flex-shrink-0" />
|
||||
<span className="text-sm leading-relaxed">{question}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Menu,
|
||||
X,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Download,
|
||||
MoreVertical,
|
||||
Search,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import ExportDialog from "./export-dialog";
|
||||
|
||||
export default function Sidebar() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState<number | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [deleteSessionId, setDeleteSessionId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
const { sessions, currentSession, selectSession, createSession, renameSession, deleteSession } = useChatStore();
|
||||
|
||||
// 过滤会话(按创建时间排序)
|
||||
const filteredSessions = () => {
|
||||
if (!searchQuery) {
|
||||
return sessions.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
}
|
||||
|
||||
return sessions
|
||||
.filter(session =>
|
||||
session.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const newSession = await createSession("新对话");
|
||||
if (newSession) {
|
||||
selectSession(newSession.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = async (sessionId: number) => {
|
||||
await selectSession(sessionId);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleRenameSession = (sessionId: number, currentTitle: string) => {
|
||||
setEditingSession(sessionId);
|
||||
setEditTitle(currentTitle);
|
||||
};
|
||||
|
||||
const handleSaveRename = async () => {
|
||||
if (editingSession && editTitle.trim()) {
|
||||
try {
|
||||
await renameSession(editingSession, editTitle.trim());
|
||||
setEditingSession(null);
|
||||
setEditTitle("");
|
||||
toast.success("会话重命名成功");
|
||||
} catch (error) {
|
||||
toast.error("重命名失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setEditingSession(null);
|
||||
setEditTitle("");
|
||||
};
|
||||
|
||||
const handleDeleteSession = (sessionId: number) => {
|
||||
setDeleteSessionId(sessionId);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deleteSessionId) {
|
||||
try {
|
||||
await deleteSession(deleteSessionId);
|
||||
setDeleteSessionId(null);
|
||||
toast.success("会话已删除");
|
||||
} catch (error) {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteSessionId(null);
|
||||
};
|
||||
|
||||
const renderSessionItem = (session: any) => {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`group relative flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all hover:bg-muted/50 ${
|
||||
currentSession?.id === session.id ? 'bg-muted border border-border' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{session.created_at ? (() => {
|
||||
const rawDate = new Date(session.created_at);
|
||||
const date = new Date(rawDate.getTime() + 8 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
if (date >= today) {
|
||||
// 今天:只显示时间
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Shanghai' });
|
||||
} else {
|
||||
// 昨天及更早:显示日期+时间
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Shanghai'
|
||||
});
|
||||
}
|
||||
})() : '未知时间'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<DropdownMenu
|
||||
open={openDropdownId === session.id}
|
||||
onOpenChange={(open) => setOpenDropdownId(open ? session.id : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
handleRenameSession(session.id, session.title);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<ExportDialog
|
||||
sessionId={session.id}
|
||||
sessionTitle={session.title}
|
||||
onClose={() => setOpenDropdownId(null)}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
</DropdownMenuItem>
|
||||
</ExportDialog>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
handleDeleteSession(session.id);
|
||||
}}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端菜单按钮 */}
|
||||
<div className="lg:hidden fixed top-4 left-4 z-50">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40 w-80 max-w-[85vw] bg-card/50 backdrop-blur-sm border-r border-border transform transition-transform duration-300 ease-in-out mobile-safe-area lg:h-full
|
||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 头部 */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">对话历史</h2>
|
||||
<Button
|
||||
onClick={handleNewChat}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<div className="py-4">
|
||||
<div className="space-y-1">
|
||||
{filteredSessions().map(renderSessionItem)}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<MessageSquare className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-2">还没有对话记录</p>
|
||||
<p className="text-xs text-muted-foreground">开始一个新的对话吧</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端遮罩 */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-30"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
<Dialog open={!!editingSession} onOpenChange={handleCancelRename}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名会话</DialogTitle>
|
||||
<DialogDescription>
|
||||
为这个会话输入一个新的名称
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="输入新的会话名称"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && editTitle.trim()) {
|
||||
handleSaveRename();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelRename}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveRename} disabled={!editTitle.trim()}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={!!deleteSessionId} onOpenChange={() => setDeleteSessionId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除会话</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除这个会话吗?此操作无法撤销,会话中的所有消息都将被删除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelDelete}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, ExternalLink, Star, Database, Globe } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface SourceReference {
|
||||
title: string;
|
||||
filename?: string;
|
||||
page?: number;
|
||||
score?: number;
|
||||
preview: string;
|
||||
url?: string;
|
||||
source_type?: "web" | "rag";
|
||||
}
|
||||
|
||||
interface SourceReferencesProps {
|
||||
sources: SourceReference[];
|
||||
maxSources?: number;
|
||||
}
|
||||
|
||||
export default function SourceReferences({ sources, maxSources = 5 }: SourceReferencesProps) {
|
||||
if (!sources || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaySources = sources.slice(0, maxSources);
|
||||
const ragSources = displaySources.filter(s => s.source_type !== "web");
|
||||
const webSources = displaySources.filter(s => s.source_type === "web");
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{ragSources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<Database className="h-4 w-4" />
|
||||
<span>知识库来源 ({ragSources.length})</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{ragSources.map((source, index) => (
|
||||
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{source.title}
|
||||
</CardTitle>
|
||||
{source.score != null && source.score > 0 && source.score < 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Star className="h-3 w-3 text-yellow-500" />
|
||||
<span className="text-xs text-gray-500">
|
||||
{(source.score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{source.filename}
|
||||
{source.page && ` • 第 ${source.page} 页`}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{source.preview}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>网络来源 ({webSources.length})</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{webSources.map((source, index) => (
|
||||
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{source.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-xs text-gray-600 line-clamp-2 mb-2">
|
||||
{source.preview}
|
||||
</p>
|
||||
{source.url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => window.open(source.url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
查看原文
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length > maxSources && (
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
还有 {sources.length - maxSources} 个相关来源
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user