This commit is contained in:
liamzi
2025-01-08 10:31:57 +08:00
parent 9dcfe5b0fa
commit ed597ac7fb
4 changed files with 381 additions and 9 deletions

2
.env
View File

@@ -1,6 +1,6 @@
VITE_SUPABASE_URL=https://base.uppmkt.com VITE_SUPABASE_URL=https://base.uppmkt.com
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
VITE_DIFY_API_KEY=app-OK28lKfA6Ib82K0B4vwZcsXK

View File

@@ -0,0 +1,218 @@
import { Button, Drawer, Input, Space, message } from 'antd';
import { CodeHighlight } from "@mantine/code-highlight";
import { DownloadOutlined, EditOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { useRef, useEffect, useState } from 'react';
import { useDifyChat } from '@/hooks/aichat';
export default function DifyChatDrawer({ open, onClose, onExport }) {
const {
messages,
setMessages,
isLoading,
sendMessage,
clearHistory,
storedMessages,
setStoredMessages
} = useDifyChat();
const [input, setInput] = useState('');
const messagesEndRef = useRef(null);
const [editingMessageId, setEditingMessageId] = useState(null);
const [editingContent, setEditingContent] = useState('');
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSendMessage = async (e) => {
e.preventDefault();
await sendMessage(input);
setInput('');
};
const handleEdit = (message) => {
if(isLoading) return;
setEditingContent(message.content);
setEditingMessageId(message.id);
};
const handleSaveEdit = () => {
try {
JSON.parse(editingContent);
setMessages(messages.map(msg =>
msg.id === editingMessageId
? { ...msg, content: editingContent }
: msg
));
setEditingMessageId(null);
message.success('编辑成功');
} catch (error) {
message.error('请输入有效的 JSON 格式');
}
};
const handleCancelEdit = () => {
setEditingMessageId(null);
setEditingContent('');
};
const handleExport = (content) => {
try {
const jsonContent = JSON.parse(content);
onExport?.(jsonContent);
} catch (error) {
console.error(error);
message.error('导出失败,请重试');
}
};
return (
<Drawer
title={
<div className="flex justify-between items-center">
<span className="text-lg font-medium text-gray-800">AI 助手</span>
<Button
size="small"
className="hover:bg-gray-100"
onClick={clearHistory}
>
清空历史
</Button>
</div>
}
placement="right"
width={800}
open={open}
onClose={onClose}
className="rounded-l-xl"
>
<div className="flex flex-col h-[calc(100vh-108px)]">
<div className="flex-1 overflow-y-auto px-4 space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={`rounded-lg p-4 transition-all ${
message.role === 'assistant'
? 'bg-blue-50 hover:bg-blue-100'
: 'bg-gray-50 hover:bg-gray-100'
}`}
>
<div className="flex justify-between items-center mb-3">
<span className={`font-medium ${
message.role === 'assistant' ? 'text-blue-600' : 'text-gray-600'
}`}>
{message.role === 'assistant' ? 'AI 助手' : '用户'}
</span>
{message.role === 'assistant' && (
<Space>
<Button
type="text"
size="small"
icon={<EditOutlined />}
disabled={isLoading}
onClick={() => handleEdit(message)}
className="text-gray-500 hover:text-blue-600"
>
编辑
</Button>
<Button
type="text"
size="small"
icon={<DownloadOutlined />}
onClick={() => handleExport(message.content)}
className="text-gray-500 hover:text-blue-600"
>
导出
</Button>
</Space>
)}
</div>
{message.role === "assistant" ? (
<div className="relative">
{editingMessageId === message.id ? (
<div className="rounded-lg border border-blue-200">
<Editor
height="300px"
defaultLanguage="json"
value={editingContent}
theme="vs-light"
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
renderLineHighlight: 'none',
roundedSelection: true,
}}
onChange={setEditingContent}
onMount={(editor) => {
editor.getModel()?.updateOptions({ tabSize: 2 });
editor.focus();
}}
/>
<div className="flex justify-end gap-2 p-2 bg-gray-50 border-t">
<Button
size="small"
icon={<CloseOutlined />}
onClick={handleCancelEdit}
className="hover:bg-gray-200"
>
取消
</Button>
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={handleSaveEdit}
className="bg-blue-600 hover:bg-blue-700"
>
保存
</Button>
</div>
</div>
) : (
<CodeHighlight
code={message.content}
language="json"
copyLabel="复制代码"
copiedLabel="已复制!"
withLineNumbers
className="rounded-lg"
/>
)}
</div>
) : (
<div className="text-gray-700 whitespace-pre-wrap break-words">
{message.content}
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="border-t bg-white p-4">
<form onSubmit={handleSendMessage} className="flex gap-2">
<Input
value={input}
placeholder="请输入您的问题..."
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
className="flex-1 rounded-lg border-gray-300 hover:border-blue-400 focus:border-blue-600 focus:shadow-blue-100"
/>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
className="rounded-lg bg-blue-600 hover:bg-blue-700"
>
发送
</Button>
</form>
</div>
</div>
</Drawer>
);
}

143
src/hooks/aichat.jsx Normal file
View File

@@ -0,0 +1,143 @@
import { useState, useRef } from 'react';
import { useSessionStorage } from 'react-use';
import { message } from 'antd';
const DIFY_API_KEY = import.meta.env.VITE_DIFY_API_KEY;
const DIFY_API_ENDPOINT = 'https://api.dify.ai/v1';
const STORAGE_KEY = 'dify_chat_history';
const CONVERSATION_ID_KEY = 'dify_conversation_id';
export function useDifyChat() {
const [storedMessages, setStoredMessages] = useSessionStorage(STORAGE_KEY, '[]');
const [conversationId, setConversationId] = useSessionStorage(CONVERSATION_ID_KEY, '');
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const abortController = useRef(null);
const clearHistory = () => {
setMessages([]);
setStoredMessages('[]');
setConversationId('');
message.success('历史记录已清空');
};
const sendMessage = async (input) => {
if (!input.trim() || isLoading) return;
try {
setIsLoading(true);
const userMessage = { role: 'user', content: input, id: Date.now().toString() };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
const conversationMessages = newMessages.map(msg => ({
role: msg.role,
content: msg.content
}));
abortController.current = new AbortController();
const requestBody = {
query: input,
user: 'default-user',
response_mode: "streaming",
inputs: {},
conversation_history: conversationMessages
};
if (conversationId) {
requestBody.conversation_id = conversationId;
}
const response = await fetch(`${DIFY_API_ENDPOINT}/chat-messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${DIFY_API_KEY}`
},
body: JSON.stringify(requestBody),
signal: abortController.current.signal
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'API 请求失败');
}
const aiMessage = { role: 'assistant', content: '', id: Date.now().toString() };
setMessages(msgs => [...msgs, aiMessage]);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
switch (data.event) {
case 'workflow_started':
if (data.conversation_id) {
setConversationId(data.conversation_id);
}
break;
case 'message':
if (data.answer) {
aiMessage.content += data.answer;
setMessages(msgs => msgs.map(msg =>
msg.id === aiMessage.id ? aiMessage : msg
));
}
break;
case 'error':
throw new Error(data.data?.message || '未知错误');
case 'done':
console.log('Stream completed');
break;
default:
break;
}
} catch (e) {
console.error('Error parsing stream:', e);
}
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
message.info('消息发送已取消');
} else {
console.error('Error:', error);
message.error('发送消息失败');
}
} finally {
setIsLoading(false);
abortController.current = null;
}
};
return {
messages,
setMessages,
isLoading,
sendMessage,
clearHistory,
storedMessages,
setStoredMessages
};
}

View File

@@ -21,8 +21,8 @@ import { supabase } from "@/config/supabase";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import SectionList from '@/components/SectionList' import SectionList from '@/components/SectionList'
import ChatAIDrawer from '@/components/ChatAi'; import DifyChatDrawer from '@/components/difyChatAi';
import ChatAIDrawer from '@/components/ChatAi';
const { Title } = Typography; const { Title } = Typography;
// 添加货币符号映射 // 添加货币符号映射
@@ -311,7 +311,8 @@ const QuotationForm = () => {
} }
}, [id, templateId]); }, [id, templateId]);
const [open, setOpen] = useState(false); const [difyOpen, setDifyOpen] = useState(false);
const [vercelOpen, setVercelOpen] = useState(false);
const handleExport = (data) => { const handleExport = (data) => {
if(data?.activityName&&data?.currency){ if(data?.activityName&&data?.currency){
@@ -353,7 +354,8 @@ const QuotationForm = () => {
message.success('已添加新的服务项目'); message.success('已添加新的服务项目');
} }
setOpen(false); setDifyOpen(false);
setVercelOpen(false);
}; };
return ( return (
@@ -393,8 +395,11 @@ const QuotationForm = () => {
保存 保存
</Button> </Button>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setDifyOpen(true)}>
AI 助手 AI for Dify
</Button>
<Button onClick={() => setVercelOpen(true)}>
AI for Vercel
</Button> </Button>
</> </>
)} )}
@@ -497,9 +502,15 @@ const QuotationForm = () => {
</Card> </Card>
</Form> </Form>
</Card> </Card>
<DifyChatDrawer
open={difyOpen}
onClose={() => setDifyOpen(false)}
onExport={handleExport}
/>
<ChatAIDrawer <ChatAIDrawer
open={open} open={vercelOpen}
onClose={() => setOpen(false)} onClose={() => setVercelOpen(false)}
onExport={handleExport} onExport={handleExport}
/> />
</div> </div>