[dev]chats

This commit is contained in:
susie-laptop 2025-06-05 17:47:51 -04:00
parent 4653afdefe
commit 0bd25c4f2a
20 changed files with 2805 additions and 113 deletions

1792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,9 +30,13 @@
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.0",
"redux-persist": "^6.0.0"
"react-syntax-highlighter": "^15.6.1",
"redux-persist": "^6.0.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.25.0",

View File

@ -1,23 +1,45 @@
import { Send, X } from 'lucide-react';
import { LoaderCircle, Send, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchCreatorDetail } from '../store/slices/creatorsSlice';
import { Button, Form } from 'react-bootstrap';
import { fetchConversationSummary, getRecommendedReply } from '../store/slices/chatSlice';
export default function ChatDetails({ onCloseChatDetails }) {
const [search, setSearch] = useState('');
const { selectedChat } = useSelector((state) => state.inbox);
const { currentChat } = useSelector((state) => state.chat);
const { selectedCreator } = useSelector((state) => state.creators);
const dispatch = useDispatch();
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form submitted');
const query = {
conversation_id: currentChat?.conversation_id,
conversation_summary: currentChat?.summary?.summary || '',
last_message: currentChat?.last_message || '',
goal_id: '',
};
dispatch(getRecommendedReply(query));
};
const handleSendEmail = () => {
console.log('Send email');
if (!currentChat?.recommended_reply?.reply) return;
const query = {
conversation_id: currentChat?.conversation_id,
body: currentChat?.recommended_reply?.reply,
from_email: '',
subject: '',
};
dispatch(getRecommendedReply(query));
};
useEffect(() => {
dispatch(fetchCreatorDetail({ creatorId: selectedChat.id }));
}, [dispatch, selectedChat]);
dispatch(fetchCreatorDetail({ creatorId: currentChat?.negotiation?.creator?.id }));
dispatch(fetchConversationSummary(currentChat?.conversation_id));
}, [dispatch, currentChat]);
return (
<div className='chat-details'>
@ -30,42 +52,63 @@ export default function ChatDetails({ onCloseChatDetails }) {
</div>
<div className='chat-detail-header-creator'>
<div className='chat-detail-header-creator-avatar'>
<img src={selectedChat?.avatar} alt='avatar' />
<img src={selectedCreator?.creator?.avatar} alt={selectedCreator?.creator?.name} />
</div>
<div className='chat-detail-header-creator-name'>{selectedChat?.name}</div>
<div className='chat-detail-header-creator-name'>{selectedCreator?.creator?.name}</div>
</div>
</div>
<div className='chat-detail-header-infos'>
<div className='chat-detail-header-info-item'>
<div className='chat-detail-header-info-item-label'>Category</div>
<div className='chat-detail-header-info-item-value'>{selectedCreator?.category}</div>
<div className='chat-detail-header-info-item-value'>{selectedCreator?.business?.category}</div>
</div>
<div className='chat-detail-header-info-item'>
<div className='chat-detail-header-info-item-label'>MCN</div>
<div className='chat-detail-header-info-item-value'>{selectedCreator?.mcn || '--'}</div>
<div className='chat-detail-header-info-item-value'>{selectedCreator?.business?.mcn || '--'}</div>
</div>
<div className='chat-detail-header-info-item'>
<div className='chat-detail-header-info-item-label'>Pricing</div>
<div className='chat-detail-header-info-item-value'>{selectedCreator?.pricing || '--'}</div>
<div className='chat-detail-header-info-item-value'>
{selectedCreator?.business?.pricing?.price || '--'}
</div>
</div>
<div className='chat-detail-header-info-item'>
<div className='chat-detail-header-info-item-label'>Collab.</div>
<div className='chat-detail-header-info-item-value'>{selectedCreator?.collab || '--'}</div>
<div className='chat-detail-header-info-item-value'>
{selectedCreator?.business?.latest_collab || '--'}
</div>
</div>
</div>
<div className='chat-detail-summary'>
<div className='chat-detail-summary-title'>Chat Summary</div>
<Form.Control as='textarea' rows={6} className='chat-detail-summary-input' />
<Form.Control
as='textarea'
disabled
rows={6}
className='chat-detail-summary-input'
value={currentChat?.summary?.summary || currentChat?.summary_error || 'No summary available'}
/>
</div>
<div className='chat-detail-generate'>
<div className='chat-detail-generate-title'>Chat Generate</div>
<Form className='generate-form' onSubmit={handleSubmit}>
<Form.Control as='textarea' rows={4} className='generate-input' />
<Form.Control
disabled
as='textarea'
rows={4}
className='generate-input'
value={currentChat?.recommended_reply?.reply || currentChat?.recommended_reply_error}
/>
<Button className='rounded-pill submit-btn btn-sm' type='submit'>
<Send size={16} />
<LoaderCircle size={16} />
</Button>
</Form>
</div>
<div className='chat-detail-send-btn'>
<Button className='rounded-pill btn-sm' type='submit' onClick={handleSendEmail}>
Send
</Button>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
import React, { useRef, useEffect } from 'react';
import { Form } from 'react-bootstrap';
export default function ChatInput({ value, onChange }) {
export default function ChatInput({ value, onChange, onSubmit }) {
const textareaRef = useRef(null);
useEffect(() => {
@ -12,6 +12,13 @@ export default function ChatInput({ value, onChange }) {
}
}, [value]);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
};
return (
<Form.Control
as='textarea'
@ -21,6 +28,7 @@ export default function ChatInput({ value, onChange }) {
onChange={onChange}
placeholder='Send a message...'
className='chat-textarea'
onKeyDown={handleKeyDown}
/>
);
}

View File

@ -0,0 +1,57 @@
import StreamingMarkdown from './StreamingMarkdown';
import { Copy, RotateCcw } from 'lucide-react';
import { useState } from 'react';
export default function ChatMessage({ message, isStreaming = false }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('复制失败:', err);
}
};
const handleRegenerate = () => {
//
console.log('重新生成消息:', message.id);
};
if (message.role === 'user') {
return (
<div className='message user mb-3'>
<div className='d-flex justify-content-end'>
<div className='message-content user-message'>
<div className='message-text'>{message.content}</div>
</div>
</div>
</div>
);
}
return (
<div className='message assistant mb-3'>
<StreamingMarkdown content={message.content} isStreaming={isStreaming} typewriterSpeed={20} />
{/* 消息操作按钮 */}
{!isStreaming && (
<div className='message-actions mt-2'>
<button className='btn btn-sm btn-outline-primary me-2' onClick={handleCopy} title='复制内容'>
<Copy size={14} />
{copied && <span className='ms-1'>已复制</span>}
</button>
<button
className='btn btn-sm btn-outline-secondary d-none'
onClick={handleRegenerate}
title='重新生成'
>
<RotateCcw size={14} />
</button>
</div>
)}
</div>
);
}

360
src/components/ChatTest.jsx Normal file
View File

@ -0,0 +1,360 @@
import { useState } from 'react';
import { Button, Container, Row, Col } from 'react-bootstrap';
import ChatMessage from './ChatMessage';
const testMessages = [
{
id: 1,
role: 'user',
content: '你好可以帮我介绍一下React Hooks吗',
created_at: '2024-01-01 10:00:00',
},
{
id: 2,
role: 'assistant',
content: `# React Hooks 详解
React Hooks React 16.8 版本引入的新特性它允许你在**不编写 class 组件**的情况下使用 state 以及其他的 React 特性
## 主要的 Hooks
### 1. useState
\`useState\` 是最常用的 Hook用于在函数组件中添加状态
\`\`\`javascript
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>你点击了 {count} </p>
<button onClick={() => setCount(count + 1)}>
点击我
</button>
</div>
);
}
\`\`\`
### 2. useEffect
\`useEffect\` 用于处理副作用,如数据获取、订阅或手动更改 DOM
\`\`\`javascript
import { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
// componentDidMount componentDidUpdate:
useEffect(() => {
document.title = \`你点击了 \${count}\`;
});
return (
<div>
<p>你点击了 {count} </p>
<button onClick={() => setCount(count + 1)}>
点击我
</button>
</div>
);
}
\`\`\`
## 优势
| 特性 | Class 组件 | Hooks |
|------|-----------|-------|
| 状态管理 | this.state | useState |
| 生命周期 | 复杂的生命周期方法 | useEffect |
| 逻辑复用 | 高阶组件/渲染属性 | 自定义Hooks |
| 代码量 | 较多 | 较少 |
## 注意事项
> **重要**: 只在最顶层使用 Hook不要在循环条件或嵌套函数中调用 Hook
### 自定义 Hook
你可以创建自定义 Hook 来复用状态逻辑
\`\`\`javascript
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
\`\`\`
希望这个介绍对你有帮助如果有任何问题随时问我`,
created_at: '2024-01-01 10:01:00',
},
{
id: 3,
role: 'user',
content: '这个示例很清楚!能再给我一个复杂的代码示例吗?',
created_at: '2024-01-01 10:02:00',
},
{
id: 4,
role: 'assistant',
content: `# 复杂的 React Hooks 示例
下面是一个更复杂的示例展示了多个 Hooks 的组合使用
## 用户列表组件
\`\`\`javascript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
//
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const userData = await response.json();
setUsers(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
//
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
//
const filteredAndSortedUsers = useMemo(() => {
let filtered = users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
return filtered.sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
} else if (sortBy === 'email') {
return a.email.localeCompare(b.email);
} else if (sortBy === 'joinDate') {
return new Date(b.joinDate) - new Date(a.joinDate);
}
return 0;
});
}, [users, searchTerm, sortBy]);
//
const deleteUser = useCallback(async (userId) => {
if (!window.confirm('确定要删除这个用户吗?')) return;
try {
await fetch(\`/api/users/\${userId}\`, { method: 'DELETE' });
setUsers(prev => prev.filter(user => user.id !== userId));
} catch (err) {
alert('删除失败:' + err.message);
}
}, []);
if (loading) return <div className="spinner">加载中...</div>;
if (error) return <div className="error">错误: {error}</div>;
return (
<div className="user-list">
<h2>用户列表 ({filteredAndSortedUsers.length})</h2>
{/* 搜索和排序控件 */}
<div className="controls">
<input
type="text"
placeholder="搜索用户..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="sort-select"
>
<option value="name">按姓名排序</option>
<option value="email">按邮箱排序</option>
<option value="joinDate">按加入日期排序</option>
</select>
</div>
{/* 用户列表 */}
<div className="users">
{filteredAndSortedUsers.map(user => (
<UserCard
key={user.id}
user={user}
onDelete={() => deleteUser(user.id)}
/>
))}
</div>
</div>
);
}
//
const UserCard = React.memo(({ user, onDelete }) => {
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} className="avatar" />
<div className="user-info">
<h3>{user.name}</h3>
<p>{user.email}</p>
<small>加入时间: {new Date(user.joinDate).toLocaleDateString()}</small>
</div>
<button onClick={onDelete} className="delete-btn">
删除
</button>
</div>
);
});
export default UserList;
\`\`\`
## 关键特性解析
### 🔥 性能优化
- \`useCallback\`: 缓存函数,避免不必要的重新创建
- \`useMemo\`: 缓存计算结果,避免重复计算
- \`React.memo\`: 防止子组件不必要的重渲染
### 📊 状态管理
- 多个 \`useState\` 管理不同的状态
- 复杂的状态更新逻辑
### 🎯 副作用处理
- \`useEffect\` 处理数据获取
- 清理函数处理内存泄漏
这个示例展示了在实际项目中如何组合使用多个 Hooks 来构建功能完整的组件`,
created_at: '2024-01-01 10:03:00',
},
];
export default function ChatTest() {
const [messages, setMessages] = useState(testMessages);
const [streamingMessageId, setStreamingMessageId] = useState(null);
const simulateStreaming = () => {
const newMessage = {
id: messages.length + 1,
role: 'assistant',
content: `# 实时流式渲染演示
这是一个**实时流式渲染**的演示文本会逐字显示模拟真实的AI对话体验
## 特性展示
### 1. 打字机效果
- 文字逐个显示
- 可以跳过动画
- 流畅的视觉体验
### 2. Markdown 支持
支持完整的Markdown语法
\`\`\`javascript
//
function hello() {
console.log("Hello, World!");
return "streaming";
}
\`\`\`
### 3. 表格支持
| 功能 | 状态 | 说明 |
|------|------|------|
| 打字机效果 | | 完成 |
| 代码高亮 | | 完成 |
| 表格渲染 | | 完成 |
> 这是一个引用块的示例用来展示特殊内容
### 4. 列表支持
1. 有序列表项1
2. 有序列表项2
3. 有序列表项3
- 无序列表项A
- 无序列表项B
- 无序列表项C
**粗体文本** *斜体文本* 也完全支持
[链接示例](https://react.dev)
---
这就是流式渲染的完整演示`,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, newMessage]);
setStreamingMessageId(newMessage.id);
// streaming
setTimeout(() => {
setStreamingMessageId(null);
}, newMessage.content.length * 20 + 2000);
};
return (
<Container fluid className='mt-4'>
<Row>
<Col md={8} className='mx-auto'>
<div className='d-flex justify-content-between align-items-center mb-4'>
<h2>聊天组件测试</h2>
<Button onClick={simulateStreaming} variant='primary'>
开始流式演示
</Button>
</div>
<div
className='chat-test-container'
style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
maxHeight: '70vh',
overflowY: 'auto',
}}
>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
isStreaming={message.id === streamingMessageId}
/>
))}
</div>
</Col>
</Row>
</Container>
);
}

View File

@ -4,12 +4,14 @@ import { fetchChatHistory } from '../store/slices/inboxSlice';
import { Ellipsis, Send } from 'lucide-react';
import { Button, Form } from 'react-bootstrap';
import ChatInput from './ChatInput';
import ChatMessage from './ChatMessage';
export default function ChatWindow({ onOpenChatDetails }) {
const { selectedChat, chatStatus } = useSelector((state) => state.inbox);
const { currentChat, status } = useSelector((state) => state.chat);
const [activePlatform, setActivePlatform] = useState('email');
const dispatch = useDispatch();
const [message, setMessage] = useState('');
const [streamingMessageId, setStreamingMessageId] = useState(null);
const platformOptions = [
{
@ -27,14 +29,13 @@ export default function ChatWindow({ onOpenChatDetails }) {
};
useEffect(() => {
if (selectedChat) {
console.log(selectedChat);
if (currentChat) {
console.log(currentChat);
}
}, [selectedChat]);
}, [currentChat]);
const handleSendMessage = (e) => {
e.preventDefault();
console.log(e.target.message.value);
const handleSendMessage = () => {
console.log(message);
};
return (
@ -42,14 +43,14 @@ export default function ChatWindow({ onOpenChatDetails }) {
<div className='chat-window-header'>
<div className='chat-window-header-left'>
<div className='chat-window-header-left-avatar'>
<img src={selectedChat.avatar} alt='avatar' />
<img src={currentChat?.negotiation?.creator?.avatar} alt={currentChat?.negotiation?.creator?.name} />
</div>
<div className='chat-window-header-left-info'>
<div
className='chat-window-header-left-info-name fw-bold'
onClick={onOpenChatDetails}
>
{selectedChat.name}
<div className='chat-window-header-left-info-name fw-bold' onClick={onOpenChatDetails}>
{currentChat?.negotiation?.creator?.name}
</div>
<div className='chat-window-header-left-info-product rounded-pill'>
{currentChat?.negotiation?.product?.name}
</div>
</div>
</div>
@ -72,17 +73,15 @@ export default function ChatWindow({ onOpenChatDetails }) {
</div>
<div className='chat-window-body'>
<div className='chat-body'>
{selectedChat?.chatHistory?.length > 0 &&
selectedChat?.chatHistory?.map((msg) => (
<div key={msg.id} className={`message ${msg.role === 'user' ? 'user' : 'assistant'}`}>
{msg.content}
</div>
{currentChat?.messages?.length > 0 &&
currentChat?.messages?.map((msg) => (
<ChatMessage key={msg.id} message={msg} isStreaming={msg.id === streamingMessageId} />
))}
</div>
</div>
<div className='chat-window-footer'>
<Form className='footer-input' onSubmit={handleSendMessage}>
<ChatInput value={message} onChange={(e) => setMessage(e.target.value)} />
<ChatInput value={message} onChange={(e) => setMessage(e.target.value)} onSubmit={handleSendMessage} />
<Button variant='outline-primary' className='border-0' type='submit'>
<Send />
</Button>

View File

@ -1,22 +1,29 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChatHistory, fetchInboxList, selectChat } from '../store/slices/inboxSlice';
import { fetchInboxList } from '../store/slices/inboxSlice';
import { fetchChatDetails, selectChat } from '../store/slices/chatSlice';
import { chatDateFormat } from '../lib/utils';
export default function InboxList() {
const dispatch = useDispatch();
const { inboxList, inboxStatus: status, error, selectedChat } = useSelector((state) => state.inbox);
const [activeToggle, setActiveToggle] = useState('all');
const [activeSelect, setActiveSelect] = useState('all');
const { inboxList, inboxStatus: status, error } = useSelector((state) => state.inbox);
const { currentChat } = useSelector((state) => state.chat);
const [activeToggle, setActiveToggle] = useState('all'); // all or initial_contact or negotiation or follow_up
const [activeSelect, setActiveSelect] = useState('all'); // all or unread
const toggleOptions = [
{ name: '全部', value: 'all' },
{ name: '首次建联', value: 'initial' },
{ name: '砍价邮件', value: 'bargain' },
{ name: '合作追踪', value: 'coop' },
{ name: '首次建联', value: 'initial_contact' },
{ name: '砍价邮件', value: 'negotiation' },
{ name: '合作追踪', value: 'follow_up' },
];
useEffect(() => {
dispatch(fetchInboxList());
}, [dispatch]);
if (activeToggle === 'all') {
dispatch(fetchInboxList({}));
} else {
dispatch(fetchInboxList({ status: activeToggle }));
}
}, [dispatch, activeToggle]);
useEffect(() => {
if (inboxList.length > 0) {
@ -27,7 +34,7 @@ export default function InboxList() {
const handleSelectChat = (item) => {
dispatch(selectChat(item));
dispatch(fetchChatHistory(item.id));
dispatch(fetchChatDetails({ conversation_id: item.conversation_id }));
};
return (
@ -68,30 +75,36 @@ export default function InboxList() {
<div className='list-content'>
{status === 'loading' && <div>Loading...</div>}
{status === 'failed' && <div>Error: {error}</div>}
{status === 'succeeded' && (inboxList.length > 0 ?
inboxList.map((item) => (
<div
key={item.id}
className={`list-item ${selectedChat.id === item.id ? 'active' : ''}`}
onClick={() => handleSelectChat(item)}
>
<div className='list-item-left'>
<div className='list-item-left-avatar'>
<img src={item.avatar} alt={item.name} />
{status === 'succeeded' &&
(inboxList.length > 0 ? (
inboxList.map((chat) => (
<div
key={chat.conversation_id}
className={`list-item ${
currentChat?.conversation_id === chat.conversation_id ? 'active' : ''
}`}
onClick={() => handleSelectChat(chat)}
>
<div className='list-item-left'>
<div className='list-item-left-avatar'>
<img src={chat.creator.avatar} alt={chat.creator.name} />
</div>
<div className='list-item-info'>
<div className='list-name fw-bold'>{chat.creator.name}</div>
<div className='list-message'>{chat.last_message}</div>
</div>
</div>
<div className='list-item-info'>
<div className='list-name fw-bold'>{item.name}</div>
<div className='list-message'>{item.message}</div>
<div className='list-item-right'>
<div className='list-item-right-time'>{chatDateFormat(chat.last_time)}</div>
{chat.unread_count > 0 && (
<div className='list-item-right-badge'>{chat.unread_count}</div>
)}
</div>
</div>
<div className='list-item-right'>
<div className='list-item-right-time'>{item.date}</div>
{item.unreadMessageCount > 0 && (
<div className='list-item-right-badge'>{item.unreadMessageCount}</div>
)}
</div>
</div>
)) : <div className='list-item-empty'>No Chats Found</div>)}
))
) : (
<div className='list-item-empty'>No Chats Found</div>
))}
</div>
</div>
);

View File

@ -0,0 +1,61 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTypewriter } from '../hooks/useTypewriter';
export default function StreamingMarkdown({ content, isStreaming = z, typewriterSpeed = 30 }) {
const { displayText, isTyping, skipAnimation } = useTypewriter(isStreaming ? content : '', typewriterSpeed);
//
const textToRender = isStreaming ? displayText : content;
return (
<div className='streaming-markdown'>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter style={tomorrow} language={match[1]} PreTag='div' {...props}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
//
table({ children }) {
return (
<div className='table-responsive'>
<table className='table table-striped table-hover'>{children}</table>
</div>
);
},
//
a({ href, children }) {
return (
<a href={href} target='_blank' rel='noopener noreferrer' className='text-decoration-none'>
{children}
</a>
);
},
}}
>
{textToRender}
</ReactMarkdown>
{/* 打字机效果控制 */}
{isStreaming && isTyping && (
<div className='typewriter-controls mt-2'>
<button className='btn btn-sm btn-outline-secondary' onClick={skipAnimation}>
跳过动画
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { useState, useEffect, useRef } from 'react';
export const useTypewriter = (text, speed = 30) => {
const [displayText, setDisplayText] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const intervalRef = useRef(null);
const indexRef = useRef(0);
useEffect(() => {
if (!text) return;
setDisplayText('');
setIsTyping(true);
setIsCompleted(false);
indexRef.current = 0;
intervalRef.current = setInterval(() => {
setDisplayText((prev) => {
const newText = text.slice(0, indexRef.current + 1);
indexRef.current++;
if (indexRef.current >= text.length) {
setIsTyping(false);
setIsCompleted(true);
clearInterval(intervalRef.current);
}
return newText;
});
}, speed);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [text, speed]);
const skipAnimation = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
setDisplayText(text);
setIsTyping(false);
setIsCompleted(true);
};
return {
displayText,
isTyping,
isCompleted,
skipAnimation,
};
};

View File

@ -1,3 +1,5 @@
@import './styles/ChatMessage.scss';
:root {
--bs-primary-rgb: 99, 102, 241; /* 必须匹配custom-theme.scss中的$primary */
}

View File

@ -1,3 +1,4 @@
import { format, isToday, parseISO } from 'date-fns';
import { BRAND_SOURCES } from "./constant";
/**
@ -60,4 +61,13 @@ export const getBrandSourceName = (source) => {
export const getTemplateMissionName = (mission) => {
return TEMPLATE_MISSIONS.find(item => item.value === mission)?.name || mission;
};
export const chatDateFormat = (date) => {
const now = typeof date === 'string' ? parseISO(date) : date;
if (isToday(now)) {
return format(now, 'HH:mm');
} else {
return format(now, 'MMM do');
}
};

View File

@ -3,11 +3,11 @@ import InboxList from '@/components/InboxList';
import ChatWindow from '@/components/ChatWindow';
import '@/styles/Inbox.scss';
import { useSelector, useDispatch } from 'react-redux';
import { resetSelectedChat } from '@/store/slices/inboxSlice';
import { resetSelectedChat } from '@/store/slices/chatSlice';
import ChatDetails from '@/components/ChatDetails';
export default function CreatorInbox() {
const { selectedChat } = useSelector((state) => state.inbox);
const { currentChat } = useSelector((state) => state.chat);
const [openChatDetails, setOpenChatDetails] = useState(false);
const dispatch = useDispatch();
@ -17,12 +17,12 @@ export default function CreatorInbox() {
};
}, []);
useEffect(() => {}, [selectedChat]);
useEffect(() => {}, [currentChat]);
return (
<React.Fragment>
<InboxList />
{selectedChat?.id && <ChatWindow onOpenChatDetails={() => setOpenChatDetails(true)} />}
{currentChat?.conversation_id && <ChatWindow onOpenChatDetails={() => setOpenChatDetails(true)} />}
{openChatDetails && <ChatDetails onCloseChatDetails={() => setOpenChatDetails(false)} />}
</React.Fragment>
);

View File

@ -12,6 +12,7 @@ import CreatorDiscovery from '@/pages/CreatorDiscovery';
import PrivateCreator from '@/pages/PrivateCreator';
import CreatorDetail from '@/pages/CreatorDetail';
import InboxTemplate from '@/pages/InboxTemplate';
import ChatTest from '@/components/ChatTest';
// Routes configuration object
const routes = [
@ -28,7 +29,7 @@ const routes = [
children: [
{
path: '',
element: <Database path='tiktok'/>,
element: <Database path='tiktok' />,
},
{
path: 'tiktok',
@ -93,6 +94,10 @@ const routes = [
path: '/inbox-templates',
element: <InboxTemplate />,
},
{
path: '/chat-test',
element: <ChatTest />,
},
];
// Create router with routes wrapped in the layout

View File

@ -30,7 +30,8 @@ api.interceptors.response.use(
window.location.href = '/login';
dispatch(clearUser());
}
return Promise.reject(error);
console.log(error);
return Promise.reject(error.response.data || error);
}
);

View File

@ -50,6 +50,41 @@ export const createConversation = createAsyncThunk(
}
);
export const fetchConversationSummary = createAsyncThunk(
'chat/fetchConversationSummary',
async (conversation_id, { rejectWithValue, dispatch }) => {
try {
const response = await api.get(`/gmail/conversations/summary/${conversation_id}/`);
console.log(response);
if (response.code === 200) {
return response.data;
}
console.log(response);
throw new Error(response.message);
} catch (error) {
console.log(error);
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.message);
}
}
);
export const getRecommendedReply = createAsyncThunk(
'chat/getRecommendedReply',
async (query, { rejectWithValue, dispatch }) => {
try {
const response = await api.post('/gmail/recommended-reply/', query);
if (response.code === 200) {
return response.data;
}
throw new Error(response.message);
} catch (error) {
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.message);
}
}
);
const initialState = {
currentChat: null,
status: 'idle',
@ -59,20 +94,51 @@ const initialState = {
const chatSlice = createSlice({
name: 'chat',
initialState,
reducers: {},
reducers: {
selectChat: (state, action) => {
state.currentChat = action.payload;
console.log(action.payload);
},
resetSelectedChat: (state) => {
state.currentChat = null;
},
},
extraReducers: (builder) => {
builder.addCase(fetchChatDetails.pending, (state) => {
state.status = 'loading';
});
builder.addCase(fetchChatDetails.fulfilled, (state, action) => {
state.status = 'succeeded';
state.currentChat = action.payload;
const { messages, negotiation } = action.payload;
state.currentChat.messages = messages;
state.currentChat.negotiation = negotiation;
});
builder.addCase(fetchChatDetails.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
builder.addCase(fetchConversationSummary.pending, (state) => {
state.status = 'loading';
});
builder.addCase(fetchConversationSummary.fulfilled, (state, action) => {
state.status = 'succeeded';
state.currentChat.summary = action.payload;
});
builder.addCase(fetchConversationSummary.rejected, (state, action) => {
state.currentChat.summary_error = action.payload;
});
builder.addCase(getRecommendedReply.pending, (state) => {
state.status = 'loading';
});
builder.addCase(getRecommendedReply.fulfilled, (state, action) => {
state.status = 'succeeded';
state.currentChat.recommended_reply = action.payload;
});
builder.addCase(getRecommendedReply.rejected, (state, action) => {
state.currentChat.recommended_reply_error = action.payload;
});
},
});
export const { selectChat, resetSelectedChat } = chatSlice.actions;
export default chatSlice.reducer;

View File

@ -248,7 +248,7 @@ export const fetchPrivateCreators = createAsyncThunk(
const { code, data, message, pagination } = await api.post(
`/daren_detail/private/pools/creators/filter/?page=${page}`,
{ pool_id: 1, filter }
{ filter }
);
if (code === 200) {
return { data, pagination };

View File

@ -1,5 +1,4 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { format, isToday, parseISO } from 'date-fns';
import api from '@/services/api';
import { setNotificationBarMessage } from './notificationBarSlice';
@ -164,28 +163,39 @@ const mockChatHistory = [
},
];
const chatDateFormat = (date) => {
const now = typeof date === 'string' ? parseISO(date) : date;
if (isToday(now)) {
return format(now, 'HH:mm');
} else {
return format(now, 'MMM do');
}
};
export const fetchInboxList = createAsyncThunk('inbox/fetchInboxList', async (_, { rejectWithValue }) => {
export const fetchInboxList = createAsyncThunk('inbox/fetchInboxList', async (query, { rejectWithValue }) => {
try {
const response = await api.get('/chat-history/');
const response = await api.get('/chat-history/get_negotiation_chats/', { params: query });
if (response.code === 200) {
return response.data;
} else {
throw new Error(response.message);
}
} catch (error) {
return rejectWithValue(error.response.data.message);
return rejectWithValue(error.message);
}
});
export const deleteConversation = createAsyncThunk(
'inbox/deleteConversation',
async (id, { rejectWithValue, dispatch }) => {
try {
const response = await api.delete('/chat-history/delete_conversation/', {
params: { conversation_id: id },
});
if (response.code === 200) {
dispatch(setNotificationBarMessage({ message: response.message, type: 'success' }));
dispatch(fetchInboxList({}));
return response.data;
}
throw new Error(response.message);
} catch (error) {
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.message);
}
}
);
export const fetchChatHistory = createAsyncThunk('inbox/fetchChatHistory', async (id) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return { chatHistory: mockChatHistory, chatId: id };
@ -200,7 +210,7 @@ export const fetchTemplates = createAsyncThunk('inbox/fetchTemplates', async (qu
throw new Error(response.message);
}
} catch (error) {
return rejectWithValue(error.response.data.message);
return rejectWithValue(error.message);
}
});
@ -216,7 +226,7 @@ export const editTemplateThunk = createAsyncThunk(
}
} catch (error) {
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.response.data.message);
return rejectWithValue(error.message);
}
}
);
@ -233,7 +243,7 @@ export const addTemplateThunk = createAsyncThunk(
}
} catch (error) {
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.response.data.message);
return rejectWithValue(error.message);
}
}
);
@ -247,7 +257,7 @@ export const getTemplateDetail = createAsyncThunk('inbox/getTemplateDetail', asy
throw new Error(response.message);
}
} catch (error) {
return rejectWithValue(error.response.data.message);
return rejectWithValue(error.message);
}
});
@ -264,14 +274,7 @@ const initialState = {
const inboxSlice = createSlice({
name: 'inbox',
initialState,
reducers: {
selectChat: (state, action) => {
state.selectedChat = action.payload;
},
resetSelectedChat: (state) => {
state.selectedChat = {};
},
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchInboxList.pending, (state) => {
@ -279,7 +282,7 @@ const inboxSlice = createSlice({
})
.addCase(fetchInboxList.fulfilled, (state, action) => {
state.inboxStatus = 'succeeded';
state.inboxList = action.payload;
state.inboxList = action.payload.results;
})
.addCase(fetchInboxList.rejected, (state, action) => {
state.inboxStatus = 'failed';
@ -336,11 +339,11 @@ const inboxSlice = createSlice({
state.error = action.payload;
})
.addCase(getTemplateDetail.fulfilled, (state, action) => {
const template = state.templates.find(item => item.id === action.payload.id);
const template = state.templates.find((item) => item.id === action.payload.id);
if (template) {
Object.assign(template, action.payload);
}
})
});
},
});

190
src/styles/ChatMessage.scss Normal file
View File

@ -0,0 +1,190 @@
// ChatMessage 样式
.message {
.message-content {
max-width: 70%;
word-wrap: break-word;
&.user-message {
background: var(--bs-primary);
color: white;
border-radius: 18px 18px 4px 18px;
padding: 12px 16px;
.message-text {
margin: 0;
}
}
&.assistant-message {
background: var(--bs-light);
border: 1px solid var(--bs-border-color);
border-radius: 18px 18px 18px 4px;
padding: 12px 16px;
.streaming-markdown {
// Markdown 内容样式
h1, h2, h3, h4, h5, h6 {
margin-top: 16px;
margin-bottom: 8px;
&:first-child {
margin-top: 0;
}
}
p {
margin-bottom: 8px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
}
// 代码块样式
pre {
background: #2d3748 !important;
border-radius: 8px;
margin: 12px 0;
overflow-x: auto;
code {
background: transparent !important;
color: #e2e8f0 !important;
padding: 0 !important;
}
}
// 内联代码样式
code {
background: var(--bs-secondary-bg);
color: var(--bs-danger);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
// 引用块样式
blockquote {
border-left: 4px solid var(--bs-primary);
margin-left: 0;
padding-left: 16px;
color: var(--bs-secondary);
font-style: italic;
}
// 列表样式
ul, ol {
padding-left: 20px;
margin-bottom: 12px;
li {
margin-bottom: 4px;
line-height: 1.5;
}
}
// 链接样式
a {
color: var(--bs-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
color: var(--bs-primary-dark);
}
}
// 表格样式
.table-responsive {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--bs-border-color);
.table {
margin-bottom: 0;
font-size: 0.9em;
th {
background: var(--bs-secondary-bg);
border-top: none;
font-weight: 600;
}
}
}
}
// 消息操作按钮
.message-actions {
display: flex;
gap: 8px;
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
.btn {
font-size: 0.8em;
padding: 4px 8px;
border-radius: 6px;
svg {
margin-right: 4px;
}
}
}
// 打字机控制按钮
.typewriter-controls {
opacity: 0.8;
.btn {
font-size: 0.8em;
padding: 4px 12px;
}
}
}
}
}
// 聊天窗口body的滚动样式
.chat-window-body {
.chat-body {
padding: 16px;
max-height: calc(100vh - 200px);
overflow-y: auto;
// 自定义滚动条
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bs-border-color);
border-radius: 3px;
&:hover {
background: var(--bs-secondary);
}
}
}
}
// 响应式调整
@media (max-width: 768px) {
.message .message-content {
max-width: 85%;
&.user-message, &.assistant-message {
padding: 10px 12px;
font-size: 0.9em;
}
}
}

View File

@ -105,15 +105,28 @@
flex-flow: row nowrap;
gap: 0.5rem;
align-items: center;
width: 100%;
overflow: hidden;
.list-item-left-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background-color: $primary-150;
}
.list-item-info {
.list-message {
color: $neutral-700;
color: $neutral-900;
width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.875rem;
}
}
}
@ -172,6 +185,18 @@
border-radius: 50%;
overflow: hidden;
}
.chat-window-header-left-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 1rem;
.chat-window-header-left-info-product {
font-weight: 400;
font-size: 0.875rem;
padding: 6px 10px;
background-color: $neutral-200;
}
}
}
.chat-window-header-right {
@ -267,7 +292,8 @@
width: 300px;
flex-shrink: 0;
overflow-y: auto;
align-items: flex-end;
.chat-details-header {
display: flex;
flex-flow: column nowrap;
@ -275,6 +301,7 @@
gap: 1rem;
border-bottom: 1px solid $neutral-200;
padding-bottom: 1rem;
width: 100%;
.chat-header-title {
display: flex;
@ -296,10 +323,16 @@
border-radius: 50%;
overflow: hidden;
background: $primary-150;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
}
.chat-detail-header-infos {
width: 100%;
display: flex;
flex-flow: column nowrap;
gap: 0.5rem;
@ -318,6 +351,7 @@
}
}
.chat-detail-summary {
width: 100%;
display: flex;
flex-flow: column nowrap;
gap: 0.5rem;
@ -334,6 +368,7 @@
}
}
.chat-detail-generate {
width: 100%;
display: flex;
flex-flow: column nowrap;
padding: 1rem 0;
@ -372,11 +407,11 @@
padding: 1.5rem;
display: flex;
flex-flow: column nowrap;
gap: .875rem;
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1F;
gap: 0.875rem;
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1f;
max-height: 380px;
height: fit-content;
.template-item-name {
font-size: 1.25rem;
font-weight: 700;