基于 shiy-temp分支修改
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# APP ID
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
# APP API key
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
# API url prefix
|
||||
NEXT_PUBLIC_API_URL=
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 处理删除API(204状态码)
|
||||
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);
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
Generated
+1094
-8
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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
@@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user