first commit
This commit is contained in:
44
app/components/ChatMessage.tsx
Normal file
44
app/components/ChatMessage.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ChatMessageProps {
|
||||
content: string;
|
||||
sender: 'user' | 'bot';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ content, sender, timestamp }) => {
|
||||
// 格式化时间戳
|
||||
const formattedTime = timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// 将消息内容按换行符分割
|
||||
const messageParts = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className={`flex items-end mb-4 ${sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{sender === 'bot' && (
|
||||
<div className="user-avatar mr-2">
|
||||
<span>AI</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-3 rounded-lg ${sender === 'user' ? 'message-user' : 'message-bot'}`}>
|
||||
{messageParts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < messageParts.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sender === 'user' && (
|
||||
<div className="user-avatar ml-2">
|
||||
<span>我</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-gray-500 ml-2 opacity-70">
|
||||
{formattedTime}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
app/components/Message.tsx
Normal file
117
app/components/Message.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Paper, Typography, Avatar, useMediaQuery, useTheme } from '@mui/material';
|
||||
import type { Message as MessageType } from '../types/types';
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const Message: React.FC<MessageProps> = ({ message, isLast = false }) => {
|
||||
const isUser = message.sender === 'user';
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const paperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 计算气泡的borderRadius
|
||||
const borderRadius = isUser
|
||||
? '18px 18px 4px 18px'
|
||||
: '18px 18px 18px 4px';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={message.id}
|
||||
display="flex"
|
||||
marginBottom={2}
|
||||
justifyContent={isUser ? "flex-end" : "flex-start"}
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-end"
|
||||
maxWidth={isMobile ? "85%" : "70%"}
|
||||
gap={1.5}
|
||||
>
|
||||
{!isUser && (
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: theme.palette.primary.main,
|
||||
boxShadow: theme.shadows[2],
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
🤖
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
ref={paperRef}
|
||||
elevation={isLast ? 2 : 1}
|
||||
sx={{
|
||||
padding: 2,
|
||||
backgroundColor: isUser
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.background.default,
|
||||
color: isUser ? 'white' : theme.palette.text.primary,
|
||||
borderRadius: borderRadius,
|
||||
position: 'relative',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[isLast ? 4 : 2],
|
||||
transform: 'translateY(-1px)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
{message.content.split('\n').map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
{index < message.content.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
marginTop: 1,
|
||||
opacity: 0.7,
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'right'
|
||||
}}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{isUser && (
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: theme.palette.primary.dark,
|
||||
boxShadow: theme.shadows[2],
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
👤
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Message;
|
||||
135
app/components/MessageInput.tsx
Normal file
135
app/components/MessageInput.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { TextField, IconButton, Box, Tooltip, CircularProgress, useTheme } from '@mui/material';
|
||||
import { SendRounded } from '@mui/icons-material';
|
||||
|
||||
interface MessageInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onKeyDown,
|
||||
disabled
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const textareaRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
// 自动调整文本框高度
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
// 找到实际的textarea元素
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: '0 -2px 10px rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-end"
|
||||
gap={1.5}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
ref={textareaRef}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="输入您的问题..."
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
multiline
|
||||
rows={1}
|
||||
inputProps={{
|
||||
style: {
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '24px',
|
||||
lineHeight: 1.5,
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 4,
|
||||
'& fieldset': {
|
||||
borderColor: theme.palette.divider,
|
||||
borderWidth: '2px',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: theme.palette.primary.light,
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: '2px',
|
||||
},
|
||||
'&.Mui-disabled fieldset': {
|
||||
borderColor: theme.palette.divider,
|
||||
opacity: 0.6,
|
||||
}
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
transform: isFocused ? 'translateY(-1px)' : 'translateY(0)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip title="发送消息" placement="top">
|
||||
<IconButton
|
||||
onClick={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
sx={{
|
||||
backgroundColor: (disabled || !value.trim())
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
width: 48,
|
||||
height: 48,
|
||||
'&:hover': {
|
||||
backgroundColor: (disabled || !value.trim())
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.dark,
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: theme.shadows[2],
|
||||
}}
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{disabled ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : (
|
||||
<SendRounded fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
21
app/components/MuiThemeProvider.tsx
Normal file
21
app/components/MuiThemeProvider.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import React from 'react';
|
||||
import theme from '../muiTheme';
|
||||
|
||||
interface MuiThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const MuiThemeProvider: React.FC<MuiThemeProviderProps> = ({ children }) => {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MuiThemeProvider;
|
||||
296
app/components/Sidebar.tsx
Normal file
296
app/components/Sidebar.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Typography,
|
||||
Button,
|
||||
Divider,
|
||||
useTheme,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { Add, Settings } from '@mui/icons-material';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
lastMessage?: string;
|
||||
timestamp?: Date;
|
||||
isActive?: boolean;
|
||||
avatar?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// 模拟对话历史数据
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '开始',
|
||||
lastMessage: '守正出奇,让你的内容脱颖而出',
|
||||
timestamp: new Date(),
|
||||
isActive: true,
|
||||
avatar: '🤖',
|
||||
unreadCount: 0
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '营销文案',
|
||||
lastMessage: '爆款标题的3个关键要素...',
|
||||
timestamp: new Date(Date.now() - 3600000),
|
||||
isActive: false,
|
||||
avatar: '📝',
|
||||
unreadCount: 1
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '社交媒体策略',
|
||||
lastMessage: '如何提高社交媒体互动率?',
|
||||
timestamp: new Date(Date.now() - 86400000),
|
||||
isActive: false,
|
||||
avatar: '📱',
|
||||
unreadCount: 0
|
||||
}
|
||||
];
|
||||
|
||||
// 处理新建对话
|
||||
const handleNewConversation = () => {
|
||||
console.log('创建新对话');
|
||||
};
|
||||
|
||||
// 处理选择对话
|
||||
const handleSelectConversation = (conversationId: string) => {
|
||||
console.log('选择对话:', conversationId);
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 86400000) { // 24小时内
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diff < 172800000) { // 48小时内
|
||||
return '昨天';
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: 300 },
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 用户信息区域 */}
|
||||
<Box sx={{ p: 2, borderBottom: `1px solid ${theme.palette.divider}` }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.primary.main,
|
||||
mb: 0.5,
|
||||
fontSize: '1.1rem',
|
||||
}}
|
||||
>
|
||||
爆款文案助手
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.85rem' }}
|
||||
>
|
||||
亲爱的安先生
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 新建对话按钮 */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Add fontSize="small" />}
|
||||
onClick={handleNewConversation}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
py: 1.2,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
boxShadow: theme.shadows[1],
|
||||
}}
|
||||
>
|
||||
新建对话
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 对话历史列表 */}
|
||||
<List
|
||||
component="nav"
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: 6,
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.divider,
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1,
|
||||
display: 'block',
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
最近对话
|
||||
</Typography>
|
||||
|
||||
{conversations.map((conversation) => (
|
||||
<React.Fragment key={conversation.id}>
|
||||
<ListItem
|
||||
onClick={() => handleSelectConversation(conversation.id)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mx: 1,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
backgroundColor: conversation.isActive ? theme.palette.action.selected : 'transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
paddingX: 2,
|
||||
paddingY: 1.5,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
bgcolor: conversation.isActive ? theme.palette.primary.main : theme.palette.background.default,
|
||||
color: conversation.isActive ? 'white' : theme.palette.text.primary,
|
||||
fontSize: '1.2rem',
|
||||
boxShadow: conversation.isActive ? theme.shadows[2] : 'none',
|
||||
}}
|
||||
>
|
||||
{conversation.avatar}
|
||||
</Avatar>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" gap={1}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: conversation.isActive ? 600 : 500,
|
||||
fontSize: '0.9rem',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{conversation.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
>
|
||||
{conversation.timestamp && formatTime(conversation.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
{conversation.lastMessage}
|
||||
</Typography>
|
||||
{conversation.unreadCount && conversation.unreadCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '9px',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
|
||||
{conversation.unreadCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ component: 'div' }}
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider light />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* 底部设置区域 */}
|
||||
<Box sx={{ p: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
<Button
|
||||
fullWidth
|
||||
startIcon={<Settings fontSize="small" />}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
3
app/components/index.ts
Normal file
3
app/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './ChatMessage';
|
||||
export * from './MessageInput';
|
||||
export * from './Sidebar';
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
489
app/globals.css
Normal file
489
app/globals.css
Normal file
@ -0,0 +1,489 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--sidebar-bg: #f5f5f5;
|
||||
--chat-bg: #ffffff;
|
||||
--message-input-bg: #f9f9f9;
|
||||
--message-user-bg: #667eea;
|
||||
--message-bot-bg: #f5f5f5;
|
||||
--border-color: #e0e0e0;
|
||||
--primary-color: #667eea;
|
||||
--primary-color-dark: #5a67d8;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--sidebar-bg: #1a1a1a;
|
||||
--chat-bg: #0a0a0a;
|
||||
--message-input-bg: #1e1e1e;
|
||||
--message-user-bg: #4338ca;
|
||||
--message-bot-bg: #1e1e1e;
|
||||
--border-color: #333333;
|
||||
--primary-color: #6366f1;
|
||||
--primary-color-dark: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 聊天界面容器 */
|
||||
.chat-interface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: var(--chat-bg);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header-left p {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-right .config-btn {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header-right .config-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 侧边栏 - 保留原有侧边栏样式但降低优先级 */
|
||||
.sidebar {
|
||||
background-color: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
background-color: var(--chat-bg);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message-input-container {
|
||||
background-color: var(--message-input-bg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
background-color: var(--message-user-bg);
|
||||
border-radius: 18px 18px 0 18px;
|
||||
align-self: flex-end;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message-bot {
|
||||
background-color: var(--message-bot-bg);
|
||||
border-radius: 18px 18px 18px 0;
|
||||
align-self: flex-start;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.8);
|
||||
}
|
||||
|
||||
/* 聊天内容区 - 覆盖原有样式 */
|
||||
.chat-interface .chat-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
background-color: var(--chat-bg);
|
||||
scroll-behavior: smooth;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 消息样式 */
|
||||
.message {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.user-message {
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.bot-message {
|
||||
justify-content: flex-start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
margin: 0 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-message .message-avatar {
|
||||
background-color: var(--message-user-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bot-message .message-avatar {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
background-color: var(--message-user-bg);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bot-message .message-content {
|
||||
background-color: var(--message-bot-bg);
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
display: block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-message .message-time {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.bot-message .message-time {
|
||||
color: rgba(156, 163, 175, 0.8);
|
||||
}
|
||||
|
||||
/* 输入区 */
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
padding: 1.5rem 2rem;
|
||||
background-color: var(--chat-bg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chat-input-area textarea {
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
transition: border-color 0.3s ease;
|
||||
background-color: var(--message-input-bg);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-input-area textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.chat-input-area textarea:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.chat-input-area button:hover:not(:disabled) {
|
||||
background-color: var(--primary-color-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(90, 103, 216, 0.2);
|
||||
}
|
||||
|
||||
.chat-input-area button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chat-input-area button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 配置表单覆盖层 */
|
||||
.config-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* 配置表单 */
|
||||
.config-form {
|
||||
background-color: var(--chat-bg);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.config-form h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
background-color: var(--message-input-bg);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
/* 打字动画 */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #999;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.chat-interface {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.chat-interface .chat-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 85%;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
33
app/layout.tsx
Normal file
33
app/layout.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "爆款内容策划师",
|
||||
description: "与AI策划师一起打造爆款内容",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
128
app/muiAnimations.css
Normal file
128
app/muiAnimations.css
Normal file
@ -0,0 +1,128 @@
|
||||
/* MUI 自定义动画效果 */
|
||||
|
||||
/* 消息淡入动画 */
|
||||
@keyframes messageFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框聚焦动画 */
|
||||
@keyframes inputFocus {
|
||||
from {
|
||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 0 8px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 打字指示器动画 */
|
||||
@keyframes typingBounce {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮悬停动画 */
|
||||
@keyframes buttonHover {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 侧边栏项目选中动画 */
|
||||
@keyframes sidebarItemSelect {
|
||||
from {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
to {
|
||||
background-color: rgba(102, 126, 234, 0.15);
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
/* 应用动画到MUI组件 */
|
||||
.MuiPaper-root {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 确保消息动画应用 */
|
||||
.chat-message-enter {
|
||||
animation: messageFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 输入框聚焦效果 */
|
||||
.MuiOutlinedInput-root.Mui-focused {
|
||||
animation: inputFocus 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* 打字指示器效果 */
|
||||
.typing-indicator span {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #667eea;
|
||||
margin: 0 2px;
|
||||
animation: typingBounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
.MuiButton-root:not(:disabled) {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.MuiButton-root:not(:disabled):hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 平滑滚动 */
|
||||
.chat-container {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 600px) {
|
||||
.MuiBox-root {
|
||||
transition: padding 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载指示器动画 */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
68
app/muiTheme.ts
Normal file
68
app/muiTheme.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
// 创建完整的MUI主题
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
light: '#8898f7',
|
||||
dark: '#5a67d8',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4fd1c5',
|
||||
light: '#6ee7db',
|
||||
dark: '#38b2ac',
|
||||
},
|
||||
background: {
|
||||
default: '#f8f9fa',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#1a202c',
|
||||
secondary: '#4a5568',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: {
|
||||
fontWeight: 700,
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
body1: {
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAvatar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
495
app/page.tsx
Normal file
495
app/page.tsx
Normal file
@ -0,0 +1,495 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Settings, Help, Message as MessageIcon } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Container,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Alert,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import type { Message as MessageType } from './types/types';
|
||||
import { initializeCoze, getCozeService, isCozeInitialized } from './service/initializeCoze';
|
||||
import type { CozeConfig } from './types/types';
|
||||
import MuiThemeProvider from './components/MuiThemeProvider';
|
||||
import Message from './components/Message';
|
||||
import { MessageInput } from './components/MessageInput';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
|
||||
// 欢迎消息
|
||||
const welcomeMessage: MessageType = {
|
||||
id: '1',
|
||||
content: '终于等到你了!我是你的爆款文案策划师,开始之前,我需要你提供一些简单的信息,让我能给你更有针对性的运营方案。\n1. 你的行业/背调/产品/服务(即使没有现有产品,可以说想要的产品/服务)\n2. 你的目标用户(根据你从业经验描绘的目标用户)',
|
||||
sender: 'bot',
|
||||
timestamp: new Date(Date.now() - 60000),
|
||||
};
|
||||
|
||||
const ChatInterface: React.FC = () => {
|
||||
// 状态管理
|
||||
const [messages, setMessages] = useState<MessageType[]>([welcomeMessage]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isConfigured, setIsConfigured] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'help'>('chat');
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
// 聊天容器的引用,用于自动滚动到底部
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 组件挂载时,使用提供的API密钥和Bot ID初始化Coze服务
|
||||
useEffect(() => {
|
||||
// 客户端初始化代码
|
||||
|
||||
// 使用用户提供的API密钥和Bot ID
|
||||
const apiKey = 'sat_XLx0k9rgirVutzov6ADylmscgU0zXufCZJHU13xorno5d7JzVCSrFkl1kJpQsJX0';
|
||||
const botId = '7560159608908939283'; // 从环境变量获取Bot ID
|
||||
|
||||
// 添加日志记录配置状态
|
||||
console.log('Coze API Key provided:', !!apiKey);
|
||||
console.log('Coze Bot ID:', botId);
|
||||
|
||||
if (apiKey && botId) {
|
||||
const cozeConfig: CozeConfig = { apiKey, botId };
|
||||
const initialized = initializeCoze(cozeConfig);
|
||||
setIsConfigured(initialized);
|
||||
console.log('Coze service initialization result:', initialized);
|
||||
} else {
|
||||
console.warn('Coze API 配置不完整');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 当消息更新时,自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
|
||||
|
||||
// 处理消息发送
|
||||
const handleSendMessage = async () => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
// 添加用户消息到对话中
|
||||
const userMessage: MessageType = {
|
||||
id: Date.now().toString(),
|
||||
content: input,
|
||||
sender: 'user',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 创建临时的bot消息ID,用于流式更新
|
||||
const tempBotMessageId = (Date.now() + 1).toString();
|
||||
|
||||
try {
|
||||
if (isConfigured && isCozeInitialized()) {
|
||||
// 使用Coze服务发送消息
|
||||
const cozeService = getCozeService();
|
||||
|
||||
// 流式响应处理函数
|
||||
const handleStreamResponse = (partialText: string) => {
|
||||
// 更新消息列表,找到临时消息或创建新消息
|
||||
setMessages(prev => {
|
||||
// 检查是否已存在临时bot消息
|
||||
const hasTempMessage = prev.some(msg => msg.id === tempBotMessageId && msg.sender === 'bot');
|
||||
|
||||
if (hasTempMessage) {
|
||||
// 更新已存在的临时消息
|
||||
return prev.map(msg =>
|
||||
msg.id === tempBotMessageId && msg.sender === 'bot'
|
||||
? { ...msg, content: partialText }
|
||||
: msg
|
||||
);
|
||||
} else {
|
||||
// 创建新的临时bot消息
|
||||
const tempBotMessage: MessageType = {
|
||||
id: tempBotMessageId,
|
||||
content: partialText,
|
||||
sender: 'bot',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
return [...prev, tempBotMessage];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 发送消息并传入流式回调
|
||||
await cozeService.sendMessage(input.trim(), handleStreamResponse);
|
||||
} else {
|
||||
// 如果未配置或初始化失败,使用模拟的回复
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const mockBotMessage: MessageType = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
content: '您的消息已收到!这是一条智能回复,为您提供专业的文案建议。\n\n根据您的需求,我可以为您提供:\n- 吸引人的标题创意\n- 产品描述优化\n- 社交媒体文案\n- 广告标语设计\n\n请继续告诉我更多关于您的产品或服务信息,我会提供更有针对性的建议!',
|
||||
sender: 'bot',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, mockBotMessage]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
// 获取具体的错误信息
|
||||
const errorMsg = error instanceof Error ? error.message : '未知错误';
|
||||
const errorMessage: MessageType = {
|
||||
id: (Date.now() + 2).toString(),
|
||||
content: `抱歉,处理您的请求时遇到了问题。请稍后再试,或刷新页面重试。`,
|
||||
sender: 'bot',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
setError(`消息发送失败: ${errorMsg}`);
|
||||
setShowError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理错误提示关闭
|
||||
const handleCloseError = () => {
|
||||
setShowError(false);
|
||||
};
|
||||
|
||||
// 处理回车键发送消息
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理新建对话
|
||||
const handleNewConversation = () => {
|
||||
if (isConfigured && isCozeInitialized()) {
|
||||
const cozeService = getCozeService();
|
||||
cozeService.resetConversation();
|
||||
}
|
||||
// 重置消息列表,只保留欢迎消息
|
||||
setMessages([welcomeMessage]);
|
||||
console.log('已开始新对话');
|
||||
};
|
||||
|
||||
// 渲染聊天界面
|
||||
return (
|
||||
<MuiThemeProvider>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', backgroundColor: theme.palette.background.default }}>
|
||||
{/* 顶部导航 */}
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
px: { xs: 2, md: 4 },
|
||||
py: 2.5,
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl">
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
爆款文案助手
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ opacity: 0.9, fontSize: '0.9rem' }}>
|
||||
让AI帮你生成吸引人的文案内容
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={handleNewConversation}
|
||||
title="新建对话"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MessageIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={() => setActiveTab(activeTab === 'chat' ? 'help' : 'chat')}
|
||||
title={activeTab === 'chat' ? '帮助' : '返回聊天'}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Help fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title="设置"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Settings fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Paper>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* 侧边栏 - 仅在中等及以上屏幕显示 */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* 聊天内容区 */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 内容切换标签 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === 'chat'
|
||||
? `2px solid ${theme.palette.primary.main}`
|
||||
: 'none',
|
||||
color: activeTab === 'chat'
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.text.secondary,
|
||||
fontWeight: activeTab === 'chat' ? 600 : 400,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
onClick={() => setActiveTab('chat')}
|
||||
>
|
||||
对话
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === 'help'
|
||||
? `2px solid ${theme.palette.primary.main}`
|
||||
: 'none',
|
||||
color: activeTab === 'help'
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.text.secondary,
|
||||
fontWeight: activeTab === 'help' ? 600 : 400,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
onClick={() => setActiveTab('help')}
|
||||
>
|
||||
使用帮助
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 帮助页面内容 */}
|
||||
{activeTab === 'help' && (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 2, md: 4 },
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h2" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
如何使用爆款文案助手
|
||||
</Typography>
|
||||
|
||||
<Paper elevation={1} sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: theme.palette.primary.main }}>
|
||||
开始对话
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
1. 简单描述您的产品或服务,包括行业、目标用户和核心卖点
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
2. 告诉我们您需要的文案类型(如产品描述、广告文案、社交媒体内容等)
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
3. 提供任何特定要求或偏好,如风格、长度、关键词等
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={1} sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: theme.palette.primary.main }}>
|
||||
常见问题
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
• 助手可以生成哪些类型的文案?
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2, ml: 4, color: theme.palette.text.secondary }}>
|
||||
产品描述、广告标语、社交媒体文案、标题创意、邮件营销内容等
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
• 如何获得更好的回复?
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2, ml: 4, color: theme.palette.text.secondary }}>
|
||||
提供越详细的信息,获得的文案质量越好。包括目标受众、产品特点、品牌调性等
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 聊天消息区域 */}
|
||||
{activeTab === 'chat' && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
p: { xs: 2, md: 4 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark'
|
||||
? theme.palette.background.default
|
||||
: '#fafafa',
|
||||
}}
|
||||
ref={chatContainerRef}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1 && !isLoading}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<Box display="flex" alignItems="flex-end" gap={1.5} maxWidth={isMobile ? "85%" : "70%"}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: theme.palette.primary.main,
|
||||
boxShadow: theme.shadows[1],
|
||||
}}
|
||||
>
|
||||
🤖
|
||||
</Avatar>
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
padding: 2,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
borderRadius: '18px 18px 18px 4px',
|
||||
minWidth: '120px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
animation: 'pulse 1.4s infinite ease-in-out',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
animation: 'pulse 1.4s infinite ease-in-out 0.2s',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
animation: 'pulse 1.4s infinite ease-in-out 0.4s',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<MessageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSendMessage}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
<Snackbar
|
||||
open={showError}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseError}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseError}
|
||||
severity="error"
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInterface;
|
||||
2
app/service/api.ts
Normal file
2
app/service/api.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Coze API服务文件
|
||||
// 注意:测试函数和未使用的接口已移除,以避免类型冲突问题
|
||||
259
app/service/cozeService.ts
Normal file
259
app/service/cozeService.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import type { Message } from '../types/types';
|
||||
import { CozeAPI } from '@coze/api';
|
||||
|
||||
|
||||
// 定义Coze事件数据接口
|
||||
interface DeltaEventData {
|
||||
content?: string;
|
||||
delta?: { content?: string };
|
||||
}
|
||||
|
||||
interface CompletedEventData {
|
||||
conversation_id?: string;
|
||||
usage?: { token_count?: number };
|
||||
token_count?: number;
|
||||
messages?: { content?: string }[];
|
||||
}
|
||||
|
||||
// 错误事件数据接口 - 备用
|
||||
// interface ErrorEventData {
|
||||
// code?: string;
|
||||
// message?: string;
|
||||
// type?: string;
|
||||
// }
|
||||
|
||||
class CozeService {
|
||||
private apiKey: string | null = null;
|
||||
private botId: string | null = null;
|
||||
private userId: string = `user_${Date.now()}`; // 生成一个临时用户ID
|
||||
private apiClient: CozeAPI | null = null;
|
||||
private conversationId: string | null = null; // 存储对话ID,用于后续调用
|
||||
|
||||
constructor() {
|
||||
// 初始化服务,实际使用时需要设置API密钥和Bot ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置API密钥和Bot ID
|
||||
* @param apiKey Coze API密钥
|
||||
* @param botId Bot ID
|
||||
*/
|
||||
setCredentials(apiKey: string, botId: string): void {
|
||||
this.apiKey = apiKey;
|
||||
this.botId = botId;
|
||||
|
||||
// 初始化Coze API客户端 - 严格按照官方示例实现
|
||||
this.apiClient = new CozeAPI({
|
||||
token: apiKey,
|
||||
baseURL: 'https://api.coze.cn'
|
||||
});
|
||||
|
||||
console.log('Coze API client initialized with official SDK');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给Coze agent
|
||||
* 使用官方SDK的stream方法实现流式响应
|
||||
* 严格按照api.ts示例实现,使用for await...of循环处理流式响应
|
||||
* @param message 用户消息内容
|
||||
* @param onStream 流式响应回调函数
|
||||
* @returns Promise<Message> 完整的响应消息
|
||||
*/
|
||||
async sendMessage(
|
||||
message: string,
|
||||
onStream?: (partialText: string, isDone: boolean) => void
|
||||
): Promise<Message> {
|
||||
// 检查初始化状态
|
||||
if (!this.isInitialized()) {
|
||||
console.warn('Coze服务尚未初始化或凭据不完整。使用模拟回复。');
|
||||
return this.getMockResponse(message);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('正在使用官方SDK发送消息到Coze服务...');
|
||||
console.log('Bot ID:', this.botId);
|
||||
console.log('User ID:', this.userId);
|
||||
|
||||
// 严格按照api.ts示例调用chat.stream方法
|
||||
const res = await this.apiClient!.chat.stream({
|
||||
bot_id: this.botId!,
|
||||
user_id: this.userId,
|
||||
conversation_id: this.conversationId || undefined, // 如果有保存的对话ID则使用
|
||||
additional_messages: [
|
||||
{
|
||||
"content": message,
|
||||
"content_type": "text",
|
||||
"role": "user",
|
||||
"type": "question"
|
||||
}
|
||||
] as any // eslint-disable-line @typescript-eslint/no-explicit-any,
|
||||
});
|
||||
|
||||
let fullContent = '';
|
||||
const messageId = Date.now().toString();
|
||||
|
||||
console.log('开始处理Coze流式响应...');
|
||||
|
||||
// 严格按照api.ts的实现方式,使用for await...of循环处理流式响应
|
||||
for await (const event of res) {
|
||||
try {
|
||||
console.log('接收到Coze事件:', event.event || 'unknown');
|
||||
|
||||
// 1. 处理消息增量事件
|
||||
if (event.event === 'conversation.message.delta') {
|
||||
// 根据TypeScript类型定义,content可能直接在data上
|
||||
const deltaData = event.data as DeltaEventData;
|
||||
if (deltaData && deltaData.content) {
|
||||
const deltaContent = deltaData.content;
|
||||
fullContent += deltaContent;
|
||||
console.log('增量内容:', fullContent.substring(0, 100) + '...');
|
||||
|
||||
// 通知流式更新
|
||||
if (onStream) {
|
||||
onStream(fullContent, false);
|
||||
}
|
||||
} else if (deltaData && deltaData.delta && deltaData.delta.content) {
|
||||
// 兼容另一种可能的数据结构
|
||||
const deltaContent = deltaData.delta.content;
|
||||
fullContent += deltaContent;
|
||||
console.log('增量内容(delta路径):', fullContent.substring(0, 100) + '...');
|
||||
|
||||
if (onStream) {
|
||||
onStream(fullContent, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理聊天完成事件
|
||||
if (event.event === 'conversation.chat.completed') {
|
||||
console.log(); // 输出换行
|
||||
// 尝试从data中获取conversation_id和usage信息
|
||||
const dataObj = event.data as CompletedEventData;
|
||||
|
||||
// 打印conversation_id并保存
|
||||
if (dataObj && dataObj.conversation_id) {
|
||||
console.log('Conversation ID:', dataObj.conversation_id);
|
||||
this.conversationId = dataObj.conversation_id; // 保存对话ID供下次使用
|
||||
}
|
||||
|
||||
// 打印token使用量
|
||||
if (dataObj && dataObj.usage && dataObj.usage.token_count) {
|
||||
console.log('token usage:', dataObj.usage.token_count);
|
||||
} else if (dataObj && dataObj.token_count) {
|
||||
console.log('token usage:', dataObj.token_count);
|
||||
}
|
||||
|
||||
// 尝试从data中获取完整内容(如果有的话)
|
||||
if (dataObj && dataObj.messages && dataObj.messages[0] && dataObj.messages[0].content) {
|
||||
fullContent = dataObj.messages[0].content || '';
|
||||
console.log('使用完成事件中的完整内容');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 处理错误事件
|
||||
if (event.event === 'error') {
|
||||
console.error('错误事件:', event.data);
|
||||
throw new Error(`Coze服务错误: ${JSON.stringify(event.data)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('处理事件时出错:', err);
|
||||
console.log('错误事件完整数据:', JSON.stringify(event, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// 流式处理完成,检查是否有内容
|
||||
console.log('Coze流式响应处理完成,总内容长度:', fullContent.length);
|
||||
|
||||
if (fullContent.trim()) {
|
||||
const botMessage: Message = {
|
||||
id: messageId,
|
||||
content: fullContent,
|
||||
sender: 'bot',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
// 通知流式更新完成
|
||||
if (onStream) {
|
||||
onStream(fullContent, true);
|
||||
}
|
||||
|
||||
return botMessage;
|
||||
} else {
|
||||
throw new Error('Coze服务返回空内容,请检查API配置和网络连接');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message to Coze with official SDK:', error);
|
||||
// 获取具体的错误信息
|
||||
const errorMsg = error instanceof Error ? error.message : '未知错误';
|
||||
|
||||
// 即使出错也返回模拟回复
|
||||
return this.getMockResponse(message, errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模拟回复,当Coze API调用失败时使用
|
||||
*/
|
||||
private getMockResponse(message: string, error?: string): Message {
|
||||
let mockContent = '';
|
||||
|
||||
// 根据用户消息内容生成更有针对性的模拟回复
|
||||
if (message.includes('你好') || message.includes('hello') || message.includes('hi')) {
|
||||
mockContent = '你好!我是你的爆款文案策划师。为了给你提供更有针对性的方案,请告诉我:\n1. 你的行业/产品/服务\n2. 你的目标用户群体\n有了这些信息,我就能帮你打造更精准的文案了!';
|
||||
} else if (message.includes('文案') || message.includes('内容') || message.includes('写作')) {
|
||||
mockContent = '关于文案创作,我有一些专业建议:\n1. 明确目标受众和核心卖点\n2. 使用吸引人的标题和开头\n3. 突出产品/服务的独特价值\n4. 加入社会证明(案例、数据)\n5. 包含明确的行动号召\n需要针对特定行业的文案模板吗?';
|
||||
} else if (message.includes('价格') || message.includes('费用') || message.includes('多少钱')) {
|
||||
mockContent = '文案服务的价格因需求复杂度而异。我们提供:\n- 基础文案套餐:¥500-1000\n- 营销策划套餐:¥1000-3000\n- 品牌全案套餐:¥3000+\n欢迎告诉我你的具体需求,我可以为你提供更精准的报价。';
|
||||
} else {
|
||||
mockContent = '感谢你的提问!作为文案策划师,我可以帮你:\n- 撰写吸引人的营销文案\n- 设计爆款标题\n- 优化产品描述\n- 制定内容营销策略\n请告诉我你具体需要哪方面的帮助?';
|
||||
}
|
||||
|
||||
// 如果有错误信息,可以选择性地在开头添加提示
|
||||
if (error) {
|
||||
console.log('使用模拟回复,原因:', error);
|
||||
// 不直接显示错误,只显示友好提示
|
||||
mockContent = '【提示:当前使用的是智能助手的推荐回复】\n\n' + mockContent;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Date.now().toString(),
|
||||
content: mockContent,
|
||||
sender: 'bot',
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置对话状态
|
||||
*/
|
||||
resetConversation(): void {
|
||||
this.conversationId = null; // 清除保存的对话ID
|
||||
console.log('Conversation reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查客户端是否已初始化
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return !!this.apiClient && !!this.apiKey && !!this.botId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户ID
|
||||
*/
|
||||
setUserId(userId: string): void {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户ID
|
||||
*/
|
||||
getUserId(): string {
|
||||
return this.userId;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
// 创建并导出单例实例
|
||||
const cozeServiceInstance = new CozeService();
|
||||
export default cozeServiceInstance;
|
||||
85
app/service/initializeCoze.ts
Normal file
85
app/service/initializeCoze.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import cozeService from './cozeService';
|
||||
import type { CozeConfig } from '../types/types';
|
||||
|
||||
/**
|
||||
* 初始化Coze服务
|
||||
* 这个函数应该在客户端组件中调用,因为API密钥需要在客户端安全管理
|
||||
*
|
||||
* @param config Coze配置信息,包含API密钥和Bot ID
|
||||
* @returns 是否初始化成功
|
||||
*/
|
||||
export const initializeCoze = (config: CozeConfig): boolean => {
|
||||
try {
|
||||
// 验证配置
|
||||
if (!config.apiKey || !config.botId) {
|
||||
console.error('Missing Coze API key or Bot ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置凭证并初始化客户端
|
||||
cozeService.setCredentials(config.apiKey, config.botId);
|
||||
|
||||
console.log('Coze service initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Coze service:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从环境变量或localStorage加载Coze配置
|
||||
* 在实际应用中,建议使用更安全的方式存储API密钥
|
||||
*/
|
||||
export const loadCozeConfig = (): CozeConfig | null => {
|
||||
try {
|
||||
// 在实际应用中,你可能会从环境变量或安全存储中获取这些值
|
||||
// 这里我们从localStorage中读取作为示例
|
||||
const storedConfig = localStorage.getItem('cozeConfig');
|
||||
if (storedConfig) {
|
||||
return JSON.parse(storedConfig);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load Coze configuration:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存Coze配置到localStorage
|
||||
* 在实际应用中,建议使用更安全的方式存储API密钥
|
||||
*/
|
||||
export const saveCozeConfig = (config: CozeConfig): boolean => {
|
||||
try {
|
||||
localStorage.setItem('cozeConfig', JSON.stringify(config));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save Coze configuration:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取已初始化的Coze服务实例
|
||||
* @returns CozeService实例
|
||||
*/
|
||||
export function getCozeService() {
|
||||
return cozeService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Coze服务是否已初始化
|
||||
* @returns boolean 是否已初始化
|
||||
*/
|
||||
export function isCozeInitialized(): boolean {
|
||||
return cozeService.isInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置Coze对话
|
||||
*/
|
||||
export function resetCozeConversation(): void {
|
||||
cozeService.resetConversation();
|
||||
}
|
||||
232
app/styles.css
Normal file
232
app/styles.css
Normal file
@ -0,0 +1,232 @@
|
||||
/* 全局样式 */
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #5a67d8;
|
||||
--background-color: #f5f5f5;
|
||||
--card-background: #ffffff;
|
||||
--border-color: #e0e0e0;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
/* 聊天界面样式 */
|
||||
.chat-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
/* 顶部导航样式 */
|
||||
.header {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 主内容区域样式 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.sidebar {
|
||||
width: 25%;
|
||||
min-width: 280px;
|
||||
background-color: var(--card-background);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 聊天内容区样式 */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 消息样式 */
|
||||
.message-row {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-row.bot {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.avatar.bot {
|
||||
background-color: var(--primary-color);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.avatar.user {
|
||||
background-color: var(--primary-dark);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-bubble.bot {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 输入区样式 */
|
||||
.input-area {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 加载状态样式 */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
margin: 0 2px;
|
||||
animation: pulse 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 60%, 100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.7;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 640px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
10
app/theme.ts
Normal file
10
app/theme.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
// 创建MUI主题并导出
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
},
|
||||
},
|
||||
});
|
||||
64
app/types/types.ts
Normal file
64
app/types/types.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 消息类型接口
|
||||
*/
|
||||
export interface Message {
|
||||
/**
|
||||
* 消息唯一标识符
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* 发送者类型
|
||||
*/
|
||||
sender: 'user' | 'bot';
|
||||
|
||||
/**
|
||||
* 消息时间戳
|
||||
*/
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coze API 配置接口
|
||||
*/
|
||||
export interface CozeConfig {
|
||||
/**
|
||||
* Coze API 密钥
|
||||
*/
|
||||
apiKey: string;
|
||||
|
||||
/**
|
||||
* Bot ID
|
||||
*/
|
||||
botId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天上下文接口
|
||||
*/
|
||||
export interface ChatContext {
|
||||
/**
|
||||
* 当前对话ID
|
||||
*/
|
||||
conversationId: string | null;
|
||||
|
||||
/**
|
||||
* 历史消息列表
|
||||
*/
|
||||
messages: Message[];
|
||||
|
||||
/**
|
||||
* 是否已初始化
|
||||
*/
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式响应回调函数类型
|
||||
*/
|
||||
export type StreamCallback = (partialText: string, isDone: boolean) => void;
|
||||
Reference in New Issue
Block a user