基于 shiy-temp分支修改

This commit is contained in:
pingchuan
2025-06-04 11:18:52 +08:00
parent 87ad3376fe
commit af33de09db
36 changed files with 6293 additions and 105 deletions
+6
View File
@@ -0,0 +1,6 @@
# APP ID
NEXT_PUBLIC_APP_ID=
# APP API key
NEXT_PUBLIC_APP_KEY=
# API url prefix
NEXT_PUBLIC_API_URL=
+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>
);
}
+44 -38
View File
@@ -23,7 +23,7 @@ interface SidebarProps {
const APP_MENU_MAP = {
'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'system-settings'],
'record': ['home', 'file-management', 'rule-management', 'system-settings'],
'model': ['home']
'model': ['chat-with-llm']
};
// 应用模块名称映射
@@ -45,7 +45,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
const [currentApp, setCurrentApp] = useState<string>(selectedApp);
const navigate = useNavigate();
// 组件挂载后从 sessionStorage 读取初始 reviewType
useEffect(() => {
try {
@@ -58,7 +58,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
console.error('读取 reviewType 失败:', error);
}
}, []);
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
useEffect(() => {
// 监听 sessionStorage 变化(主要用于多标签页情况)
@@ -67,15 +67,15 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
setCurrentApp(e.newValue);
}
};
// 添加事件监听器
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
// 监听路由变化,重新检查 reviewType
useEffect(() => {
try {
@@ -88,14 +88,14 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
console.error('路由变化时读取 reviewType 失败:', error);
}
}, [location.pathname]);
// 监听 selectedApp 属性变化
useEffect(() => {
if (selectedApp) {
setCurrentApp(selectedApp);
}
}, [selectedApp]);
const menuItems: MenuItem[] = [
{
id: 'home',
@@ -103,6 +103,12 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
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: '文件管理',
@@ -116,10 +122,10 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
icon: 'ri-upload-cloud-line'
},
{
id:'documents',
title:'文档列表',
path:'/documents',
icon:'ri-file-list-3-line'
id: 'documents',
title: '文档列表',
path: '/documents',
icon: 'ri-file-list-3-line'
}
]
},
@@ -219,7 +225,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
]
}
];
// 初始化展开状态,默认全部展开
useEffect(() => {
const initialExpandedState: Record<string, boolean> = {};
@@ -230,23 +236,23 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
});
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('侧边栏折叠/展开');
@@ -254,7 +260,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
e.stopPropagation();
onToggle();
};
// 处理子菜单项点击事件
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
// 只需要阻止冒泡,不阻止默认行为
@@ -272,15 +278,15 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
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">
@@ -300,7 +306,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
<img src="/logo.svg" alt="智慧法务" className="w-12 h-12 mr-2" />
{!collapsed && <h2 className="text-lg font-medium"></h2>}
</div>
<button
<button
className="sidebar-toggle"
onClick={handleToggleSidebar}
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
@@ -309,7 +315,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
<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">
@@ -318,7 +324,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
</div>
</div>
)}
<div className="py-4 px-[10px]">
{filteredMenuItems.map((item) => (
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
@@ -337,7 +343,7 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
</Link>
) : (
<>
<div
<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);
@@ -362,25 +368,25 @@ export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
)}
</div>
{(expandedMenus[item.id] || collapsed) && (
<div
<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>
))}
<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>
)}
</>
+98
View File
@@ -0,0 +1,98 @@
import type { AppInfo } from '../types/dify_chat';
// 在客户端获取环境变量的辅助函数
const getEnvVar = (name: string, defaultValue: string = '') => {
// 在服务端
if (typeof window === 'undefined') {
return process.env[name] || defaultValue;
}
// 在客户端,从window.__ENV获取
return (window as any).__ENV?.[name] || defaultValue;
};
// 从完整的APP URL中提取APP ID
const extractAppId = (appUrl: string): string => {
if (!appUrl) return '';
// 如果是完整的URL,提取最后的UUID部分
const match = appUrl.match(/\/app\/([a-f0-9-]{36})/);
if (match) {
return match[1];
}
// 如果已经是UUID格式,直接返回
if (/^[a-f0-9-]{36}$/.test(appUrl)) {
return appUrl;
}
return appUrl;
};
// 获取配置值并添加调试日志
const getApiUrl = () => {
// 在Remix中,我们使用本地API路由作为代理,而不是直接访问Dify API
if (typeof window !== 'undefined') {
// 客户端:使用相对路径访问本地API
return '/api';
} else {
// 服务端:也使用相对路径
return '/api';
}
};
const getAppId = () => {
const rawAppId = getEnvVar('NEXT_PUBLIC_APP_ID', '');
const extractedAppId = extractAppId(rawAppId);
// console.log('🔧 Chat Config Debug:', {
// rawAppId,
// extractedAppId,
// apiUrl: getApiUrl(),
// hasApiKey: !!getEnvVar('NEXT_PUBLIC_APP_KEY', ''),
// difyApiUrl: getEnvVar('NEXT_PUBLIC_API_URL', ''),
// });
return extractedAppId;
};
// 聊天应用配置
export const CHAT_CONFIG = {
// API相关配置 - 使用本地API路由作为代理
API_URL: getApiUrl(),
APP_ID: getAppId(),
API_KEY: getEnvVar('NEXT_PUBLIC_APP_KEY', ''),
// Dify API 配置(用于服务端)
DIFY_API_URL: getEnvVar('NEXT_PUBLIC_API_URL', 'https://api.dify.ai/v1'),
// 应用信息
APP_INFO: {
title: '大模型对话',
description: '大模型对话',
copyright: '大模型对话',
privacy_policy: '大模型对话',
default_language: 'zh-Hans',
},
// 功能配置
isShowPrompt: false,
promptTemplate: 'I want you to act as a javascript console.',
// API相关
API_PREFIX: '/api',
// 本地化
LOCALE_COOKIE_NAME: 'locale',
// 限制
DEFAULT_VALUE_MAX_LEN: 48,
};
// SSE超时设置
export const SSE_TIMEOUT = 100000;
// 内容类型
export const ContentType = {
json: 'application/json',
stream: 'text/event-stream',
form: 'application/x-www-form-urlencoded; charset=UTF-8',
download: 'application/octet-stream',
};
+574
View File
@@ -0,0 +1,574 @@
import { useState, useCallback, useRef } from 'react';
import { produce } from 'immer';
import { useBoolean, useGetState } from 'ahooks';
import { sendChatMessage, updateFeedback, generateConversationName } from '../services/api.client';
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat';
import { CHAT_CONFIG } from '../config/chat';
/**
* 聊天消息处理钩子
*/
export default function useChatMessage({
onUpdateConversationList,
onConversationIdChange,
}: {
onUpdateConversationList?: (conversationId: string, data: { name: string }) => void;
onConversationIdChange?: (conversationId: string) => void;
}) {
// 聊天消息列表
const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>([]);
// 是否正在响应中
const [isResponding, { setTrue: setResponding, setFalse: setNotResponding }] = useBoolean(false);
// 是否已停止响应
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false);
// 响应的会话是否为当前会话
const [isRespondingConIsCurrCon, setIsRespondingConCurrCon, getIsRespondingConIsCurrCon] = useGetState(true);
// 消息任务ID
const [messageTaskId, setMessageTaskId] = useState('');
// 用户查询
const [userQuery, setUserQuery] = useState('');
// 中止控制器
const abortControllerRef = useRef<AbortController | null>(null);
// 当前流式响应项的引用
const currentResponseRef = useRef<ChatItem | null>(null);
/**
* 记录错误日志
*/
const logError = useCallback((message: string) => {
console.error(`[Chat Error]: ${message}`);
}, []);
/**
* 检查是否可以发送消息
*/
const checkCanSend = useCallback(() => {
if (isResponding) {
console.warn('机器人正在回复中,请稍后再试');
return false;
}
return true;
}, [isResponding]);
/**
* 立即更新聊天列表 - 移除防抖机制
*/
const updateChatList = useCallback((
responseItem: ChatItem,
questionId: string,
placeholderAnswerId: string,
questionItem: ChatItem,
originalResponseId?: string
) => {
// console.log('🔄 更新聊天列表:', {
// responseItemId: responseItem.id,
// responseContent: responseItem.content,
// originalResponseId,
// questionId,
// placeholderAnswerId
// });
setChatList(produce(getChatList(), (draft) => {
// console.log('📝 当前聊天列表:', draft.map(item => ({ id: item.id, content: item.content.substring(0, 20), isAnswer: item.isAnswer })));
// 移除占位符
const placeholderIndex = draft.findIndex(item => item.id === placeholderAnswerId);
if (placeholderIndex !== -1) {
// console.log('🗑️ 移除占位符:', placeholderAnswerId);
draft.splice(placeholderIndex, 1);
}
// 确保问题存在
const questionIndex = draft.findIndex(item => item.id === questionId);
if (questionIndex === -1) {
// console.log(' 添加问题:', questionId);
draft.push({ ...questionItem });
}
// 更新或添加响应 - 考虑ID可能已经改变的情况
let responseIndex = draft.findIndex(item => item.id === responseItem.id);
// console.log('🔍 查找响应索引 (当前ID):', { responseItemId: responseItem.id, responseIndex });
// 如果找不到当前ID的响应,尝试查找原始ID
if (responseIndex === -1 && originalResponseId) {
responseIndex = draft.findIndex(item => item.id === originalResponseId);
// console.log('🔍 查找响应索引 (原始ID):', { originalResponseId, responseIndex });
}
// 如果找不到任何匹配的响应,查找最后一个AI回答
if (responseIndex === -1) {
responseIndex = draft.findIndex((item, index) =>
item.isAnswer &&
index > draft.findIndex(q => q.id === questionId)
);
// console.log('🔍 查找响应索引 (最后AI回答):', { responseIndex });
}
if (responseIndex !== -1) {
// console.log('✏️ 更新现有响应:', { responseIndex, newContent: responseItem.content.substring(0, 20) });
draft[responseIndex] = { ...responseItem };
} else {
// console.log(' 添加新响应:', { responseId: responseItem.id, content: responseItem.content.substring(0, 20) });
draft.push({ ...responseItem });
}
// console.log('📝 更新后聊天列表:', draft.map(item => ({ id: item.id, content: item.content.substring(0, 20), isAnswer: item.isAnswer })));
}));
}, [getChatList, setChatList]);
/**
* 更新当前问答 - 移除防抖,立即更新
*/
const updateCurrentQA = useCallback(({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
originalResponseId,
}: {
responseItem: ChatItem;
questionId: string;
placeholderAnswerId: string;
questionItem: ChatItem;
originalResponseId?: string;
}) => {
// 更新当前响应引用
currentResponseRef.current = responseItem;
// 立即更新,不使用防抖
updateChatList(responseItem, questionId, placeholderAnswerId, questionItem, originalResponseId);
}, [updateChatList]);
/**
* 转换文件格式为服务器格式
*/
const transformToServerFile = useCallback((fileItem: any) => {
return {
type: 'image',
transfer_method: fileItem.transferMethod || fileItem.transfer_method,
url: fileItem.url,
upload_file_id: fileItem.id || fileItem.upload_file_id,
};
}, []);
/**
* 发送消息
*/
const handleSend = useCallback(async (
message: string,
conversationId: string | null,
files?: VisionFile[],
inputs?: Record<string, any>,
) => {
if (!checkCanSend() || !message.trim()) {
return;
}
// console.log('📤 发送消息:', { message, conversationId });
setUserQuery(message);
setResponding();
setHasStopResponded(false);
setIsRespondingConCurrCon(true);
// 处理输入参数
const toServerInputs: Record<string, any> = {};
if (inputs) {
Object.keys(inputs).forEach((key) => {
const value = inputs[key];
if (value?.supportFileType) {
toServerInputs[key] = transformToServerFile(value);
} else if (Array.isArray(value) && value[0]?.supportFileType) {
toServerInputs[key] = value.map((item: any) => transformToServerFile(item));
} else {
toServerInputs[key] = value;
}
});
}
// 准备请求数据
const data: Record<string, any> = {
inputs: toServerInputs,
query: message,
conversation_id: conversationId === '-1' ? null : conversationId,
};
// 添加文件数据
if (files && files.length > 0) {
data.files = files.map((item) => {
if (item.transfer_method === 'local_file') {
return {
...item,
url: '',
};
}
return item;
});
}
// 生成唯一ID
const questionId = `question-${Date.now()}`;
const placeholderAnswerId = `answer-placeholder-${Date.now()}`;
// 创建问题项
const questionItem: ChatItem = {
id: questionId,
content: message,
isAnswer: false,
message_files: files,
};
// 创建答案占位符
const placeholderAnswerItem: ChatItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
};
// 更新聊天列表
const newList = [...getChatList(), questionItem, placeholderAnswerItem];
setChatList(newList);
let isAgentMode = false;
let hasSetResponseId = false;
const prevTempNewConversationId = conversationId || '-1';
let tempNewConversationId = '';
// 创建响应项
const responseItem: ChatItem = {
id: `response-${Date.now()}`,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
};
// 保存原始响应ID
const originalResponseId = responseItem.id;
try {
// 发送消息
await sendChatMessage(data, {
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }) => {
// console.log('📨 收到流式数据:', { message, isFirstMessage, messageId, newConversationId });
if (!isAgentMode) {
// 累积消息内容
responseItem.content = responseItem.content + message;
// console.log('📝 累积消息内容:', { currentContent: responseItem.content });
} else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1];
if (lastThought) {
lastThought.thought = (lastThought.thought || '') + message;
}
}
if (messageId && !hasSetResponseId) {
responseItem.id = messageId;
hasSetResponseId = true;
// console.log('🆔 设置响应ID:', { oldId: originalResponseId, newId: messageId });
}
// 重要:确保正确获取新会话ID
if (newConversationId && !tempNewConversationId) {
tempNewConversationId = newConversationId;
// console.log('🆔 获取到新会话ID:', tempNewConversationId);
}
setMessageTaskId(taskId || '');
// 检查是否切换到其他会话
// console.log('🔍 会话检查:', {
// prevTempNewConversationId,
// conversationId,
// isEqual: prevTempNewConversationId === conversationId
// });
// 修复新会话的匹配逻辑
const isNewConversationMatch = (prevTempNewConversationId === '-1' && conversationId === null) ||
(prevTempNewConversationId === conversationId);
if (!isNewConversationMatch) {
// console.log('⚠️ 会话不匹配,跳过更新');
setIsRespondingConCurrCon(false);
return;
}
// console.log('🔄 准备调用updateCurrentQA:', {
// responseItemId: responseItem.id,
// responseContent: responseItem.content,
// questionId,
// placeholderAnswerId,
// originalResponseId
// });
// 更新当前问答(使用防抖)
updateCurrentQA({
responseItem: { ...responseItem }, // 创建副本避免引用问题
questionId,
placeholderAnswerId,
questionItem,
originalResponseId,
});
},
onCompleted: async (hasError?: boolean) => {
// console.log('✅ 消息发送完成:', { hasError });
// 立即更新最终状态
if (currentResponseRef.current) {
updateCurrentQA({
responseItem: { ...currentResponseRef.current },
questionId,
placeholderAnswerId,
questionItem,
originalResponseId,
});
}
if (hasError) {
setNotResponding();
return;
}
// 如果是新会话,处理会话ID更新和名称生成
// 检查原始传入的conversationId是否为新会话标识
const isNewConversation = conversationId === '-1' || conversationId === null;
if (tempNewConversationId && isNewConversation) {
try {
// console.log('🆕 处理新会话:', {
// tempNewConversationId,
// originalConversationId: conversationId,
// isNewConversation
// });
// 通知会话ID变更(这会触发localStorage更新)
onConversationIdChange?.(tempNewConversationId);
// 生成会话名称
const res = await generateConversationName(tempNewConversationId);
const { data } = res as any;
if (data?.name) {
// console.log('📝 生成会话名称:', data.name);
onUpdateConversationList?.(tempNewConversationId, { name: data.name });
}
} catch (err) {
console.error('生成会话名称失败:', err);
}
} else {
// console.log('🔍 不是新会话,跳过处理:', {
// tempNewConversationId,
// conversationId,
// isNewConversation
// });
}
setNotResponding();
setUserQuery('');
},
onFile: (file) => {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1];
if (lastThought) {
lastThought.message_files = [...(lastThought.message_files || []), { ...file }];
} else {
if (!responseItem.message_files) {
responseItem.message_files = [];
}
responseItem.message_files.push(file);
}
updateCurrentQA({
responseItem: { ...responseItem },
questionId,
placeholderAnswerId,
questionItem,
originalResponseId,
});
},
onThought: (thought) => {
isAgentMode = true;
if (thought.message_id && !hasSetResponseId) {
responseItem.id = thought.message_id;
hasSetResponseId = true;
}
if (!responseItem.agent_thoughts) {
responseItem.agent_thoughts = [];
}
if (responseItem.agent_thoughts.length === 0) {
responseItem.agent_thoughts.push(thought);
} else {
const lastThought = responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1];
// 相同思考ID,更新现有思考
if (lastThought.id === thought.id) {
thought.thought = lastThought.thought;
thought.message_files = lastThought.message_files;
responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = thought;
} else {
responseItem.agent_thoughts.push(thought);
}
}
// 检查是否切换到其他会话
if (prevTempNewConversationId !== conversationId) {
setIsRespondingConCurrCon(false);
return;
}
updateCurrentQA({
responseItem: { ...responseItem },
questionId,
placeholderAnswerId,
questionItem,
originalResponseId,
});
},
onMessageEnd: (messageEnd: MessageEnd) => {
// 处理消息结束事件
// console.log('Message ended:', messageEnd);
},
onMessageReplace: (messageReplace: MessageReplace) => {
// 处理消息替换事件
responseItem.content = messageReplace.answer;
updateCurrentQA({
responseItem: { ...responseItem },
questionId,
placeholderAnswerId,
questionItem,
originalResponseId,
});
},
onError: (error) => {
logError(`聊天消息请求错误: ${error}`);
setChatList(produce(getChatList(), (draft) => {
const placeholderIndex = draft.findIndex(item => item.id === placeholderAnswerId);
if (placeholderIndex !== -1) {
draft[placeholderIndex].content = `错误: ${error}`;
draft[placeholderIndex].isError = true;
}
}));
setNotResponding();
},
getAbortController: (controller) => {
abortControllerRef.current = controller;
},
});
} catch (err: any) {
logError(`发送消息时出错: ${err.message}`);
setNotResponding();
}
}, [
checkCanSend,
getChatList,
setChatList,
logError,
onUpdateConversationList,
onConversationIdChange,
setNotResponding,
setResponding,
updateCurrentQA,
transformToServerFile,
setHasStopResponded,
setIsRespondingConCurrCon,
setMessageTaskId,
setUserQuery
]);
/**
* 处理反馈
*/
const handleFeedback = useCallback(async (messageId: string, feedback: Feedbacktype) => {
try {
await updateFeedback({
url: `messages/${messageId}/feedbacks`,
body: feedback,
});
// 更新聊天列表中的反馈
setChatList(produce(getChatList(), (draft) => {
const messageIndex = draft.findIndex(item => item.id === messageId);
if (messageIndex !== -1) {
draft[messageIndex].feedback = feedback;
}
}));
} catch (err) {
logError(`提交反馈时出错: ${err}`);
}
}, [logError, getChatList, setChatList]);
/**
* 停止响应
*/
const stopResponding = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setHasStopResponded(true);
setNotResponding();
}, [setNotResponding, setHasStopResponded]);
/**
* 重新生成回答
*/
const regenerateResponse = useCallback(async (messageId: string) => {
// 找到要重新生成的消息
const messageIndex = getChatList().findIndex(item => item.id === messageId);
if (messageIndex === -1) return;
const message = getChatList()[messageIndex];
if (!message.isAnswer) return;
// 找到对应的问题
const questionIndex = messageIndex - 1;
if (questionIndex < 0) return;
const question = getChatList()[questionIndex];
if (question.isAnswer) return;
// 重新发送问题
await handleSend(question.content, null, question.message_files);
}, [getChatList, handleSend]);
/**
* 清空聊天记录
*/
const clearChatList = useCallback(() => {
setChatList([]);
currentResponseRef.current = null;
}, [setChatList]);
return {
// 基础状态
chatList,
setChatList,
getChatList,
isResponding,
hasStopResponded,
isRespondingConIsCurrCon,
messageTaskId,
userQuery,
// 核心方法
handleSend,
handleFeedback,
stopResponding,
regenerateResponse,
clearChatList,
// 辅助方法
checkCanSend,
logError,
updateCurrentQA,
};
}
+246
View File
@@ -0,0 +1,246 @@
import { useState, useCallback } from 'react';
import { useParams } from '@remix-run/react';
import { produce } from 'immer';
import { useGetState, useLocalStorageState } from 'ahooks';
import type { ConversationItem } from '../types/dify_chat';
import { CHAT_CONFIG } from '../config/chat';
// 本地存储键名
const storageConversationIdKey = 'conversationIdInfo';
// 会话信息类型(排除inputs和id)
type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>;
/**
* 获取完整的应用URL键名(与webapp-conversation保持一致)
* @param appId 应用ID
* @returns 完整的URL键名
*/
function getAppUrlKey(appId: string): string {
if (typeof window !== 'undefined') {
// 在客户端,构建完整的URL键名
const { protocol, host } = window.location;
return `${protocol}//${host}/app/${appId}`;
}
// 在服务端,使用简化的键名
return appId;
}
/**
* 会话管理钩子
* 用于管理聊天会话、当前会话状态以及会话输入
* 采用单页面应用模式,不使用URL路由导航
*/
export default function useConversation() {
const params = useParams();
// 会话列表
const [conversationList, setConversationList] = useState<ConversationItem[]>([]);
// 当前会话ID - 使用ahooks的useGetState来获得更好的状态管理
// 初始值从URL参数或localStorage获取,如果都没有则为'-1'(新会话)
const [currConversationId, doSetCurrConversationId, getCurrConversationId] = useGetState<string>(() => {
// 首先尝试从URL参数获取
if (params.id) {
return params.id;
}
// 然后尝试从localStorage获取
const storedId = getConversationIdFromStorage(CHAT_CONFIG.APP_ID);
return storedId || '-1';
});
// 新会话输入状态
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null);
// 现有会话输入状态
const [existConversationInputs, setExistConversationInputs] = useState<Record<string, any> | null>(null);
// 新会话信息
const [newConversationInfo, setNewConversationInfo] = useState<ConversationInfoType | null>(null);
// 现有会话信息
const [existConversationInfo, setExistConversationInfo] = useState<ConversationInfoType | null>(null);
// 判断是否为新会话
const isNewConversation = currConversationId === '-1';
// 当前输入状态(根据是否为新会话来决定使用哪个状态)
const currInputs = isNewConversation ? newConversationInputs : existConversationInputs;
const setCurrInputs = isNewConversation ? setNewConversationInputs : setExistConversationInputs;
// 当前会话信息(根据是否为新会话来决定使用哪个信息)
const currConversationInfo = isNewConversation ? newConversationInfo : existConversationInfo;
/**
* 从本地存储获取会话ID
* @param appId 应用ID
* @returns 会话ID
*/
function getConversationIdFromStorage(appId: string): string {
try {
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey)
? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '{}')
: {};
// 使用完整的URL键名获取会话ID
const appUrlKey = getAppUrlKey(appId);
const conversationId = conversationIdInfo[appUrlKey];
// console.log('📖 从localStorage获取会话ID:', {
// appUrlKey,
// conversationId,
// allKeys: Object.keys(conversationIdInfo)
// });
return conversationId || '-1';
} catch (error) {
console.error('获取本地存储会话ID失败:', error);
return '-1';
}
}
/**
* 设置当前会话ID并保存到本地存储
* 纯状态管理模式,不进行URL导航
* @param id 会话ID
* @param appId 应用ID
* @param isSetToLocalStorage 是否保存到本地存储
* @param newConversationName 新会话名称(暂未使用)
*/
const setCurrConversationId = (
id: string,
appId: string,
isSetToLocalStorage = true,
newConversationName = ''
) => {
console.log('🔄 设置当前会话ID:', { id, appId, isSetToLocalStorage });
doSetCurrConversationId(id);
if (isSetToLocalStorage && id !== '-1') {
try {
// 获取现有的conversationIdInfo
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey)
? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '{}')
: {};
// 使用完整的URL键名保存会话ID(与webapp-conversation保持一致)
const appUrlKey = getAppUrlKey(appId);
conversationIdInfo[appUrlKey] = id;
globalThis.localStorage?.setItem(storageConversationIdKey, JSON.stringify(conversationIdInfo));
console.log('💾 会话ID已保存到localStorage:', {
appUrlKey,
conversationId: id,
fullStorage: conversationIdInfo
});
} catch (error) {
console.error('保存会话ID到本地存储失败:', error);
}
}
// 不进行URL导航,保持单页面应用模式
console.log('✅ 会话切换完成,当前会话ID:', id);
};
/**
* 重置新会话输入
* 使用immer来安全地重置状态
*/
const resetNewConversationInputs = () => {
if (!newConversationInputs) {
return;
}
setNewConversationInputs(produce(newConversationInputs, (draft) => {
Object.keys(draft).forEach((key) => {
draft[key] = '';
});
}));
};
/**
* 更新会话列表中的特定会话
* @param id 会话ID
* @param updates 要更新的字段
*/
const updateConversationInList = (id: string, updates: Partial<ConversationItem>) => {
setConversationList(produce(conversationList, (draft) => {
const index = draft.findIndex(item => item.id === id);
if (index !== -1) {
Object.assign(draft[index], updates);
}
}));
};
/**
* 添加新会话到列表
* @param conversation 新会话
*/
const addConversationToList = (conversation: ConversationItem) => {
setConversationList(produce(conversationList, (draft) => {
// 检查是否已存在,避免重复添加
const exists = draft.some(item => item.id === conversation.id);
if (!exists) {
draft.unshift(conversation);
}
}));
};
/**
* 从列表中移除会话
* @param id 会话ID
*/
const removeConversationFromList = (id: string) => {
setConversationList(produce(conversationList, (draft) => {
const index = draft.findIndex(item => item.id === id);
if (index !== -1) {
draft.splice(index, 1);
}
}));
};
/**
* 创建新会话项
* @param name 会话名称
* @param introduction 会话介绍
* @returns 新会话项
*/
const createNewConversationItem = (name: string = '新对话', introduction: string = ''): ConversationItem => {
return {
id: '-1',
name,
inputs: newConversationInputs || {},
introduction,
};
};
return {
// 基础状态
conversationList,
setConversationList,
currConversationId,
getCurrConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
// 输入状态
currInputs,
newConversationInputs,
existConversationInputs,
resetNewConversationInputs,
setCurrInputs,
// 会话信息
currConversationInfo,
setNewConversationInfo,
setExistConversationInfo,
// 辅助方法
updateConversationInList,
addConversationToList,
removeConversationFromList,
createNewConversationItem,
};
}
+48 -31
View File
@@ -1,20 +1,20 @@
// import React from 'react';
import {
Links,
import {
Links,
// LiveReload, // 不再需要,使用Vite时会与内置HMR冲突
Meta,
Outlet,
Scripts,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
useRouteError,
type MetaFunction,
useLoaderData
} from "@remix-run/react";
import {
LoaderFunctionArgs,
redirect,
createCookieSessionStorage,
import {
LoaderFunctionArgs,
redirect,
createCookieSessionStorage,
ActionFunctionArgs
} from "@remix-run/node";
import { Layout } from "~/components/layout/Layout";
@@ -85,7 +85,7 @@ export async function createUserSession(isAuthenticated: boolean, userRole: User
// 销毁会话(登出)
export async function logout(request: Request) {
const session = await getSession(request);
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
@@ -97,58 +97,67 @@ export async function logout(request: Request) {
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "logout") {
return logout(request);
}
return null;
}
// 添加loader函数进行全局认证检查
// 添加loader函数进行全局认证检查并传递环境变量给客户端
export async function loader({ request }: LoaderFunctionArgs) {
// 获取当前路径
const url = new URL(request.url);
const pathname = url.pathname;
// 排除不需要登录验证的路径
const publicPaths = ['/login', '/favicon.ico'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
// 获取用户会话
const { isAuthenticated, userRole } = await getUserSession(request);
// console.log("Auth status:", { isAuthenticated, userRole, pathname });
// 如果访问需要认证的路径但未登录,重定向到登录页
if (!isPublicPath && !isAuthenticated) {
// 保存请求的URL,以便登录后重定向回来
const session = await getSession(request);
// 如果路径是/home,则将重定向目标设置为/
const redirectTarget = pathname === "/home" ? "/" : pathname;
// 保存重定向目标
session.set("redirectTo", redirectTarget);
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
}
// 如果已登录且访问登录页,重定向到首页
if (pathname === "/login" && isAuthenticated) {
// console.log("Already authenticated, redirecting from login to /");
return redirect("/");
}
// 检查访问权限 - 如果是common用户访问了开发者专属页面,重定向到首页
if (userRole === 'common' && developerOnlyPaths.some(path => pathname.startsWith(path))) {
return redirect("/");
}
// 向组件传递认证状态当前路径
return Response.json({ isAuthenticated, userRole, pathname });
// 向组件传递认证状态当前路径和环境变量
return Response.json({
isAuthenticated,
userRole,
pathname,
ENV: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_APP_ID: process.env.NEXT_PUBLIC_APP_ID,
NEXT_PUBLIC_APP_KEY: process.env.NEXT_PUBLIC_APP_KEY,
},
});
}
@@ -168,6 +177,8 @@ export function links() {
{ rel: "stylesheet", href: styles },
{ rel: "stylesheet", href: messageModalStyles },
{ rel: "stylesheet", href: toastStyles },
// 添加 Antd 样式
{ rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" },
{ rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
// { rel: "preconnect", href: "https://fonts.googleapis.com" },
// { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
@@ -176,15 +187,16 @@ export function links() {
}
export default function App() {
const { userRole } = useLoaderData<typeof loader>();
const { userRole, ENV } = useLoaderData<typeof loader>();
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style dangerouslySetInnerHTML={{ __html: `
<style dangerouslySetInnerHTML={{
__html: `
:root {
--color-primary: #00684a;
--color-primary-hover: #005a3f;
@@ -199,6 +211,11 @@ export default function App() {
` }} />
<Meta />
<Links />
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV = ${JSON.stringify(ENV)}`,
}}
/>
</head>
<body className="font-sans">
<MessageModalProvider>
@@ -219,11 +236,11 @@ export default function App() {
export function ErrorBoundary() {
const error = useRouteError();
// 为错误页面设置标题和描述
let title = "发生错误";
let message = "发生了一个未知错误,请稍后重试";
if (isRouteErrorResponse(error)) {
title = `错误 ${error.status}`;
message = error.data?.message || "发生了一个错误,请稍后重试";
@@ -231,7 +248,7 @@ export function ErrorBoundary() {
title = "意外错误";
message = "服务器发生了意外错误,请稍后重试";
}
return (
<html lang="zh-CN">
<head>
@@ -242,7 +259,7 @@ export function ErrorBoundary() {
<Links />
</head>
<body>
<AppErrorBoundary
<AppErrorBoundary
status={isRouteErrorResponse(error) ? error.status : 500}
statusText={isRouteErrorResponse(error) ? error.statusText : "服务器错误"}
message={message}
+26 -26
View File
@@ -20,18 +20,18 @@ export const meta: MetaFunction = () => {
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "logout") {
return logout(request);
}
return null;
}
// 验证用户登录状态
export async function loader({ request }: LoaderFunctionArgs) {
const { isAuthenticated, userRole } = await getUserSession(request);
if (!isAuthenticated) {
return redirect("/login");
}
@@ -45,12 +45,12 @@ export default function Index() {
date: '',
time: ''
});
// 打印服务器端传递的用户角色
useEffect(() => {
console.log('_index 服务器返回的用户角色:', userRole);
}, [userRole]);
// 更新日期时间
useEffect(() => {
const updateDateTime = () => {
@@ -61,16 +61,16 @@ export default function Index() {
time: now.format('HH:mm:ss')
});
};
// 初始化时间
updateDateTime();
// 每秒更新一次
const timerID = setInterval(updateDateTime, 1000);
return () => clearInterval(timerID);
}, []);
// 处理模块点击
const handleModuleClick = (path: string, reviewType: string) => {
// 将reviewType存入sessionStorage
@@ -93,7 +93,7 @@ export default function Index() {
if (typeof window !== 'undefined') {
sessionStorage.clear();
}
// 使用Form组件提交登出请求
const form = document.getElementById('logout-form') as HTMLFormElement;
if (form) {
@@ -110,7 +110,7 @@ export default function Index() {
<Form method="post" id="logout-form" className="hidden">
<input type="hidden" name="intent" value="logout" />
</Form>
{/* 头部 */}
<header className="header">
<div className="logo-container">
@@ -125,8 +125,8 @@ export default function Index() {
<div className="user">
<img src="/avatar.png" alt="用户头像" className="avatar" />
<span className="username">{userRole === 'developer' ? '系统管理员' : '普通用户'}</span>
<button
onClick={handleLogout}
<button
onClick={handleLogout}
className="logout-button"
aria-label="登出"
>
@@ -135,15 +135,15 @@ export default function Index() {
</div>
</div>
</header>
{/* 主要内容 */}
<main className="index-main-content">
<h1 className="welcome-text">- -</h1>
<div className="modules-container">
{/* 合同管理模块 */}
<div
className="module-card"
<div
className="module-card"
onClick={() => handleModuleClick('/contract-template/search', 'contract')}
onKeyDown={(e) => handleKeyDown('/contract-template/search', 'contract', e)}
role="button"
@@ -153,10 +153,10 @@ export default function Index() {
<i className="ri-file-list-2-fill text-[3rem] text-[#269b6c]"></i>
<span className="module-name"></span>
</div>
{/* 案卷智能评查模块 */}
<div
className="module-card"
<div
className="module-card"
onClick={() => handleModuleClick('/home', 'record')}
onKeyDown={(e) => handleKeyDown('/home', 'record', e)}
role="button"
@@ -166,12 +166,12 @@ export default function Index() {
<i className="ri-folder-shared-fill text-[3rem] text-[#269b6c]"></i>
<span className="module-name"></span>
</div>
{/* 智慧法务大模型模块 */}
<div
className="module-card"
onClick={() => handleModuleClick('/', 'model')}
onKeyDown={(e) => handleKeyDown('/', 'model', e)}
<div
className="module-card"
onClick={() => handleModuleClick('/chat-with-llm', 'model')}
onKeyDown={(e) => handleKeyDown('/chat-with-llm', 'model', e)}
role="button"
tabIndex={0}
aria-label="智慧法务大模型"
@@ -185,7 +185,7 @@ export default function Index() {
<footer className="footer">
<div className="mountains-bg"></div>
</footer>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo } from '../utils/session.server';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
const { user } = await getSessionInfo(request);
const body = await request.json();
const {
inputs,
query,
files,
conversation_id: conversationId,
response_mode: responseMode,
} = body;
// ('🚀 Chat Messages API - User:', user, 'Query:', query?.substring(0, 100));
const response = await difyClient.createChatMessage(
inputs,
query,
user,
responseMode,
conversationId,
files
);
// 对于流式响应,直接返回流
if (responseMode === 'streaming') {
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
// 对于非流式响应,返回JSON
return new Response(JSON.stringify(response), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
} catch (error: any) {
// console.error('❌ Chat Messages API - Error:', error);
return new Response(
JSON.stringify({ error: error.message || 'Failed to send message' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
export async function action({ request, params }: ActionFunctionArgs) {
try {
const { user, session } = await getSessionInfo(request);
const { id } = params;
if (!id) {
return json({ error: '会话ID不能为空' }, { status: 400 });
}
const body = await request.json();
const { auto_generate, name } = body;
// console.log('💬 Rename Conversation API - User:', user, 'ID:', id, 'Auto Generate:', auto_generate, 'Name:', name);
// 调用服务端API重命名会话
const data = await difyClient.renameConversation(id, name, user, auto_generate);
// console.log('✅ Rename Conversation API - Success:', data);
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error: any) {
console.error('❌ Rename Conversation API - Error:', error);
return json(
{
error: error.message || '重命名会话失败'
},
{
status: 500,
headers: {
'Set-Cookie': await commitSession((await getSessionInfo(request)).session),
},
}
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
export async function action({ request, params }: ActionFunctionArgs) {
try {
const { user, session } = await getSessionInfo(request);
const { id } = params;
if (!id) {
return json({ error: '会话ID不能为空' }, { status: 400 });
}
const method = request.method;
if (method === 'DELETE') {
// console.log('🗑️ Delete Conversation API - User:', user, 'ID:', id);
// 调用服务端API删除会话
const data = await difyClient.deleteConversation(id, user);
// console.log('✅ Delete Conversation API - Success:', data);
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
return json({ error: '不支持的请求方法' }, { status: 405 });
} catch (error: any) {
console.error('❌ Delete Conversation API - Error:', error);
return json(
{
error: error.message || '删除会话失败'
},
{
status: 500,
headers: {
'Set-Cookie': await commitSession((await getSessionInfo(request)).session),
},
}
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
export async function loader({ request }: LoaderFunctionArgs) {
try {
const { user, session } = await getSessionInfo(request);
// ('💬 Conversations API - User:', user);
const data = await difyClient.getConversations(user);
// ('✅ Conversations API - Success:', data);
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error: any) {
console.error('❌ Conversations API - Error:', error);
return json(
{
data: [],
error: error.message || 'Failed to fetch conversations'
},
{
status: 500,
headers: {
'Set-Cookie': await commitSession((await getSessionInfo(request)).session),
},
}
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
export async function action({ request }: ActionFunctionArgs) {
try {
const { user, session } = await getSessionInfo(request);
// console.log('💬 File Upload API - User:', user);
// 从请求中获取文件
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return json({ error: '没有找到文件' }, { status: 400 });
}
// 获取文件内容
const fileBuffer = await file.arrayBuffer();
// 这里需要在dify-client.server.ts中添加上传文件的方法
// 目前我们返回一个临时响应
// TODO: 实现文件上传功能
// 构造模拟响应
const uploadId = `upload_${Date.now()}`;
// console.log('✅ File Upload API - Success:', { id: uploadId, fileName: file.name, size: file.size });
return json({ id: uploadId }, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error: any) {
console.error('❌ File Upload API - Error:', error);
return json(
{
error: error.message || '文件上传失败'
},
{
status: 500,
headers: {
'Set-Cookie': await commitSession((await getSessionInfo(request)).session),
},
}
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
export async function loader({ request }: LoaderFunctionArgs) {
try {
const { user, session } = await getSessionInfo(request);
const url = new URL(request.url);
const conversationId = url.searchParams.get('conversation_id');
if (!conversationId) {
return json(
{ error: 'conversation_id is required' },
{ status: 400 }
);
}
// ('📨 Messages API - User:', user, 'ConversationId:', conversationId);
const data = await difyClient.getConversationMessages(user, conversationId);
// ('✅ Messages API - Success:', data);
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error: any) {
console.error('❌ Messages API - Error:', error);
return json(
{ error: error.message || 'Failed to fetch messages' },
{
status: 500,
headers: {
'Set-Cookie': await commitSession((await getSessionInfo(request)).session),
},
}
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
export async function loader({ request }: LoaderFunctionArgs) {
try {
const { user, session } = await getSessionInfo(request);
// ('📋 Parameters API - User:', user);
const data = await difyClient.getApplicationParameters(user);
// ('✅ Parameters API - Success:', data);
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
} catch (error: any) {
console.error('❌ Parameters API - Error:', error);
return json(
{ error: error.message || 'Failed to fetch parameters' },
{
status: 500,
headers: {
'Set-Cookie': await commitSession((await getSessionInfo(request)).session),
},
}
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { json } from "@remix-run/node";
import { type MetaFunction } from "@remix-run/node";
import Chat from "~/components/chat";
import chatIndexStyles from "~/styles/components/chat-with-llm/index.css?url";
import chatMessageStyles from "~/styles/components/chat-with-llm/chat-message.css?url";
import chatInputStyles from "~/styles/components/chat-with-llm/chat-input.css?url";
import chatSidebarStyles from "~/styles/components/chat-with-llm/sidebar.css?url";
import chatThoughtProcessStyles from "~/styles/components/chat-with-llm/thought-process.css?url";
import chatMarkdownStyles from "~/styles/components/chat-with-llm/markdown.css?url";
export function links() {
return [
{ rel: "stylesheet", href: chatIndexStyles },
{ rel: "stylesheet", href: chatMessageStyles },
{ rel: "stylesheet", href: chatInputStyles },
{ rel: "stylesheet", href: chatSidebarStyles },
{ rel: "stylesheet", href: chatThoughtProcessStyles },
{ rel: "stylesheet", href: chatMarkdownStyles }
];
}
export const meta: MetaFunction = () => {
return [
{ title: "AI对话 - 中国烟草AI合同及卷宗审核系统" },
{
name: "description",
content: "与AI助手进行智能对话,获取专业的法务建议和文档分析"
}
];
};
/**
* 聊天主页面
* 实现单页面应用模式,所有会话切换都在同一页面内完成
*/
export default function ChatWithLLMIndex() {
return (
<div className="h-full chat-container">
<Chat />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Outlet } from "@remix-run/react";
import { type MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "AI对话 - 中国烟草AI合同及卷宗审核系统" },
{
name: "chat-with-llm",
content: "AI对话模块,包括AI对话功能"
}
];
};
export const handle = {
breadcrumb: "AI对话"
};
/**
* 配置列表路由布局
*/
export default function ContractSearchLayout() {
return <Outlet />;
}
+580
View File
@@ -0,0 +1,580 @@
import { CHAT_CONFIG, ContentType, SSE_TIMEOUT } from '../config/chat';
import type { Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat';
import { unicodeToChar } from '../utils/chat-utils';
// 基础请求选项
const baseOptions = {
method: 'GET',
mode: 'cors' as RequestMode,
credentials: 'include' as RequestCredentials,
headers: new Headers({
'Content-Type': ContentType.json,
}),
redirect: 'follow' as RequestRedirect,
};
// 回调接口定义
export type IOnDataMoreInfo = {
conversationId?: string;
taskId?: string;
messageId: string;
errorMessage?: string;
errorCode?: string;
}
export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void;
export type IOnThought = (thought: ThoughtItem) => void;
export type IOnFile = (file: VisionFile) => void;
export type IOnMessageEnd = (messageEnd: MessageEnd) => void;
export type IOnMessageReplace = (messageReplace: MessageReplace) => void;
export type IOnCompleted = (hasError?: boolean) => void;
export type IOnError = (msg: string, code?: string) => void;
// 工作流相关类型
export type WorkflowStartedResponse = {
task_id: string;
workflow_run_id: string;
event: string;
data: {
id: string;
workflow_id: string;
sequence_number: number;
created_at: number;
};
}
export type WorkflowFinishedResponse = {
task_id: string;
workflow_run_id: string;
event: string;
data: {
id: string;
workflow_id: string;
status: string;
outputs: any;
error: string;
elapsed_time: number;
total_tokens: number;
total_steps: number;
created_at: number;
finished_at: number;
};
}
export type NodeStartedResponse = {
task_id: string;
workflow_run_id: string;
event: string;
data: {
id: string;
node_id: string;
node_type: string;
index: number;
predecessor_node_id?: string;
inputs: any;
created_at: number;
extras?: any;
};
}
export type NodeFinishedResponse = {
task_id: string;
workflow_run_id: string;
event: string;
data: {
id: string;
node_id: string;
node_type: string;
index: number;
predecessor_node_id?: string;
inputs: any;
process_data: any;
outputs: any;
status: string;
error: string;
elapsed_time: number;
execution_metadata: {
total_tokens: number;
total_price: number;
currency: string;
};
created_at: number;
};
}
export type IOnWorkflowStarted = (workflowStarted: WorkflowStartedResponse) => void;
export type IOnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) => void;
export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void;
export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void;
// 处理流式响应
const handleStream = (
response: Response,
onData: IOnData,
onCompleted?: IOnCompleted,
onThought?: IOnThought,
onMessageEnd?: IOnMessageEnd,
onMessageReplace?: IOnMessageReplace,
onFile?: IOnFile,
onWorkflowStarted?: IOnWorkflowStarted,
onWorkflowFinished?: IOnWorkflowFinished,
onNodeStarted?: IOnNodeStarted,
onNodeFinished?: IOnNodeFinished,
onError?: IOnError,
) => {
if (!response.ok) {
onError?.('网络响应错误');
throw new Error('网络响应错误');
}
const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let bufferObj: Record<string, any>;
let isFirstMessage = true;
function read() {
let hasError = false;
reader?.read().then((result: any) => {
if (result.done) {
onCompleted && onCompleted();
return;
}
buffer += decoder.decode(result.value, { stream: true });
const lines = buffer.split('\n');
try {
lines.forEach((message) => {
if (message.startsWith('data: ')) {
try {
bufferObj = JSON.parse(message.substring(6)) as Record<string, any>;
}
catch (e) {
// 处理消息截断
onData('', isFirstMessage, {
conversationId: bufferObj?.conversation_id,
messageId: bufferObj?.message_id || bufferObj?.id,
});
return;
}
if (bufferObj.status === 400 || !bufferObj.event) {
onData('', false, {
conversationId: undefined,
messageId: '',
errorMessage: bufferObj?.message,
errorCode: bufferObj?.code,
});
hasError = true;
onCompleted?.(true);
return;
}
if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') {
onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
conversationId: bufferObj.conversation_id,
messageId: bufferObj.id || bufferObj.message_id,
taskId: bufferObj.task_id,
});
isFirstMessage = false;
} else if (bufferObj.event === 'agent_thought' && onThought) {
onThought(bufferObj as ThoughtItem);
} else if (bufferObj.event === 'message_file' && onFile) {
onFile(bufferObj as VisionFile);
} else if (bufferObj.event === 'message_end' && onMessageEnd) {
onMessageEnd(bufferObj as MessageEnd);
} else if (bufferObj.event === 'message_replace' && onMessageReplace) {
onMessageReplace(bufferObj as MessageReplace);
} else if (bufferObj.event === 'workflow_started' && onWorkflowStarted) {
onWorkflowStarted(bufferObj as WorkflowStartedResponse);
} else if (bufferObj.event === 'workflow_finished' && onWorkflowFinished) {
onWorkflowFinished(bufferObj as WorkflowFinishedResponse);
} else if (bufferObj.event === 'node_started' && onNodeStarted) {
onNodeStarted(bufferObj as NodeStartedResponse);
} else if (bufferObj.event === 'node_finished' && onNodeFinished) {
onNodeFinished(bufferObj as NodeFinishedResponse);
}
}
});
// 保留最后一行(可能是不完整的消息)
buffer = lines[lines.length - 1];
}
catch (err) {
console.error('解析响应时出错:', err);
onData('', false, {
conversationId: undefined,
messageId: '',
errorMessage: `${err}`,
});
hasError = true;
onCompleted?.(true);
return;
}
if (!hasError)
read();
}).catch(err => {
console.error('读取流时出错:', err);
onError?.(err.message);
});
}
read();
};
// 基础Fetch函数
const baseFetch = (url: string, fetchOptions: any, needAllResponseContent: boolean = false) => {
const options = Object.assign({}, baseOptions, fetchOptions);
// 构建完整URL - 修复重复的/v1问题
// CHAT_CONFIG.API_URL 已经包含了 /v1,所以不需要再添加 API_PREFIX
let urlWithPrefix = `${CHAT_CONFIG.API_URL}/${url.replace(/^\//, '')}`;
const { method, params, body } = options;
// 处理GET请求的查询参数
if (method === 'GET' && params) {
const paramsArray: string[] = [];
Object.keys(params).forEach(key =>
paramsArray.push(`${key}=${encodeURIComponent(params[key])}`),
);
if (urlWithPrefix.search(/\?/) === -1)
urlWithPrefix += `?${paramsArray.join('&')}`;
else
urlWithPrefix += `&${paramsArray.join('&')}`;
delete options.params;
}
// 处理请求体
if (body && typeof body === 'object')
options.body = JSON.stringify(body);
// 添加认证头
if (!options.headers)
options.headers = {};
if (CHAT_CONFIG.API_KEY) {
options.headers['Authorization'] = `Bearer ${CHAT_CONFIG.API_KEY}`;
}
return Promise.race([
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('请求超时'));
}, SSE_TIMEOUT);
}),
new Promise((resolve, reject) => {
fetch(urlWithPrefix, options)
.then((res: Response) => {
const resClone = res.clone();
('📥 API Response:', {
status: res.status,
statusText: res.statusText,
url: urlWithPrefix
});
// 错误处理
if (!/^(2|3)\d{2}$/.test(res.status.toString())) {
try {
const bodyJson = res.json();
switch (res.status) {
case 401:
console.error('❌ Invalid token');
break;
default:
bodyJson.then((data: any) => {
console.error('❌ API Error:', data.message);
});
}
}
catch (e) {
console.error('❌ Response Error:', e);
}
return Promise.reject(resClone);
}
// 处理删除API204状态码)
if (res.status === 204) {
resolve({ result: 'success' });
return;
}
// 返回数据
const contentType = res.headers.get('Content-Type') || '';
const data = contentType.includes('application/octet-stream') ? res.blob() : res.json();
resolve(needAllResponseContent ? resClone : data);
})
.catch((err) => {
console.error('❌ Fetch Error:', err);
reject(err);
});
}),
]);
};
// SSE POST 请求
export const ssePost = (
url: string,
fetchOptions: any,
{
onData,
onCompleted,
onThought,
onFile,
onMessageEnd,
onMessageReplace,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onError,
getAbortController,
}: {
onData: IOnData;
onCompleted?: IOnCompleted;
onThought?: IOnThought;
onFile?: IOnFile;
onMessageEnd?: IOnMessageEnd;
onMessageReplace?: IOnMessageReplace;
onError?: IOnError;
getAbortController?: (abortController: AbortController) => void;
onWorkflowStarted?: IOnWorkflowStarted;
onWorkflowFinished?: IOnWorkflowFinished;
onNodeStarted?: IOnNodeStarted;
onNodeFinished?: IOnNodeFinished;
},
) => {
const options = Object.assign({}, baseOptions, {
method: 'POST',
}, fetchOptions);
// 修复URL构建逻辑,与baseFetch保持一致
const urlWithPrefix = `${CHAT_CONFIG.API_URL}/${url.replace(/^\//, '')}`;
const controller = new AbortController();
if (getAbortController)
getAbortController(controller);
options.headers = {
...options.headers,
'Content-Type': 'application/json',
'Accept': ContentType.stream,
};
if (CHAT_CONFIG.API_KEY) {
options.headers['Authorization'] = `Bearer ${CHAT_CONFIG.API_KEY}`;
}
options.signal = controller.signal;
const { body } = options;
if (body && typeof body === 'object')
options.body = JSON.stringify(body);
return fetch(urlWithPrefix, options)
.then((res: Response) => {
('📡 SSE Response:', {
status: res.status,
statusText: res.statusText,
url: urlWithPrefix
});
if (!/^(2|3)\d{2}$/.test(res.status.toString())) {
res.json().then((data: any) => {
console.error('❌ SSE Error:', data.message || 'Server Error');
onError?.(data.message || 'Server Error');
});
return;
}
handleStream(
res,
onData,
onCompleted,
onThought,
onMessageEnd,
onMessageReplace,
onFile,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onError
);
})
.catch((err) => {
console.error('❌ SSE Request Error:', err);
onError?.(err.message);
});
};
// 公共请求函数
export const request = (url: string, options = {}, needAllResponseContent = false) => {
return baseFetch(url, { ...baseOptions, ...options }, needAllResponseContent);
};
// GET 请求
export const get = (url: string, options = {}) => {
return request(url, { ...options, method: 'GET' });
};
// POST 请求
export const post = (url: string, options = {}) => {
return request(url, { ...options, method: 'POST' });
};
// PUT 请求
export const put = (url: string, options = {}) => {
return request(url, { ...options, method: 'PUT' });
};
// DELETE 请求
export const del = (url: string, options = {}) => {
return request(url, { ...options, method: 'DELETE' });
};
// 发送聊天消息
export const sendChatMessage = async (
body: Record<string, any>,
{
onData,
onCompleted,
onThought,
onFile,
onError,
getAbortController,
onMessageEnd,
onMessageReplace,
onWorkflowStarted,
onNodeStarted,
onNodeFinished,
onWorkflowFinished,
}: {
onData: IOnData;
onCompleted: IOnCompleted;
onFile?: IOnFile;
onThought?: IOnThought;
onMessageEnd?: IOnMessageEnd;
onMessageReplace?: IOnMessageReplace;
onError?: IOnError;
getAbortController?: (abortController: AbortController) => void;
onWorkflowStarted?: IOnWorkflowStarted;
onNodeStarted?: IOnNodeStarted;
onNodeFinished?: IOnNodeFinished;
onWorkflowFinished?: IOnWorkflowFinished;
},
) => {
return ssePost('chat-messages', {
body: {
...body,
response_mode: 'streaming',
},
}, {
onData,
onCompleted,
onThought,
onFile,
onError,
getAbortController,
onMessageEnd,
onMessageReplace,
onNodeStarted,
onWorkflowStarted,
onWorkflowFinished,
onNodeFinished
});
};
// 获取会话列表
export const fetchConversations = async () => {
return get('conversations', {
params: { limit: 100, first_id: '' },
});
};
// 获取聊天消息列表
export const fetchChatList = async (conversationId: string) => {
return get('messages', {
params: { conversation_id: conversationId, limit: 20, last_id: '' },
});
};
// 获取应用参数
export const fetchAppParams = async () => {
return get('parameters');
};
// 更新反馈
export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => {
return post(url, { body });
};
// 生成会话名称
export const generateConversationName = async (id: string) => {
return post(`conversations/${id}/name`, {
body: { auto_generate: true },
});
};
// 重命名会话
export const renameConversation = async (id: string, name: string, autoGenerate: boolean = false) => {
return post(`conversations/${id}/name`, {
body: {
name: autoGenerate ? undefined : name,
auto_generate: autoGenerate
},
});
};
// 删除会话
export const deleteConversation = async (id: string) => {
return del(`conversations/${id}`);
};
// 文件上传
export const upload = (fetchOptions: any): Promise<any> => {
const urlPrefix = CHAT_CONFIG.API_PREFIX;
const urlWithPrefix = `${CHAT_CONFIG.API_URL}${urlPrefix}/file-upload`;
const defaultOptions = {
method: 'POST',
url: urlWithPrefix,
data: {},
};
const options = {
...defaultOptions,
...fetchOptions,
};
return new Promise((resolve, reject) => {
const xhr = options.xhr;
xhr.open(options.method, options.url);
for (const key in options.headers)
xhr.setRequestHeader(key, options.headers[key]);
if (CHAT_CONFIG.API_KEY) {
xhr.setRequestHeader('Authorization', `Bearer ${CHAT_CONFIG.API_KEY}`);
}
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200)
resolve({ id: xhr.response });
else
reject(xhr);
}
};
xhr.upload.onprogress = options.onprogress;
xhr.send(options.data);
});
};
+185
View File
@@ -0,0 +1,185 @@
import { CHAT_CONFIG } from '../config/chat';
// 获取环境变量的服务端函数
const getServerEnvVar = (name: string, defaultValue: string = '') => {
return process.env[name] || defaultValue;
};
// Dify API 客户端配置
const DIFY_CONFIG = {
API_URL: getServerEnvVar('NEXT_PUBLIC_API_URL', 'https://api.dify.ai/v1'),
API_KEY: getServerEnvVar('NEXT_PUBLIC_APP_KEY', ''),
APP_ID: (() => {
const rawAppId = getServerEnvVar('NEXT_PUBLIC_APP_ID', '');
// 从完整URL中提取APP ID
const match = rawAppId.match(/\/app\/([a-f0-9-]{36})/);
return match ? match[1] : rawAppId;
})(),
};
// console.log('🔧 Dify Client Config:', {
// apiUrl: DIFY_CONFIG.API_URL,
// appId: DIFY_CONFIG.APP_ID,
// hasApiKey: !!DIFY_CONFIG.API_KEY
// });
// 基础请求函数
const difyFetch = async (endpoint: string, options: RequestInit = {}) => {
const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`;
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${DIFY_CONFIG.API_KEY}`,
...options.headers,
};
// console.log('🌐 Dify API Request:', {
// url,
// method: options.method || 'GET',
// hasAuth: !!DIFY_CONFIG.API_KEY
// });
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Dify API Error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
throw new Error(`Dify API Error: ${response.status} ${response.statusText}`);
}
return response;
};
// 生成用户ID
const generateUserId = (sessionId: string) => {
return `user_${DIFY_CONFIG.APP_ID}:${sessionId}`;
};
// Dify API 客户端
export const difyClient = {
// 获取应用参数
async getApplicationParameters(user: string) {
const response = await difyFetch('parameters', {
method: 'GET',
headers: {
'Authorization': `Bearer ${DIFY_CONFIG.API_KEY}`,
},
});
return response.json();
},
// 获取会话列表
async getConversations(user: string) {
const params = new URLSearchParams({
user,
limit: '100',
first_id: '',
});
const response = await difyFetch(`conversations?${params}`, {
method: 'GET',
});
return response.json();
},
// 获取会话消息
async getConversationMessages(user: string, conversationId: string) {
const params = new URLSearchParams({
user,
conversation_id: conversationId,
limit: '20',
last_id: '',
});
const response = await difyFetch(`messages?${params}`, {
method: 'GET',
});
return response.json();
},
// 发送聊天消息
async createChatMessage(
inputs: Record<string, any>,
query: string,
user: string,
responseMode: string = 'streaming',
conversationId?: string,
files?: any[]
) {
const body = {
inputs,
query,
user,
response_mode: responseMode,
conversation_id: conversationId,
files: files || [],
};
const response = await difyFetch('chat-messages', {
method: 'POST',
body: JSON.stringify(body),
});
// 对于流式响应,直接返回Response对象
if (responseMode === 'streaming') {
return response;
}
return response.json();
},
// 重命名会话
async renameConversation(conversationId: string, name: string, user: string, autoGenerate: boolean = false) {
const body = {
name,
auto_generate: autoGenerate,
user,
};
const response = await difyFetch(`conversations/${conversationId}/name`, {
method: 'POST',
body: JSON.stringify(body),
});
return response.json();
},
// 删除会话
async deleteConversation(conversationId: string, user: string) {
const body = {
user,
};
const response = await difyFetch(`conversations/${conversationId}`, {
method: 'DELETE',
body: JSON.stringify(body),
});
return response.json();
},
// 更新消息反馈
async updateMessageFeedback(messageId: string, rating: 'like' | 'dislike' | null, user: string) {
const body = {
rating,
user,
};
const response = await difyFetch(`messages/${messageId}/feedbacks`, {
method: 'POST',
body: JSON.stringify(body),
});
return response.json();
},
};
// 工具函数
export const difyUtils = {
generateUserId,
getConfig: () => DIFY_CONFIG,
};
@@ -0,0 +1,27 @@
/* 聊天输入区域 */
.chat-input-container {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #e5e7eb;
padding: 16px 20px;
z-index: 10;
}
.chat-input-wrapper {
max-width: 100%;
margin: 0 auto;
position: relative;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chat-input-container {
padding: 12px 16px;
}
}
@media (max-width: 480px) {
.chat-input-container {
padding: 8px 12px;
}
}
@@ -0,0 +1,132 @@
/* 消息项样式 */
.chat-message {
margin-bottom: 20px;
animation: fadeInUp 0.3s ease-out;
max-width: 100%;
}
/* 消息卡片 */
.message-card {
max-width: 85%;
word-wrap: break-word;
word-break: break-word;
}
.message-card.user {
margin-left: auto;
}
.message-card.assistant {
margin-right: auto;
}
/* 流式文本效果 */
.streaming-text {
position: relative;
}
.streaming-text::after {
content: '';
/* 移除光标 */
animation: blink 1s infinite;
color: #1890ff;
margin-left: 2px;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
/* 消息动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应指示器 */
.responding-indicator {
display: flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 14px;
}
/* 反馈按钮容器 */
.feedback-buttons {
margin-top: 12px;
display: flex;
gap: 8px;
}
/* 建议问题 */
.suggested-questions {
margin-top: 16px;
}
.question-button {
margin-bottom: 8px;
margin-right: 8px;
white-space: normal;
height: auto;
padding: 8px 12px;
text-align: left;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #374151;
transition: all 0.2s ease;
}
.question-button:hover {
border-color: #1890ff;
background: #f0f9ff;
color: #1890ff;
}
/* 消息时间戳 */
.message-timestamp {
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
display: flex;
gap: 12px;
}
/* 消息图片 */
.message-images {
margin-top: 12px;
}
.message-images .ant-image {
border-radius: 8px;
overflow: hidden;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-card {
max-width: 95%;
}
}
@media (max-width: 480px) {
.message-card {
max-width: 100%;
}
}
@@ -0,0 +1,246 @@
/* 聊天布局样式 */
/* 聊天容器 - 自适应布局 */
.chat-container {
height: 400px;
width: 100%;
flex-direction: column;
background: #fff;
overflow: hidden;
position: relative;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin: 20px auto;
}
/* 聊天头部 */
.chat-header {
flex-shrink: 0;
height: 60px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 10;
}
.chat-header h1 {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
/* 聊天消息列表容器 */
.chat-messages {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
scroll-behavior: smooth;
background: #f9fafb;
position: relative;
}
/* 自定义滚动条 */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 新对话欢迎界面 */
.chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: 40px 20px;
}
.chat-welcome h3 {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
}
.chat-welcome p {
font-size: 16px;
color: #6b7280;
margin-bottom: 0;
}
/* 加载状态 */
.chat-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
.chat-loading .ant-spin {
font-size: 24px;
}
/* 错误状态 */
.chat-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: 40px 20px;
}
.chat-error h2 {
font-size: 20px;
font-weight: 600;
color: #dc2626;
margin-bottom: 12px;
}
.chat-error p {
font-size: 14px;
color: #6b7280;
margin-bottom: 0;
}
/* 确保聊天容器在主内容区域中占满全部空间 */
.main-content .chat-container {
height: calc(89vh - 0px);
/* 减去任何顶部导航栏的高度 */
}
/* 如果有面包屑导航,需要调整高度 */
.main-content .breadcrumb+.chat-container {
height: calc(100vh - 60px);
/* 减去面包屑的高度 */
}
/* 侧边栏滚动区域样式 */
.h-full.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.h-full.overflow-y-auto::-webkit-scrollbar-track {
background: #f8f9fa;
border-radius: 3px;
}
.h-full.overflow-y-auto::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
transition: background-color 0.2s ease;
}
.h-full.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Firefox 滚动条样式 */
.h-full.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: #d1d5db #f8f9fa;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chat-container {
height: 100vh;
}
.chat-messages {
padding: 16px;
}
.chat-header {
padding: 0 16px;
height: 56px;
}
.chat-header h1 {
font-size: 16px;
}
}
@media (max-width: 480px) {
.chat-messages {
padding: 12px;
}
.chat-welcome {
padding: 20px 16px;
}
.chat-welcome h3 {
font-size: 20px;
}
.chat-welcome p {
font-size: 14px;
}
}
/* 全局按钮主题色统一 */
.ant-btn-primary {
background-color: rgb(0, 104, 74) !important;
border-color: rgb(0, 104, 74) !important;
}
.ant-btn-primary:hover {
background-color: rgba(0, 104, 74, 0.8) !important;
border-color: rgba(0, 104, 74, 0.8) !important;
}
.ant-btn-primary:focus {
background-color: rgb(0, 104, 74) !important;
border-color: rgb(0, 104, 74) !important;
}
.ant-btn-primary:active {
background-color: rgba(0, 104, 74, 0.9) !important;
border-color: rgba(0, 104, 74, 0.9) !important;
}
/* 禁用状态保持原样 */
.ant-btn-primary:disabled {
background-color: rgba(0, 0, 0, 0.04) !important;
border-color: #d9d9d9 !important;
color: rgba(0, 0, 0, 0.25) !important;
}
/* 链接按钮主题色 */
.ant-btn-link {
color: rgb(0, 104, 74) !important;
}
.ant-btn-link:hover {
color: rgba(0, 104, 74, 0.8) !important;
}
.ant-btn-link:focus {
color: rgb(0, 104, 74) !important;
}
.ant-btn-link:active {
color: rgba(0, 104, 74, 0.9) !important;
}
@@ -0,0 +1,136 @@
/* Markdown 样式 */
.markdown-content {
font-size: 14px;
line-height: 1.6;
color: #374151;
overflow-wrap: break-word;
}
/* 标题样式 */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
.markdown-content h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.markdown-content h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.markdown-content h3 {
font-size: 1.25em;
}
.markdown-content h4 {
font-size: 1em;
}
/* 段落样式 */
.markdown-content p {
margin: 0.75em 0;
}
/* 列表样式 */
.markdown-content ul,
.markdown-content ol {
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1.5em;
}
.markdown-content li {
margin: 0.3em 0;
}
/* 代码样式 */
.markdown-content code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
.markdown-content pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 6px;
margin: 1em 0;
}
.markdown-content pre code {
padding: 0;
margin: 0;
background-color: transparent;
border: 0;
word-break: normal;
white-space: pre;
}
/* 表格样式 */
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
overflow: auto;
}
.markdown-content table th,
.markdown-content table td {
padding: 8px 16px;
border: 1px solid #dfe2e5;
}
.markdown-content table th {
font-weight: 600;
background-color: #f6f8fa;
}
.markdown-content table tr:nth-child(2n) {
background-color: #f6f8fa;
}
/* 链接样式 */
.markdown-content a {
color: #0969da;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
/* 引用样式 */
.markdown-content blockquote {
margin: 1em 0;
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
}
/* 水平线样式 */
.markdown-content hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
@@ -0,0 +1,74 @@
/* 聊天侧边栏样式 */
.chat-sidebar-menu .ant-menu-item {
margin: 4px 0;
border-radius: 6px;
height: auto;
line-height: 1.4;
padding: 8px 12px;
}
.chat-sidebar-menu .ant-menu-item:hover {
background-color: #f5f5f5;
}
.chat-sidebar-menu .ant-menu-item-selected {
background-color: rgba(0, 104, 74, 0.1);
border-color: rgb(0, 104, 74);
}
.chat-sidebar-menu .ant-menu-item-selected::after {
border-right: 3px solid rgb(0, 104, 74);
}
/* 会话项样式 */
.chat-sidebar-menu .ant-menu-item .ant-menu-title-content {
width: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.ant-layout-sider {
position: fixed !important;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
}
.ant-layout-sider.ant-layout-sider-collapsed {
left: -200px;
}
}
/* 滚动条样式 */
.chat-sidebar-menu::-webkit-scrollbar {
width: 4px;
}
.chat-sidebar-menu::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.chat-sidebar-menu::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 2px;
}
.chat-sidebar-menu::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 确保侧边栏布局正确 */
.ant-layout-sider {
display: flex !important;
flex-direction: column !important;
}
/* 侧边栏内容区域样式 */
.ant-layout-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
@@ -0,0 +1,57 @@
/* 思考过程样式 */
.thought-process-card {
margin-bottom: 12px;
border-left: 4px solid #1890ff;
background-color: #f0f8ff;
}
.thought-process-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.thought-process-tool-icon {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #1890ff;
}
.thought-process-content {
padding: 0 12px 12px;
}
.thought-process-collapsed {
max-height: 150px;
overflow: hidden;
position: relative;
}
.thought-process-collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to bottom, rgba(240, 248, 255, 0), rgba(240, 248, 255, 1));
}
.thought-process-observation {
margin-top: 8px;
padding: 8px 12px;
background-color: #f0fff0;
border: 1px solid #b7eb8f;
border-radius: 4px;
}
.thought-process-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: #1890ff;
}
+264
View File
@@ -0,0 +1,264 @@
// 应用信息类型
export interface AppInfo {
title: string;
description: string;
copyright: string;
privacy_policy: string;
default_language: string;
}
// 会话项类型
export interface ConversationItem {
id: string;
name: string;
inputs?: Record<string, any>;
introduction?: string;
}
// 聊天消息类型
export interface ChatItem {
id: string;
content: string;
isAnswer: boolean;
feedback?: Feedbacktype;
agent_thoughts?: ThoughtItem[];
message_files?: VisionFile[];
isError?: boolean;
workflow_run_id?: string;
workflowProcess?: WorkflowProcess;
more?: MessageMore;
useCurrentUserAvatar?: boolean;
isOpeningStatement?: boolean;
suggestedQuestions?: string[];
}
// 消息更多信息类型
export interface MessageMore {
time: string;
tokens: number;
latency: number | string;
}
// 反馈类型
export type Feedbacktype = {
rating: 'like' | 'dislike' | null;
content?: string;
}
// 思考过程类型
export interface ThoughtItem {
id?: string;
chain_id?: string;
thought?: string;
observation?: string;
message_files?: VisionFile[];
tool_name?: string;
tool_input?: string;
tool_output?: string;
tool_finished?: boolean;
parent_id?: string;
children_ids?: string[];
sort?: number;
message_id?: string;
tool?: string;
position?: number;
files?: string[];
}
// 文本类型表单项
export interface TextTypeFormItem {
label: string;
variable: string;
required: boolean;
max_length: number;
}
// 选择类型表单项
export interface SelectTypeFormItem {
label: string;
variable: string;
required: boolean;
options: string[];
}
// 用户输入表单项
export type UserInputFormItem = {
'text-input': TextTypeFormItem;
} | {
'select': SelectTypeFormItem;
} | {
'paragraph': TextTypeFormItem;
}
// 提示配置类型
export interface PromptConfig {
prompt_template: string;
prompt_variables: PromptVariable[];
}
// 提示变量类型
export interface PromptVariable {
key: string;
name: string;
type: string;
required: boolean;
options?: string[];
max_length?: number;
allowed_file_extensions?: string[];
allowed_file_types?: string[];
allowed_file_upload_methods?: TransferMethod[];
}
// 视觉文件类型
export interface VisionFile {
id?: string;
type: string;
transfer_method: TransferMethod;
url?: string;
upload_file_id?: string;
belongs_to?: string;
usage?: string;
result?: any;
detail?: Resolution;
}
// 图片文件类型
export interface ImageFile {
type: TransferMethod;
_id: string;
fileId: string;
file?: File;
progress: number;
url: string;
base64Url?: string;
deleted?: boolean;
}
// 视觉设置类型
export interface VisionSettings {
enabled: boolean;
detail?: Resolution;
number_limits?: number;
transfer_methods?: TransferMethod[];
image_file_size_limit?: number | string;
}
// 分辨率枚举
export enum Resolution {
low = 'low',
high = 'high',
}
// 传输方法枚举
export enum TransferMethod {
local_file = 'local_file',
remote_url = 'remote_url',
all = 'all',
}
// 工作流运行状态枚举
export enum WorkflowRunningStatus {
init = 'init',
running = 'running',
completed = 'completed',
error = 'error',
waiting = 'waiting',
succeeded = 'succeeded',
failed = 'failed',
stopped = 'stopped',
}
// 节点运行状态枚举
export enum NodeRunningStatus {
NotStart = 'not-start',
Waiting = 'waiting',
Running = 'running',
Succeeded = 'succeeded',
Failed = 'failed',
}
// 块类型枚举
export enum BlockEnum {
Start = 'start',
End = 'end',
Answer = 'answer',
LLM = 'llm',
KnowledgeRetrieval = 'knowledge-retrieval',
QuestionClassifier = 'question-classifier',
IfElse = 'if-else',
Code = 'code',
TemplateTransform = 'template-transform',
HttpRequest = 'http-request',
VariableAssigner = 'variable-assigner',
Tool = 'tool',
}
// 节点追踪类型
export interface NodeTracing {
id: string;
index: number;
predecessor_node_id: string;
node_id: string;
node_type: BlockEnum;
title: string;
inputs: any;
process_data: any;
outputs?: any;
status: string;
error?: string;
elapsed_time: number;
execution_metadata: {
total_tokens: number;
total_price: number;
currency: string;
};
created_at: number;
created_by: {
id: string;
name: string;
email: string;
};
finished_at: number;
extras?: any;
expand?: boolean; // for UI
}
// 工作流进程类型
export interface WorkflowProcess {
status: WorkflowRunningStatus;
tracing: NodeTracing[];
expand?: boolean; // for UI
}
// 代码语言枚举
export enum CodeLanguage {
python3 = 'python3',
javascript = 'javascript',
json = 'json',
}
// 消息事件类型
export interface MessageEvent {
event: string;
task_id: string;
conversation_id: string;
message_id: string;
answer: string;
}
// 消息替换类型
export interface MessageReplace {
event: string;
task_id: string;
conversation_id: string;
message_id: string;
answer: string;
}
// 消息结束类型
export interface MessageEnd {
event: string;
task_id: string;
conversation_id: string;
message_id: string;
}
+123
View File
@@ -0,0 +1,123 @@
import type { PromptVariable, ThoughtItem, UserInputFormItem, VisionFile } from '../types/dify_chat';
/**
* 替换提示模板中的变量
* @param str 提示模板字符串
* @param promptVariables 提示变量列表
* @param inputs 输入值
* @returns 替换后的字符串
*/
export function replaceVarWithValues(str: string, promptVariables: PromptVariable[], inputs: Record<string, any>) {
return str.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const name = inputs[key];
if (name)
return name;
const valueObj: PromptVariable | undefined = promptVariables.find(v => v.key === key);
return valueObj ? `{{${valueObj.key}}}` : match;
});
}
/**
* 将用户输入表单转换为提示变量
* @param useInputs 用户输入表单项列表
* @returns 提示变量列表
*/
export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] | null) => {
if (!useInputs)
return [];
const promptVariables: PromptVariable[] = [];
useInputs.forEach((item: any) => {
const [type, content] = (() => {
const type = Object.keys(item)[0];
return [type === 'text-input' ? 'string' : type, item[type]];
})();
if (type === 'string' || type === 'paragraph') {
promptVariables.push({
key: content.variable,
name: content.label,
required: content.required,
type,
max_length: content.max_length,
options: [],
});
}
else if (type === 'number') {
promptVariables.push({
key: content.variable,
name: content.label,
required: content.required,
type,
options: [],
});
}
else if (type === 'file' || type === 'file-list') {
promptVariables.push({
...content,
key: content.variable,
name: content.label,
required: content.required,
type,
max_length: content.max_length,
options: [],
});
}
else {
promptVariables.push({
key: content.variable,
name: content.label,
required: content.required,
type: 'select',
options: content.options,
});
}
});
return promptVariables;
};
/**
* 排序代理思考列表
* @param list 思考列表
* @returns 排序后的列表
*/
export const sortAgentSorts = (list: ThoughtItem[]) => {
if (!list)
return list;
if (list.some(item => (item as any).position === undefined))
return list;
const temp = [...list];
temp.sort((a, b) => (a as any).position - (b as any).position);
return temp;
};
/**
* 为思考列表添加文件信息
* @param list 思考列表
* @param messageFiles 消息文件列表
* @returns 添加文件信息后的列表
*/
export const addFileInfos = (list: ThoughtItem[], messageFiles: VisionFile[]) => {
if (!list || !messageFiles)
return list;
return list.map((item) => {
if ((item as any).files && (item as any).files?.length > 0) {
return {
...item,
message_files: (item as any).files.map((fileId: string) => messageFiles.find(file => file.id === fileId)) as VisionFile[],
};
}
return item;
});
};
/**
* 将Unicode编码转换为字符
* @param text 包含Unicode编码的文本
* @returns 转换后的文本
*/
export function unicodeToChar(text: string) {
return text.replace(/\\u[0-9a-f]{4}/g, (match) => {
return String.fromCharCode(parseInt(match.substring(2), 16));
});
}
+53
View File
@@ -0,0 +1,53 @@
import { createCookieSessionStorage } from '@remix-run/node';
import { v4 as uuidv4 } from 'uuid';
import { difyUtils } from '../services/dify-client.server';
// 创建会话存储
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__dify_session',
httpOnly: true,
path: '/',
sameSite: 'lax',
secrets: ['dify-chat-secret'], // 在生产环境中应该使用环境变量
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30, // 30天
},
});
// 获取会话
export async function getSession(request: Request) {
const cookie = request.headers.get('Cookie');
return sessionStorage.getSession(cookie);
}
// 提交会话
export async function commitSession(session: any) {
return sessionStorage.commitSession(session);
}
// 获取或创建会话信息
export async function getSessionInfo(request: Request) {
const session = await getSession(request);
let sessionId = session.get('sessionId');
if (!sessionId) {
sessionId = uuidv4();
session.set('sessionId', sessionId);
}
const user = difyUtils.generateUserId(sessionId);
return {
sessionId,
user,
session,
};
}
// 设置会话头部
export function setSessionHeaders(sessionId: string) {
return {
'Set-Cookie': `session_id=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}`,
};
}
+1094 -8
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -20,11 +20,14 @@
"@remix-run/react": "^2.16.2",
"@remix-run/serve": "^2.16.2",
"@uiw/react-codemirror": "^4.23.10",
"ahooks": "^3.8.5",
"antd": "^5.25.4",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"diff": "^7.0.0",
"docx-preview": "^0.3.5",
"html-docx-js": "^0.3.1",
"immer": "^10.1.1",
"isbot": "^4.1.0",
"jszip": "^3.10.1",
"mammoth": "^1.9.0",
@@ -35,7 +38,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-pdf": "^5.7.2",
"remixicon": "^4.6.0"
"remixicon": "^4.6.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@remix-run/dev": "^2.16.2",
+1 -1
View File
@@ -42,6 +42,6 @@ export default defineConfig({
// 防止依赖预构建时触发页面刷新导致路由中断
force: false,
// 预构建这些依赖,避免首次加载时出现重新构建
include: ['react-pdf', 'pdfjs-dist','dayjs','@remix-run/node','react-dom','axios','dayjs/plugin/utc','react-router-dom','jszip'],
include: ['react-pdf', 'pdfjs-dist', 'dayjs', '@remix-run/node', 'react-dom', 'axios', 'dayjs/plugin/utc', 'react-router-dom', 'jszip', 'ahooks', 'antd', 'immer', '@ant-design/icons'],
},
});