Merge branch 'PingChuan' into shiy-temp

This commit is contained in:
2025-06-04 11:45:59 +08:00
36 changed files with 21858 additions and 15670 deletions
+256
View File
@@ -0,0 +1,256 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd';
import { SendOutlined, StopOutlined, PaperClipOutlined, PictureOutlined } from '@ant-design/icons';
import type { VisionFile } from '../../types/dify_chat';
import '../../styles/components/chat-with-llm/chat-input.css';
const { TextArea } = Input;
interface ChatInputProps {
onSendMessage: (message: string, files?: VisionFile[]) => void;
disabled?: boolean;
placeholder?: string;
onStop?: () => void;
isResponding?: boolean;
visionConfig?: {
enabled: boolean;
number_limits?: number;
image_file_size_limit?: number;
transfer_methods?: string[];
};
}
/**
* 聊天输入组件
*/
export default function ChatInput({
onSendMessage,
disabled = false,
placeholder = '输入消息...',
onStop,
isResponding = false,
visionConfig,
}: ChatInputProps) {
const [message, setMessage] = useState('');
const [files, setFiles] = useState<VisionFile[]>([]);
const textareaRef = useRef<any>(null);
const isComposing = useRef(false);
/**
* 提交消息
*/
const handleSubmit = () => {
if (!message.trim() || disabled) return;
onSendMessage(message, files.length > 0 ? files : undefined);
setMessage('');
setFiles([]);
// 聚焦回输入框
setTimeout(() => {
textareaRef.current?.focus();
}, 10);
};
/**
* 处理键盘事件
*/
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// 处理输入法状态
if (e.nativeEvent.isComposing) {
isComposing.current = true;
return;
}
// Enter发送,Shift+Enter换行
if (e.key === 'Enter' && !e.shiftKey && !isComposing.current) {
e.preventDefault();
handleSubmit();
}
};
/**
* 处理输入法结束
*/
const handleCompositionEnd = () => {
isComposing.current = false;
};
/**
* 停止响应
*/
const handleStop = () => {
onStop?.();
};
/**
* 处理文件上传
*/
const handleFileUpload = (file: File) => {
// 检查文件数量限制
if (visionConfig?.number_limits && files.length >= visionConfig.number_limits) {
antdMessage.error(`最多只能上传 ${visionConfig.number_limits} 个文件`);
return false;
}
// 检查文件大小限制
if (visionConfig?.image_file_size_limit) {
const limitMB = visionConfig.image_file_size_limit;
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > limitMB) {
antdMessage.error(`文件大小不能超过 ${limitMB}MB`);
return false;
}
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
antdMessage.error('只支持图片文件');
return false;
}
// 创建文件对象
const reader = new FileReader();
reader.onload = (e) => {
const newFile: VisionFile = {
id: `file-${Date.now()}-${Math.random()}`,
type: 'image',
transfer_method: 'local_file' as any,
url: e.target?.result as string,
upload_file_id: '',
};
setFiles(prev => [...prev, newFile]);
};
reader.readAsDataURL(file);
return false; // 阻止默认上传行为
};
/**
* 移除文件
*/
const handleRemoveFile = (fileId: string) => {
setFiles(prev => prev.filter(file => file.id !== fileId));
};
/**
* 渲染文件预览
*/
const renderFilePreview = () => {
if (files.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 mb-2 p-2 bg-gray-50 rounded">
{files.map((file) => (
<div key={file.id} className="relative group">
<img
src={file.url}
alt="预览"
className="w-16 h-16 object-cover rounded border"
/>
<Button
type="text"
size="small"
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleRemoveFile(file.id!)}
>
×
</Button>
</div>
))}
</div>
);
};
/**
* 渲染上传按钮
*/
const renderUploadButton = () => {
if (!visionConfig?.enabled) return null;
const isDisabled = disabled || (visionConfig.number_limits ? files.length >= visionConfig.number_limits : false);
return (
<Upload
beforeUpload={handleFileUpload}
showUploadList={false}
accept="image/*"
multiple={false}
>
<Tooltip title="上传图片">
<Button
type="text"
icon={<PictureOutlined />}
size="small"
disabled={isDisabled}
className="text-gray-500 hover:text-blue-500"
/>
</Tooltip>
</Upload>
);
};
return (
<div className="border-t border-gray-200 bg-white p-4">
<div className="max-w-4xl mx-auto">
{/* 文件预览 */}
{renderFilePreview()}
{/* 输入区域 */}
<div className="relative">
<div className="flex items-end gap-2">
{/* 上传按钮 */}
<div className="flex items-end pb-1">
{renderUploadButton()}
</div>
{/* 文本输入 */}
<div className="flex-1">
<TextArea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionEnd={handleCompositionEnd}
placeholder={placeholder}
disabled={disabled}
autoSize={{ minRows: 1, maxRows: 6 }}
className="resize-none"
style={{ paddingRight: '50px' }}
/>
</div>
{/* 发送/停止按钮 */}
<div className="flex items-end pb-1">
{isResponding ? (
<Tooltip title="停止生成">
<Button
type="primary"
danger
icon={<StopOutlined />}
onClick={handleStop}
disabled={disabled}
size="large"
/>
</Tooltip>
) : (
<Tooltip title="发送消息 (Enter)">
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
disabled={disabled || !message.trim()}
size="large"
/>
</Tooltip>
)}
</div>
</div>
{/* 字符计数和提示 */}
</div>
</div>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { Button, Tooltip, Avatar, Card, Image, Spin } from 'antd';
import { AntDesignOutlined, SmileTwoTone, RobotOutlined } from '@ant-design/icons';
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '../../types/dify_chat';
import { CHAT_CONFIG } from '../../config/chat';
import Markdown from './markdown';
import ThoughtProcess from './thought-process';
import { Dayjs } from 'dayjs';
import '../../styles/components/chat-with-llm/chat-message.css';
interface ChatMessageProps {
message: ChatItem;
onFeedback?: (messageId: string, feedback: Feedbacktype) => void;
isResponding?: boolean;
onRegenerate?: (messageId: string) => void;
}
/**
* 聊天消息组件
*/
export default function ChatMessage({
message,
onFeedback,
isResponding = false,
onRegenerate
}: ChatMessageProps) {
const [feedback, setFeedback] = useState<'like' | 'dislike' | null>(
message.feedback?.rating || null
);
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more } = message;
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
/**
* 处理反馈
*/
const handleFeedback = async (type: 'like' | 'dislike') => {
if (!id || id.startsWith('placeholder-') || id.startsWith('question-') || id.startsWith('opening-'))
return;
// 如果已经选择了相同的反馈,则取消选择
const newFeedback = feedback === type ? null : type;
setFeedback(newFeedback);
await onFeedback?.(id, {
rating: newFeedback,
});
};
/**
* 渲染AI回答内容
*/
const renderAnswerContent = () => {
// console.log('🎨 渲染AI回答内容:', { content, isResponding, isAgentMode });
// 如果正在响应且没有内容
if (isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content)) {
return (
<div className="responding-indicator">
<Spin size="small" />
<span>AI ...</span>
</div>
);
}
// Agent模式(有思考过程)
if (isAgentMode) {
return (
<div className="space-y-3">
{agent_thoughts?.map((thought, index) => (
<div key={index}>
{thought.thought && (
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
<Markdown content={thought.thought} />
</div>
)}
{thought.tool && (
<ThoughtProcess
thought={thought}
isFinished={!!thought.observation || !isResponding}
/>
)}
</div>
))}
</div>
);
}
// 普通模式 - 恢复Markdown渲染
return (
<div>
<div className={isResponding ? 'streaming-text' : ''}>
<Markdown content={content} />
</div>
</div>
);
};
/**
* 渲染建议问题
*/
const renderSuggestedQuestions = () => {
if (!suggestedQuestions || suggestedQuestions.length === 0) return null;
return (
<div className="suggested-questions">
<div className="text-sm text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{suggestedQuestions.map((question, index) => (
<Button
key={index}
size="small"
type="dashed"
className="question-button text-left"
onClick={() => {
// 这里可以添加点击建议问题的处理逻辑
console.log('Suggested question clicked:', question);
}}
>
{question}
</Button>
))}
</div>
</div>
);
};
/**
* 渲染消息元信息
*/
const renderMessageMeta = () => {
if (!more) return null;
return (
<div className="message-timestamp">
{more.time && <span>: {more.time}</span>}
{more.tokens && <span className="ml-2">Token: {more.tokens}</span>}
{more.latency && <span className="ml-2">: {more.latency}</span>}
</div>
);
};
// 如果是开场白,特殊处理
if (isOpeningStatement) {
return (
<div className="chat-message">
<Card className="message-card assistant" size="small">
<div className="flex items-start gap-3">
{/* <Avatar icon={<RobotOutlined />} className="flex-shrink-0" />
*/}
<Avatar
size={{ xs: 24, sm: 32, md: 40, lg: 64, xl: 80, xxl: 100 }}
icon={<RobotOutlined />}
/>
<div className="flex-1">
<Markdown content={content} />
{renderSuggestedQuestions()}
</div>
</div>
</Card>
</div>
);
}
return (
<div className="chat-message">
<div className={`flex ${isAnswer ? 'justify-start' : 'justify-end'} mb-2`}>
<div className={`message-card ${isAnswer ? 'assistant' : 'user'}`}>
<Card size="small" className="shadow-sm">
<div className="flex items-start gap-2">
{/* 头像 */}
<Avatar
icon={isAnswer ? <RobotOutlined /> : <SmileTwoTone />}
className="flex-shrink-0"
style={{
backgroundColor: isAnswer ? '#1890ff' : '#52c41a'
}}
/>
{/* 消息内容 */}
<div className="flex-1 min-w-0">
{isAnswer ? renderAnswerContent() : (
<div>
<Markdown content={content} />
{/* {renderImages(message_files)} */}
</div>
)}
{/* 建议问题 */}
{/* {isAnswer && renderSuggestedQuestions()} */}
{/* 消息元信息 */}
{/* {renderMessageMeta()} */}
{/* 反馈按钮 */}
{/* {isAnswer && (
<div className="feedback-buttons">
{renderFeedbackButtons()}
</div>
)} */}
</div>
</div>
</Card>
</div>
</div>
</div>
);
}
+571
View File
@@ -0,0 +1,571 @@
import { useState, useEffect, useRef } from 'react';
import { produce } from 'immer';
import { useBoolean, useGetState } from 'ahooks';
import { Layout, theme } from 'antd';
import ChatMessage from './chat-message';
import ChatInput from './chat-input';
import ChatSidebar, { type ChatSidebarRef } from './sidebar';
// import Header from '../layout/Header';
import useConversation from '../../hooks/use-conversation';
import useChatMessage from '../../hooks/use-chat-message';
import type { ChatItem, ConversationItem } from '../../types/dify_chat';
import { CHAT_CONFIG } from '../../config/chat';
import { fetchConversations, fetchAppParams, fetchChatList } from '../../services/api.client';
import '../../styles/components/chat-with-llm/index.css';
const { Content } = Layout;
/**
* 主聊天组件
* 实现单页面应用模式,参考webapp-conversation的初始化逻辑
*/
export default function Chat() {
// 侧边栏状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
// 会话管理
const {
conversationList,
setConversationList,
currConversationId,
getCurrConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
currInputs,
newConversationInputs,
resetNewConversationInputs,
setCurrInputs,
currConversationInfo,
setNewConversationInfo,
setExistConversationInfo,
addConversationToList,
updateConversationInList,
removeConversationFromList,
} = useConversation();
// 消息管理
const {
chatList,
setChatList,
getChatList,
isResponding,
handleSend,
stopResponding,
handleFeedback,
} = useChatMessage({
onUpdateConversationList: updateConversationInList,
onConversationIdChange: async (conversationId: string) => {
console.log('🔄 收到会话ID变更通知:', conversationId);
// 设置当前会话ID(这会触发localStorage更新)
setCurrConversationId(conversationId, CHAT_CONFIG.APP_ID);
// 如果是新会话,添加到会话列表
const existingConversation = conversationList.find(item => item.id === conversationId);
if (!existingConversation) {
console.log('🆕 添加新会话到列表:', conversationId);
const newConversation = {
id: conversationId,
name: '新对话',
inputs: currInputs || {},
introduction: '',
};
addConversationToList(newConversation);
// 检查是否需要自动重命名(新对话的第一条消息)
if (!newConversationFirstMessageSent.has(conversationId)) {
console.log('🏷️ 新对话第一条消息,准备自动重命名:', conversationId);
// 标记该对话已发送第一条消息
setNewConversationFirstMessageSent(prev => new Set(prev).add(conversationId));
// 延迟一下确保组件状态已更新
setTimeout(async () => {
try {
if (sidebarRef.current) {
await sidebarRef.current.autoRename(conversationId);
console.log('✅ 新对话自动重命名完成:', conversationId);
} else {
console.warn('⚠️ 侧边栏引用不可用,无法自动重命名');
}
} catch (error) {
console.error('❌ 新对话自动重命名失败:', error);
}
}, 1000);
}
}
},
});
// 应用状态
const [appUnavailable, setAppUnavailable] = useState<boolean>(false);
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false);
const [inited, setInited] = useState<boolean>(false);
const [promptConfig, setPromptConfig] = useState<any>(null);
// 会话状态管理
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false);
const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false);
// 聊天列表容器引用
const chatListDomRef = useRef<HTMLDivElement>(null);
// 侧边栏组件引用
const sidebarRef = useRef<ChatSidebarRef>(null);
// 跟踪新对话是否已发送第一条消息
const [newConversationFirstMessageSent, setNewConversationFirstMessageSent] = useState<Set<string>>(new Set());
// 检查应用配置
const hasSetAppConfig = CHAT_CONFIG.APP_ID && CHAT_CONFIG.API_KEY;
/**
* 处理开始聊天
*/
const handleStartChat = (inputs: Record<string, any>) => {
createNewChat();
setConversationIdChangeBecauseOfNew(true);
setCurrInputs(inputs);
setChatStarted();
// 解析变量并生成开场白
setChatList(generateNewChatListWithOpenStatement('', inputs));
};
/**
* 创建新聊天
*/
const createNewChat = () => {
setChatList([]);
setChatNotStarted();
resetNewConversationInputs();
};
/**
* 生成带开场白的新聊天列表
*/
const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null): ChatItem[] => {
const newChatList: ChatItem[] = [];
if (introduction) {
newChatList.push({
id: `opening-statement-${Date.now()}`,
content: introduction,
isAnswer: true,
isOpeningStatement: true,
});
}
return newChatList;
};
/**
* 处理会话切换
*/
const handleConversationSwitch = async () => {
if (!inited) {
return;
}
console.log('🔄 处理会话切换:', { currConversationId, isNewConversation });
// 更新当前会话的输入
let notSyncToStateIntroduction = '';
let notSyncToStateInputs: Record<string, any> | undefined | null = {};
if (!isNewConversation) {
const item = conversationList.find(item => item.id === currConversationId);
notSyncToStateInputs = item?.inputs || {};
setCurrInputs(notSyncToStateInputs as any);
notSyncToStateIntroduction = item?.introduction || '';
setExistConversationInfo({
name: item?.name || '',
introduction: notSyncToStateIntroduction,
});
} else {
notSyncToStateInputs = newConversationInputs;
setCurrInputs(notSyncToStateInputs);
}
// 更新当前会话的聊天列表
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) {
try {
console.log('📨 获取会话历史消息:', currConversationId);
// 调用API获取历史消息
const response = await fetchChatList(currConversationId);
console.log('📋 历史消息响应:', response);
if (response && (response as any).data) {
const { data: historyMessages } = response as any;
// 生成新的聊天列表,包含开场白
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
// 添加历史消息
historyMessages.forEach((item: any) => {
// 添加用户问题
newChatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
});
// 添加AI回答
newChatList.push({
id: item.id,
content: item.answer,
agent_thoughts: item.agent_thoughts || [],
feedback: item.feedback,
isAnswer: true,
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
});
});
console.log('✅ 设置历史聊天列表:', newChatList.length, '条消息');
setChatList(newChatList);
} else {
console.warn('⚠️ 获取历史消息失败或无数据');
// 如果获取失败,至少显示开场白
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
setChatList(newChatList);
}
} catch (error) {
console.error('❌ 获取历史消息失败:', error);
// 如果获取失败,至少显示开场白
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
setChatList(newChatList);
}
}
if (isNewConversation && isChatStarted) {
setChatList(generateNewChatListWithOpenStatement());
}
};
/**
* 处理会话ID变化
*/
const handleConversationIdChange = (id: string) => {
console.log('🔄 会话ID变化:', { id, currentId: currConversationId });
if (id === '-1') {
createNewChat();
setConversationIdChangeBecauseOfNew(true);
} else {
setConversationIdChangeBecauseOfNew(false);
}
// 触发会话切换
setCurrConversationId(id, CHAT_CONFIG.APP_ID);
};
/**
* 检查是否可以发送消息
*/
const checkCanSend = () => {
if (currConversationId !== '-1') {
return true;
}
if (!currInputs || !promptConfig?.prompt_variables) {
return true;
}
const inputLens = Object.values(currInputs).length;
const promptVariablesLens = promptConfig.prompt_variables.length;
const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v);
if (emptyInput) {
console.error('必填变量值不能为空');
return false;
}
return true;
};
/**
* 处理发送消息
*/
const handleSendMessage = async (message: string, files?: any[]) => {
if (isResponding) {
console.log('正在响应中,请等待...');
return;
}
if (!checkCanSend()) {
return;
}
// console.log('📤 发送消息:', { message, conversationId: currConversationId });
try {
// 准备输入数据
const toServerInputs: Record<string, any> = {};
if (currInputs) {
Object.keys(currInputs).forEach((key) => {
toServerInputs[key] = currInputs[key];
});
}
// 使用 useChatMessage 钩子的 handleSend 方法
await handleSend(
message,
isNewConversation ? null : currConversationId,
files,
toServerInputs
);
} catch (error) {
console.error('发送消息失败:', error);
}
};
/**
* 处理侧边栏切换
*/
const handleSidebarToggle = () => {
setSidebarCollapsed(!sidebarCollapsed);
};
/**
* 处理会话选择
*/
const handleConversationSelect = (conversationId: string) => {
if (conversationId !== currConversationId) {
setCurrConversationId(conversationId, CHAT_CONFIG.APP_ID);
}
};
/**
* 处理新建会话
*/
const handleNewConversation = () => {
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false);
createNewChat();
};
/**
* 处理会话删除后的状态更新
*/
const handleConversationDeleted = (conversationId: string) => {
console.log('🗑️ 处理会话删除后的状态更新:', conversationId);
// 如果删除的是当前会话,切换到新会话
if (conversationId === currConversationId) {
handleNewConversation();
}
// 从列表中移除会话
removeConversationFromList(conversationId);
console.log('✅ 会话删除状态更新完成:', conversationId);
};
/**
* 处理会话重命名后的状态更新
*/
const handleConversationRenamed = (conversationId: string, newName: string) => {
console.log('✏️ 处理会话重命名后的状态更新:', { conversationId, newName });
// 更新本地会话列表中的名称
updateConversationInList(conversationId, { name: newName });
console.log('✅ 会话重命名状态更新完成:', conversationId, '->', newName);
};
/**
* 组件初始化 - 参考webapp-conversation的逻辑
*/
useEffect(() => {
if (!hasSetAppConfig) {
console.error('应用配置不完整');
setAppUnavailable(true);
return;
}
(async () => {
try {
console.log('🚀 开始初始化聊天应用...');
// 并行获取会话列表和应用参数
const [conversationData, appParams] = await Promise.all([
fetchConversations(),
fetchAppParams()
]);
console.log('📋 获取到的数据:', { conversationData, appParams });
// 处理会话数据
const conversations = (conversationData as any).data || [];
if ((conversationData as any).error) {
console.error('获取会话列表失败:', (conversationData as any).error);
throw new Error((conversationData as any).error);
}
// 处理当前会话ID
const _conversationId = getConversationIdFromStorage(CHAT_CONFIG.APP_ID);
const isNotNewConversation = conversations.some((item: ConversationItem) => item.id === _conversationId);
console.log('💾 本地存储的会话ID:', _conversationId);
console.log('🔍 是否为已存在的会话:', isNotNewConversation);
// 获取新会话信息
const { user_input_form, opening_statement: introduction } = (appParams as any).data || {};
setNewConversationInfo({
name: '新对话',
introduction: introduction || '',
});
// 设置提示配置
setPromptConfig({
prompt_template: '',
prompt_variables: user_input_form || [],
});
// 设置会话列表
setConversationList(conversations);
// 如果存在有效的会话ID,则设置为当前会话
if (isNotNewConversation) {
console.log('🎯 设置当前会话ID:', _conversationId);
setCurrConversationId(_conversationId, CHAT_CONFIG.APP_ID, false);
} else {
// 如果localStorage为空或会话不存在,自动创建新会话
console.log('🆕 localStorage为空或会话不存在,创建新会话');
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false);
}
setInited(true);
console.log('✅ 聊天应用初始化完成');
} catch (e: any) {
console.error('❌ 初始化失败:', e);
if (e.status === 404) {
setAppUnavailable(true);
} else {
setIsUnknownReason(true);
setAppUnavailable(true);
}
}
})();
}, []);
// 监听会话切换
useEffect(() => {
handleConversationSwitch();
}, [currConversationId, inited]);
// 自动滚动到底部
useEffect(() => {
if (chatListDomRef.current) {
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight;
}
}, [chatList]);
// 如果应用不可用,显示错误页面
if (appUnavailable) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-2"></h2>
<p className="text-gray-600">
{isUnknownReason ? '发生了未知错误,请稍后重试' : '应用配置不正确,请检查配置'}
</p>
</div>
</div>
);
}
// 如果未初始化完成,显示加载状态
if (!inited) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
// 判断是否已设置输入
const hasSetInputs = (() => {
if (!isNewConversation) {
return true;
}
return isChatStarted;
})();
const conversationName = currConversationInfo?.name || '新对话';
const conversationIntroduction = currConversationInfo?.introduction || '';
return (
<Layout style={{ height: '90vh' }}>
{/* 侧边栏 */}
<ChatSidebar
ref={sidebarRef}
collapsed={sidebarCollapsed}
onToggle={handleSidebarToggle}
conversations={conversationList}
currentConversationId={currConversationId}
onConversationSelect={handleConversationSelect}
onNewConversation={handleNewConversation}
onConversationDeleted={handleConversationDeleted}
onConversationRenamed={handleConversationRenamed}
/>
{/* 主内容区域 */}
<Layout>
<Content
style={{
background: colorBgContainer,
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* 聊天区域 */}
<div className="flex-[0.85] overflow-hidden">
<div
ref={chatListDomRef}
className="h-full overflow-y-auto px-4 py-4 space-y-2"
>
{/* 如果是新会话且未开始聊天,显示欢迎信息 */}
{isNewConversation && !isChatStarted && (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600"></p>
</div>
)}
{/* 聊天消息列表 */}
{chatList.map((item) => (
<ChatMessage
key={item.id}
message={item}
isResponding={isResponding && item.id === chatList[chatList.length - 1]?.id}
onFeedback={handleFeedback}
/>
))}
</div>
</div>
{/* 输入区域 */}
<div className="flex-[0.16] border-t border-gray-200 bg-white">
<ChatInput
onSendMessage={handleSendMessage}
disabled={isResponding}
placeholder="请输入您的问题..."
onStop={stopResponding}
isResponding={isResponding}
/>
</div>
</Content>
</Layout>
</Layout>
);
}
+242
View File
@@ -0,0 +1,242 @@
import React from 'react';
import { Typography } from 'antd';
import '../../styles/components/chat-with-llm/markdown.css';
const { Paragraph, Text, Title } = Typography;
interface MarkdownProps {
content: string;
className?: string;
}
/**
* Markdown 渲染组件
* 简化版本,支持基本的 Markdown 语法
*/
export default function Markdown({ content, className = '' }: MarkdownProps) {
if (!content) return null;
/**
* 简单的 Markdown 解析器
*/
const parseMarkdown = (text: string): React.ReactNode => {
// 分割成行
const lines = text.split('\n');
const elements: React.ReactNode[] = [];
let currentParagraph: string[] = [];
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let codeBlockLanguage = '';
const flushParagraph = () => {
if (currentParagraph.length > 0) {
const paragraphText = currentParagraph.join('\n');
elements.push(
<Paragraph key={elements.length} className={className}>
{parseInlineElements(paragraphText)}
</Paragraph>
);
currentParagraph = [];
}
};
const flushCodeBlock = () => {
if (codeBlockContent.length > 0) {
elements.push(
<pre
key={elements.length}
className="bg-gray-800 text-white p-3 rounded-md overflow-x-auto my-2"
>
<code className={codeBlockLanguage ? `language-${codeBlockLanguage}` : ''}>
{codeBlockContent.join('\n')}
</code>
</pre>
);
codeBlockContent = [];
codeBlockLanguage = '';
}
};
lines.forEach((line, index) => {
// 处理代码块
if (line.startsWith('```')) {
if (inCodeBlock) {
// 结束代码块
flushCodeBlock();
inCodeBlock = false;
} else {
// 开始代码块
flushParagraph();
inCodeBlock = true;
codeBlockLanguage = line.slice(3).trim();
}
return;
}
if (inCodeBlock) {
codeBlockContent.push(line);
return;
}
// 处理标题
if (line.startsWith('# ')) {
flushParagraph();
elements.push(
<Title key={elements.length} level={1} className={className}>
{parseInlineElements(line.slice(2))}
</Title>
);
return;
}
if (line.startsWith('## ')) {
flushParagraph();
elements.push(
<Title key={elements.length} level={2} className={className}>
{parseInlineElements(line.slice(3))}
</Title>
);
return;
}
if (line.startsWith('### ')) {
flushParagraph();
elements.push(
<Title key={elements.length} level={3} className={className}>
{parseInlineElements(line.slice(4))}
</Title>
);
return;
}
// 处理列表
if (line.startsWith('- ') || line.startsWith('* ')) {
flushParagraph();
elements.push(
<ul key={elements.length} className="list-disc list-inside my-2">
<li>{parseInlineElements(line.slice(2))}</li>
</ul>
);
return;
}
if (/^\d+\.\s/.test(line)) {
flushParagraph();
const match = line.match(/^\d+\.\s(.*)$/);
if (match) {
elements.push(
<ol key={elements.length} className="list-decimal list-inside my-2">
<li>{parseInlineElements(match[1])}</li>
</ol>
);
}
return;
}
// 处理空行
if (line.trim() === '') {
flushParagraph();
return;
}
// 普通段落
currentParagraph.push(line);
});
// 处理剩余内容
flushParagraph();
flushCodeBlock();
return elements;
};
/**
* 解析行内元素(粗体、斜体、代码、链接等)
*/
const parseInlineElements = (text: string): React.ReactNode => {
const parts: React.ReactNode[] = [];
let currentText = text;
let key = 0;
// 处理行内代码
currentText = currentText.replace(/`([^`]+)`/g, (match, code) => {
const placeholder = `__CODE_${key}__`;
parts.push(
<Text key={`code-${key}`} code className="bg-gray-100 px-1 rounded">
{code}
</Text>
);
key++;
return placeholder;
});
// 处理粗体
currentText = currentText.replace(/\*\*([^*]+)\*\*/g, (match, bold) => {
const placeholder = `__BOLD_${key}__`;
parts.push(
<Text key={`bold-${key}`} strong>
{bold}
</Text>
);
key++;
return placeholder;
});
// 处理斜体
currentText = currentText.replace(/\*([^*]+)\*/g, (match, italic) => {
const placeholder = `__ITALIC_${key}__`;
parts.push(
<Text key={`italic-${key}`} italic>
{italic}
</Text>
);
key++;
return placeholder;
});
// 处理链接
currentText = currentText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
const placeholder = `__LINK_${key}__`;
parts.push(
<a
key={`link-${key}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 underline"
>
{linkText}
</a>
);
key++;
return placeholder;
});
// 重新组装文本
const finalParts: React.ReactNode[] = [];
const textParts = currentText.split(/(__(?:CODE|BOLD|ITALIC|LINK)_\d+__)/);
textParts.forEach((part, index) => {
const match = part.match(/^__(\w+)_(\d+)__$/);
if (match) {
const [, type, partKey] = match;
const component = parts.find((p: any) =>
p.key === `${type.toLowerCase()}-${partKey}`
);
if (component) {
finalParts.push(component);
}
} else if (part) {
finalParts.push(part);
}
});
return finalParts.length === 1 ? finalParts[0] : finalParts;
};
return (
<div className={`markdown-content ${className}`}>
{parseMarkdown(content)}
</div>
);
}
+397
View File
@@ -0,0 +1,397 @@
import React, { useState, forwardRef, useImperativeHandle } from 'react';
import { Button, Layout, Menu, theme, Input, Tooltip, Dropdown, Modal, message } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
PlusOutlined,
SearchOutlined,
MoreOutlined,
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import type { ConversationItem } from '../../types/dify_chat';
import { deleteConversation, renameConversation } from '../../services/api.client';
import '../../styles/components/chat-with-llm/sidebar.css';
const { Sider } = Layout;
interface ChatSidebarProps {
collapsed: boolean;
onToggle: () => void;
conversations: ConversationItem[];
currentConversationId: string;
onConversationSelect: (conversationId: string) => void;
onNewConversation: () => void;
onConversationDeleted?: (conversationId: string) => void;
onConversationRenamed?: (conversationId: string, newName: string) => void;
}
// 暴露给父组件的方法接口
export interface ChatSidebarRef {
autoRename: (conversationId: string) => Promise<void>;
}
/**
* 聊天侧边栏组件
*/
const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
collapsed,
onToggle,
conversations,
currentConversationId,
onConversationSelect,
onNewConversation,
onConversationDeleted,
onConversationRenamed,
}, ref) => {
const [searchValue, setSearchValue] = useState('');
const [renameModalVisible, setRenameModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [renamingConversation, setRenamingConversation] = useState<ConversationItem | null>(null);
const [deletingConversation, setDeletingConversation] = useState<ConversationItem | null>(null);
const [newName, setNewName] = useState('');
const [renameLoading, setRenameLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
// 过滤会话列表
const filteredConversations = conversations.filter(conv =>
conv.name.toLowerCase().includes(searchValue.toLowerCase())
);
// 处理重命名
const handleRename = (conv: ConversationItem) => {
setRenamingConversation(conv);
setNewName(conv.name);
setRenameModalVisible(true);
};
// 处理删除会话 - 显示确认Modal
const handleDeleteClick = (conv: ConversationItem) => {
setDeletingConversation(conv);
setDeleteModalVisible(true);
};
// 确认删除会话
const handleDeleteConfirm = async () => {
if (!deletingConversation) return;
setDeleteLoading(true);
try {
console.log('🗑️ 开始删除会话:', deletingConversation.id);
// 调用API删除服务器端的会话
const response = await deleteConversation(deletingConversation.id);
console.log('✅ 服务器端会话删除响应:', response);
// 检查响应是否成功
if (response && (response as any).result === 'success') {
console.log('✅ 服务器端会话删除成功');
message.success('会话删除成功');
setDeleteModalVisible(false);
// 通知父组件会话已删除
onConversationDeleted?.(deletingConversation.id);
console.log('✅ 会话删除完成:', deletingConversation.id);
} else {
throw new Error((response as any)?.error || '删除会话失败');
}
} catch (error) {
console.error('❌ 删除会话失败:', error);
message.error(`删除会话失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setDeleteLoading(false);
}
};
// 取消删除
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setDeletingConversation(null);
setDeleteLoading(false);
};
// 确认重命名
const handleRenameConfirm = async () => {
if (!renamingConversation || !newName.trim()) {
message.error('请输入有效的会话名称');
return;
}
if (newName.trim() === renamingConversation.name) {
setRenameModalVisible(false);
return;
}
setRenameLoading(true);
try {
console.log('✏️ 开始重命名会话:', { conversationId: renamingConversation.id, newName: newName.trim() });
// 调用API重命名服务器端的会话
const response = await renameConversation(renamingConversation.id, newName.trim(), false);
console.log('✅ 服务器端会话重命名响应:', response);
// 检查响应是否成功
if (response && (response as any).name) {
console.log('✅ 服务器端会话重命名成功');
message.success('重命名成功');
setRenameModalVisible(false);
// 通知父组件会话已重命名
onConversationRenamed?.(renamingConversation.id, (response as any).name);
console.log('✅ 会话重命名完成:', renamingConversation.id, '->', (response as any).name);
} else {
throw new Error((response as any)?.error || '重命名会话失败');
}
} catch (error) {
console.error('❌ 重命名会话失败:', error);
message.error(`重命名失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setRenameLoading(false);
}
};
// 取消重命名
const handleRenameCancel = () => {
setRenameModalVisible(false);
setRenamingConversation(null);
setNewName('');
setRenameLoading(false);
};
// 生成菜单项
const menuItems = filteredConversations.map(conv => ({
key: conv.id,
icon: <MessageOutlined />,
label: (
<div className="flex items-center justify-between group">
<span className="truncate flex-1" title={conv.name}>
{conv.name}
</span>
{!collapsed && (
<Dropdown
menu={{
items: [
{
key: 'rename',
icon: <EditOutlined />,
label: '重命名',
onClick: () => handleRename(conv),
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: '删除',
danger: true,
onClick: () => handleDeleteClick(conv),
},
],
}}
trigger={['click']}
placement="bottomRight"
>
<Button
type="text"
size="small"
icon={<MoreOutlined />}
className="opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
)}
</div>
),
}));
useImperativeHandle(ref, () => ({
autoRename: async (conversationId: string) => {
try {
console.log('🏷️ 开始自动重命名会话为"新对话":', conversationId);
// 调用API将会话重命名为固定的"新对话"
const response = await renameConversation(conversationId, '新对话', false);
console.log('✅ 服务器端会话重命名响应:', response);
// 检查响应是否成功
if (response && (response as any).name) {
console.log('✅ 服务器端会话重命名成功');
// 通知父组件会话已重命名
onConversationRenamed?.(conversationId, (response as any).name);
console.log('✅ 会话重命名完成:', conversationId, '->', (response as any).name);
} else {
throw new Error((response as any)?.error || '重命名会话失败');
}
} catch (error) {
console.error('❌ 重命名会话失败:', error);
// 重命名失败时不显示错误消息,避免打扰用户
console.warn('⚠️ 重命名失败,会话将保持默认名称');
}
},
}));
return (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={280}
style={{
background: colorBgContainer,
borderRight: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* 侧边栏头部 - 固定在顶部 */}
<div className="p-4 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={onToggle}
style={{
fontSize: '16px',
width: 32,
height: 32,
color: 'rgb(0, 104, 74)',
}}
/>
{!collapsed && (
<Tooltip title="新建对话">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewConversation}
size="small"
>
</Button>
</Tooltip>
)}
</div>
{/* 搜索框 */}
{!collapsed && (
<Input
placeholder="搜索对话..."
prefix={<SearchOutlined />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
)}
</div>
{/* 会话列表 - 可滚动区域 */}
<div className="flex-1 overflow-hidden">
<div
className="h-full overflow-y-auto"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#c1c1c1 #f1f1f1',
}}
>
{!collapsed && filteredConversations.length === 0 && searchValue && (
<div className="p-4 text-center text-gray-500">
<MessageOutlined className="text-2xl mb-2" />
<p></p>
</div>
)}
{!collapsed && conversations.length === 0 && !searchValue && (
<div className="p-4 text-center text-gray-500">
<MessageOutlined className="text-2xl mb-2" />
<p></p>
<Button
type="link"
onClick={onNewConversation}
className="mt-2"
>
</Button>
</div>
)}
<Menu
mode="inline"
selectedKeys={[currentConversationId]}
items={menuItems}
onClick={({ key }) => onConversationSelect(key)}
style={{
border: 'none',
background: 'transparent',
}}
className="chat-sidebar-menu"
/>
</div>
</div>
{/* 侧边栏底部 - 固定在底部 */}
{!collapsed && (
<div className="p-4 border-t border-gray-100 flex-shrink-0">
<div className="text-xs text-gray-500 text-center">
{conversations.length}
</div>
</div>
)}
{/* 重命名Modal */}
<Modal
title="重命名会话"
open={renameModalVisible}
onOk={handleRenameConfirm}
onCancel={handleRenameCancel}
confirmLoading={renameLoading}
okText="确定"
cancelText="取消"
destroyOnClose
>
<div className="py-4">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="请输入新的会话名称"
maxLength={10}
showCount
onPressEnter={handleRenameConfirm}
autoFocus
/>
</div>
</Modal>
{/* 删除确认Modal */}
<Modal
title={
<div className="flex items-center">
<ExclamationCircleOutlined className="text-red-500 mr-2" />
</div>
}
open={deleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={deleteLoading}
okText="删除"
cancelText="取消"
okType="danger"
destroyOnClose
>
<div className="py-4">
<p> <strong>"{deletingConversation?.name}"</strong> </p>
<p className="text-gray-500 text-sm mt-2"></p>
</div>
</Modal>
</Sider>
);
});
export default ChatSidebar;
+220
View File
@@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { Card, Collapse, Tag, Spin, Typography, Button } from 'antd';
import { ToolOutlined, ThunderboltOutlined, CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import type { ThoughtItem } from '../../types/dify_chat';
import Markdown from './markdown';
import '../../styles/components/chat-with-llm/thought-process.css';
const { Panel } = Collapse;
const { Text, Paragraph } = Typography;
interface ThoughtProcessProps {
thought: ThoughtItem;
isFinished: boolean;
allToolIcons?: Record<string, string>;
}
/**
* 思考过程组件
* 展示AI的思考过程和工具调用
*/
export default function ThoughtProcess({
thought,
isFinished,
allToolIcons = {}
}: ThoughtProcessProps) {
const [expanded, setExpanded] = useState(false);
const { tool_name, tool_input, tool_output, thought: thoughtText, observation } = thought;
/**
* 获取工具图标
*/
const getToolIcon = (toolName?: string) => {
if (!toolName) return <ToolOutlined />;
// 如果有自定义图标映射
if (allToolIcons[toolName]) {
return <span>{allToolIcons[toolName]}</span>;
}
// 根据工具名称返回默认图标
switch (toolName.toLowerCase()) {
case 'search':
case 'web_search':
return '🔍';
case 'calculator':
case 'math':
return '🧮';
case 'code':
case 'python':
return '💻';
case 'image':
case 'vision':
return '👁️';
case 'file':
case 'document':
return '📄';
default:
return <ToolOutlined />;
}
};
/**
* 获取状态图标和颜色
*/
const getStatusInfo = () => {
if (isFinished && observation) {
return {
icon: <CheckCircleOutlined />,
color: 'success',
text: '已完成'
};
} else if (!isFinished) {
return {
icon: <LoadingOutlined spin />,
color: 'processing',
text: '执行中'
};
} else {
return {
icon: <ThunderboltOutlined />,
color: 'default',
text: '等待中'
};
}
};
const statusInfo = getStatusInfo();
/**
* 格式化工具输入
*/
const formatToolInput = (input?: string) => {
if (!input) return '无输入参数';
try {
const parsed = JSON.parse(input);
return (
<pre className="bg-gray-50 p-2 rounded text-sm overflow-x-auto">
{JSON.stringify(parsed, null, 2)}
</pre>
);
} catch {
return <Text code>{input}</Text>;
}
};
/**
* 格式化工具输出
*/
const formatToolOutput = (output?: string) => {
if (!output) return '暂无输出';
try {
const parsed = JSON.parse(output);
return (
<pre className="bg-gray-50 p-2 rounded text-sm overflow-x-auto">
{JSON.stringify(parsed, null, 2)}
</pre>
);
} catch {
return <Markdown content={output} />;
}
};
return (
<Card
size="small"
className="my-2 border-l-4 border-l-blue-400 bg-blue-50"
bodyStyle={{ padding: '12px' }}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-lg">{getToolIcon(tool_name)}</span>
<Text strong className="text-blue-700">
{tool_name || '工具调用'}
</Text>
<Tag
color={statusInfo.color as any}
icon={statusInfo.icon}
className="ml-2"
>
{statusInfo.text}
</Tag>
</div>
{(tool_input || tool_output) && (
<Button
type="text"
size="small"
onClick={() => setExpanded(!expanded)}
className="text-blue-600"
>
{expanded ? '收起详情' : '查看详情'}
</Button>
)}
</div>
{/* 思考内容 */}
{thoughtText && (
<div className="mb-2">
<Markdown content={thoughtText} />
</div>
)}
{/* 工具详情 */}
{expanded && (tool_input || tool_output) && (
<Collapse
ghost
size="small"
className="bg-white rounded"
>
{tool_input && (
<Panel
header={
<span className="text-sm font-medium text-gray-600">
📥
</span>
}
key="input"
>
{formatToolInput(tool_input)}
</Panel>
)}
{tool_output && (
<Panel
header={
<span className="text-sm font-medium text-gray-600">
📤
</span>
}
key="output"
>
{formatToolOutput(tool_output)}
</Panel>
)}
</Collapse>
)}
{/* 观察结果 */}
{observation && observation !== tool_output && (
<div className="mt-2 p-2 bg-green-50 rounded border border-green-200">
<Text className="text-green-700 text-sm font-medium">💡 </Text>
<div className="mt-1">
<Markdown content={observation} />
</div>
</div>
)}
{/* 加载状态 */}
{!isFinished && !observation && (
<div className="flex items-center justify-center py-2 text-blue-600">
<Spin size="small" className="mr-2" />
<Text className="text-sm">...</Text>
</div>
)}
</Card>
);
}
+398 -392
View File
@@ -1,393 +1,399 @@
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from '@remix-run/react';
import type { UserRole } from '~/root';
interface MenuItem {
id: string;
title: string;
path: string;
icon: string;
hideBreadcrumb?: boolean;
requiredRole?: UserRole;
children?: MenuItem[];
}
interface SidebarProps {
onToggle: () => void;
collapsed: boolean;
userRole: UserRole;
selectedApp?: string; // 添加所选应用模块参数
}
// 定义不同应用模块下显示的菜单项ID
const APP_MENU_MAP = {
'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'system-settings'],
'record': ['home', 'file-management', 'rule-management', 'system-settings'],
'model': ['home']
};
// 应用模块名称映射
const APP_NAME_MAP: Record<string, string> = {
'contract': '合同管理',
'record': '案卷智能评查',
'model': '智慧法务大模型'
};
// 应用模块图标映射
const APP_ICON_MAP: Record<string, string> = {
'contract': 'ri-file-list-2-fill',
'record': 'ri-folder-shared-fill',
'model': 'ri-robot-2-fill'
};
export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract' }: SidebarProps) {
const location = useLocation();
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [currentApp, setCurrentApp] = useState<string>(selectedApp);
const navigate = useNavigate();
// 组件挂载后从 sessionStorage 读取初始 reviewType
useEffect(() => {
try {
const reviewType = sessionStorage.getItem('reviewType');
// console.log('初始 reviewType:', reviewType);
if (reviewType) {
setCurrentApp(reviewType);
}
} catch (error) {
console.error('读取 reviewType 失败:', error);
}
}, []);
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
useEffect(() => {
// 监听 sessionStorage 变化(主要用于多标签页情况)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'reviewType' && e.newValue) {
setCurrentApp(e.newValue);
}
};
// 添加事件监听器
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
// 监听路由变化,重新检查 reviewType
useEffect(() => {
try {
const reviewType = sessionStorage.getItem('reviewType');
// console.log('路由变化, 检查 reviewType:', reviewType, '路径:', location.pathname);
if (reviewType) {
setCurrentApp(reviewType);
}
} catch (error) {
console.error('路由变化时读取 reviewType 失败:', error);
}
}, [location.pathname]);
// 监听 selectedApp 属性变化
useEffect(() => {
if (selectedApp) {
setCurrentApp(selectedApp);
}
}, [selectedApp]);
const menuItems: MenuItem[] = [
{
id: 'home',
title: '系统概览',
path: '/home',
icon: 'ri-home-line'
},
{
id: 'file-management',
title: '文件管理',
path: '/files',
icon: 'ri-folder-line',
children: [
{
id: 'file-upload',
title: '文件上传',
path: '/files/upload',
icon: 'ri-upload-cloud-line'
},
{
id:'documents',
title:'文档列表',
path:'/documents',
icon:'ri-file-list-3-line'
}
]
},
{
id: 'rule-management',
title: '评查规则库',
path: '/rules',
icon: 'ri-book-3-line',
children: [
{
id: 'rule-groups',
title: '评查点分组',
path: '/rule-groups',
icon: 'ri-folder-open-line'
},
{
id: 'rules-list',
title: '评查点列表',
path: '/rules',
icon: 'ri-list-check-3'
},
{
id: 'rules-file',
title: '评查文件列表',
path: '/rules-files',
icon: 'ri-list-check-2'
},
// {
// id: 'rule-new',
// title: '新增评查点',
// path: '/rules-new',
// requiredRole: 'developer',
// icon: 'ri-add-circle-line'
// },
// {
// id: 'review-detail',
// title: '评查详情',
// path: '/reviews',
// icon: 'ri-file-chart-line'
// }
]
},
{
id: 'contract-template',
title: '合同模板',
path: '/contract-template',
icon: 'ri-file-search-line',
children: [
{
id: 'contract-search-ai',
title: '智能搜索',
path: '/contract-template/search',
icon: 'ri-search-line'
},
{
id: 'contract-list',
title: '合同列表',
path: '/contract-template/list',
icon: 'ri-folder-line'
}
]
},
{
id: 'system-settings',
title: '系统设置',
path: '/settings',
icon: 'ri-settings-4-line',
requiredRole: 'developer',
children: [
{
id: 'config-lists',
title: '配置列表',
path: '/config-lists',
icon: 'ri-list-check-3',
requiredRole: 'developer'
},
// {
// id: 'basic-settings',
// title: '基础设置',
// path: '/settings',
// icon: 'ri-equalizer-line'
// },
{
id: 'document-types',
title: '文档类型',
path: '/document-types',
icon: 'ri-file-list-line',
requiredRole: 'developer'
},
{
id: 'prompt-management',
title: '提示词管理',
path: '/prompts',
icon: 'ri-chat-1-line',
requiredRole: 'developer'
}
]
}
];
// 初始化展开状态,默认全部展开
useEffect(() => {
const initialExpandedState: Record<string, boolean> = {};
menuItems.forEach(item => {
if (item.children) {
initialExpandedState[item.id] = true;
}
});
setExpandedMenus(initialExpandedState);
}, []);
const toggleMenu = (id: string, e: React.MouseEvent) => {
// 我们只防止事件冒泡,不阻止默认行为
e.stopPropagation();
// console.log('父菜单展开/折叠:', id);
setExpandedMenus(prev => ({
...prev,
[id]: !prev[id]
}));
};
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
// 处理侧边栏切换事件
const handleToggleSidebar = (e: React.MouseEvent) => {
// console.log('侧边栏折叠/展开');
// 只防止事件冒泡,不阻止默认行为
e.stopPropagation();
onToggle();
};
// 处理子菜单项点击事件
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
// 只需要阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
// 获取当前应用模式下应显示的菜单ID列表
const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
// console.log('当前应用模式:', currentApp, '可见菜单ID:', visibleMenuIds);
// 根据用户角色和当前应用模式过滤菜单项
const filteredMenuItems = menuItems.filter(item => {
// 如果菜单项需要特定角色但用户没有
if (item.requiredRole && item.requiredRole !== userRole) {
return false;
}
// 检查当前菜单是否在所选应用模式中显示
if (!visibleMenuIds.includes(item.id)) {
return false;
}
return true;
});
return (
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
<div className="flex items-center"
onClick={() => {
navigate('/');
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate('/');
}
}}
>
<img src="/logo.svg" alt="智慧法务" className="w-12 h-12 mr-2" />
{!collapsed && <h2 className="text-lg font-medium"></h2>}
</div>
<button
className="sidebar-toggle"
onClick={handleToggleSidebar}
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
type="button"
>
<i className={`${collapsed ? 'ri-menu-unfold-line' : 'ri-menu-fold-line'}`}></i>
</button>
</div>
{!collapsed && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center text-green-700">
<i className={`${APP_ICON_MAP[currentApp] || 'ri-file-list-2-fill'} mr-2 text-xl`}></i>
<span className="font-medium">{APP_NAME_MAP[currentApp] || '合同管理'}</span>
</div>
</div>
)}
<div className="py-4 px-[10px]">
{filteredMenuItems.map((item) => (
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
{!item.children ? (
<Link
to={item.path}
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
onClick={(e) => {
// 只阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('单级菜单点击:', item.title, '路径:', item.path);
}}
>
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{item.title}</span>}
</Link>
) : (
<>
<div
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer z-10`}
onClick={(e) => {
// console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title);
toggleMenu(item.id, e);
}}
role="button"
tabIndex={0}
aria-expanded={expandedMenus[item.id] || false}
aria-controls={`submenu-${item.id}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleMenu(item.id, e as unknown as React.MouseEvent);
}
}}
>
<div className="flex items-center">
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{item.title}</span>}
</div>
{!collapsed && (
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
)}
</div>
{(expandedMenus[item.id] || collapsed) && (
<div
className={`submenu-container ${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'} z-20`}
id={`submenu-${item.id}`}
>
{item.children
.filter(child => !child.requiredRole || child.requiredRole === userRole)
.map((child) => (
<Link
key={child.id}
to={child.path}
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
onClick={(e) => handleSubMenuClick(child, e)}
>
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{child.title}</span>}
</Link>
))}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
);
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from '@remix-run/react';
import type { UserRole } from '~/root';
interface MenuItem {
id: string;
title: string;
path: string;
icon: string;
hideBreadcrumb?: boolean;
requiredRole?: UserRole;
children?: MenuItem[];
}
interface SidebarProps {
onToggle: () => void;
collapsed: boolean;
userRole: UserRole;
selectedApp?: string; // 添加所选应用模块参数
}
// 定义不同应用模块下显示的菜单项ID
const APP_MENU_MAP = {
'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'system-settings'],
'record': ['home', 'file-management', 'rule-management', 'system-settings'],
'model': ['chat-with-llm']
};
// 应用模块名称映射
const APP_NAME_MAP: Record<string, string> = {
'contract': '合同管理',
'record': '案卷智能评查',
'model': '智慧法务大模型'
};
// 应用模块图标映射
const APP_ICON_MAP: Record<string, string> = {
'contract': 'ri-file-list-2-fill',
'record': 'ri-folder-shared-fill',
'model': 'ri-robot-2-fill'
};
export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract' }: SidebarProps) {
const location = useLocation();
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [currentApp, setCurrentApp] = useState<string>(selectedApp);
const navigate = useNavigate();
// 组件挂载后从 sessionStorage 读取初始 reviewType
useEffect(() => {
try {
const reviewType = sessionStorage.getItem('reviewType');
// console.log('初始 reviewType:', reviewType);
if (reviewType) {
setCurrentApp(reviewType);
}
} catch (error) {
console.error('读取 reviewType 失败:', error);
}
}, []);
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
useEffect(() => {
// 监听 sessionStorage 变化(主要用于多标签页情况)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'reviewType' && e.newValue) {
setCurrentApp(e.newValue);
}
};
// 添加事件监听器
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
// 监听路由变化,重新检查 reviewType
useEffect(() => {
try {
const reviewType = sessionStorage.getItem('reviewType');
// console.log('路由变化, 检查 reviewType:', reviewType, '路径:', location.pathname);
if (reviewType) {
setCurrentApp(reviewType);
}
} catch (error) {
console.error('路由变化时读取 reviewType 失败:', error);
}
}, [location.pathname]);
// 监听 selectedApp 属性变化
useEffect(() => {
if (selectedApp) {
setCurrentApp(selectedApp);
}
}, [selectedApp]);
const menuItems: MenuItem[] = [
{
id: 'home',
title: '系统概览',
path: '/home',
icon: 'ri-home-line'
},
{
id: 'chat-with-llm',
title: 'AI对话',
path: '/chat-with-llm',
icon: 'ri-chat-smile-2-line'
},
{
id: 'file-management',
title: '文件管理',
path: '/files',
icon: 'ri-folder-line',
children: [
{
id: 'file-upload',
title: '文件上传',
path: '/files/upload',
icon: 'ri-upload-cloud-line'
},
{
id: 'documents',
title: '文档列表',
path: '/documents',
icon: 'ri-file-list-3-line'
}
]
},
{
id: 'rule-management',
title: '评查规则库',
path: '/rules',
icon: 'ri-book-3-line',
children: [
{
id: 'rule-groups',
title: '评查点分组',
path: '/rule-groups',
icon: 'ri-folder-open-line'
},
{
id: 'rules-list',
title: '评查列表',
path: '/rules',
icon: 'ri-list-check-3'
},
{
id: 'rules-file',
title: '评查文件列表',
path: '/rules-files',
icon: 'ri-list-check-2'
},
// {
// id: 'rule-new',
// title: '新增评查点',
// path: '/rules-new',
// requiredRole: 'developer',
// icon: 'ri-add-circle-line'
// },
// {
// id: 'review-detail',
// title: '评查详情',
// path: '/reviews',
// icon: 'ri-file-chart-line'
// }
]
},
{
id: 'contract-template',
title: '合同模板',
path: '/contract-template',
icon: 'ri-file-search-line',
children: [
{
id: 'contract-search-ai',
title: '智能搜索',
path: '/contract-template/search',
icon: 'ri-search-line'
},
{
id: 'contract-list',
title: '合同列表',
path: '/contract-template/list',
icon: 'ri-folder-line'
}
]
},
{
id: 'system-settings',
title: '系统设置',
path: '/settings',
icon: 'ri-settings-4-line',
requiredRole: 'developer',
children: [
{
id: 'config-lists',
title: '配置列表',
path: '/config-lists',
icon: 'ri-list-check-3',
requiredRole: 'developer'
},
// {
// id: 'basic-settings',
// title: '基础设置',
// path: '/settings',
// icon: 'ri-equalizer-line'
// },
{
id: 'document-types',
title: '文档类型',
path: '/document-types',
icon: 'ri-file-list-line',
requiredRole: 'developer'
},
{
id: 'prompt-management',
title: '提示词管理',
path: '/prompts',
icon: 'ri-chat-1-line',
requiredRole: 'developer'
}
]
}
];
// 初始化展开状态,默认全部展开
useEffect(() => {
const initialExpandedState: Record<string, boolean> = {};
menuItems.forEach(item => {
if (item.children) {
initialExpandedState[item.id] = true;
}
});
setExpandedMenus(initialExpandedState);
}, []);
const toggleMenu = (id: string, e: React.MouseEvent) => {
// 我们只防止事件冒泡,不阻止默认行为
e.stopPropagation();
// console.log('父菜单展开/折叠:', id);
setExpandedMenus(prev => ({
...prev,
[id]: !prev[id]
}));
};
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
// 处理侧边栏切换事件
const handleToggleSidebar = (e: React.MouseEvent) => {
// console.log('侧边栏折叠/展开');
// 只防止事件冒泡,不阻止默认行为
e.stopPropagation();
onToggle();
};
// 处理子菜单项点击事件
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
// 只需要阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('子菜单点击:', child.title, '路径:', child.path);
};
// 获取当前应用模式下应显示的菜单ID列表
const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
// console.log('当前应用模式:', currentApp, '可见菜单ID:', visibleMenuIds);
// 根据用户角色和当前应用模式过滤菜单项
const filteredMenuItems = menuItems.filter(item => {
// 如果菜单项需要特定角色但用户没有
if (item.requiredRole && item.requiredRole !== userRole) {
return false;
}
// 检查当前菜单是否在所选应用模式中显示
if (!visibleMenuIds.includes(item.id)) {
return false;
}
return true;
});
return (
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
<div className="flex items-center"
onClick={() => {
navigate('/');
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate('/');
}
}}
>
<img src="/logo.svg" alt="智慧法务" className="w-12 h-12 mr-2" />
{!collapsed && <h2 className="text-lg font-medium"></h2>}
</div>
<button
className="sidebar-toggle"
onClick={handleToggleSidebar}
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
type="button"
>
<i className={`${collapsed ? 'ri-menu-unfold-line' : 'ri-menu-fold-line'}`}></i>
</button>
</div>
{!collapsed && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center text-green-700">
<i className={`${APP_ICON_MAP[currentApp] || 'ri-file-list-2-fill'} mr-2 text-xl`}></i>
<span className="font-medium">{APP_NAME_MAP[currentApp] || '合同管理'}</span>
</div>
</div>
)}
<div className="py-4 px-[10px]">
{filteredMenuItems.map((item) => (
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
{!item.children ? (
<Link
to={item.path}
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
onClick={(e) => {
// 只阻止冒泡,不阻止默认行为
e.stopPropagation();
// console.log('单级菜单点击:', item.title, '路径:', item.path);
}}
>
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{item.title}</span>}
</Link>
) : (
<>
<div
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer z-10`}
onClick={(e) => {
// console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title);
toggleMenu(item.id, e);
}}
role="button"
tabIndex={0}
aria-expanded={expandedMenus[item.id] || false}
aria-controls={`submenu-${item.id}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleMenu(item.id, e as unknown as React.MouseEvent);
}
}}
>
<div className="flex items-center">
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{item.title}</span>}
</div>
{!collapsed && (
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
)}
</div>
{(expandedMenus[item.id] || collapsed) && (
<div
className={`submenu-container ${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'} z-20`}
id={`submenu-${item.id}`}
>
{item.children
.filter(child => !child.requiredRole || child.requiredRole === userRole)
.map((child) => (
<Link
key={child.id}
to={child.path}
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
onClick={(e) => handleSubMenuClick(child, e)}
>
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
{!collapsed && <span>{child.title}</span>}
</Link>
))}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
);
}