first commit

This commit is contained in:
zch1234qq
2025-10-16 21:24:18 +08:00
commit b9d03d05cc
35 changed files with 10191 additions and 0 deletions

20
.dockerignore Normal file
View File

@ -0,0 +1,20 @@
node_modules
npm-debug.log
yarn-error.log
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.next
dist
out
build
.git
.gitignore
*.md
.vscode
Dockerfile
.dockerignore
*.log
*.lock

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

20
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"MicroPython.executeButton": [
{
"text": "▶",
"tooltip": "运行",
"alignment": "left",
"command": "extension.executeFile",
"priority": 3.5
}
],
"MicroPython.syncButton": [
{
"text": "$(sync)",
"tooltip": "同步",
"alignment": "left",
"command": "extension.execute",
"priority": 4
}
]
}

32
Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# 构建阶段
FROM node:20-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制package.json和package-lock.json
COPY package*.json ./
# 安装依赖
RUN npm install
# 复制项目文件
COPY . .
# 构建应用
RUN npm run build
# 生产阶段
FROM nginx:alpine
# 复制构建产物到nginx的html目录
COPY --from=builder /app/out /usr/share/nginx/html
# 复制自定义nginx配置如果需要
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露80端口
EXPOSE 80
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View 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
View 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;

View 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>
);
};

View 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
View 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
View File

@ -0,0 +1,3 @@
export * from './ChatMessage';
export * from './MessageInput';
export * from './Sidebar';

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

489
app/globals.css Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
// Coze API服务文件
// 注意:测试函数和未使用的接口已移除,以避免类型冲突问题

259
app/service/cozeService.ts Normal file
View 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;

View 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
View 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
View 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
View 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;

25
eslint.config.mjs Normal file
View File

@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

27
nginx.conf Normal file
View File

@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 支持Next.js的路由
location / {
try_files $uri $uri/ /index.html;
}
# 静态文件缓存
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 错误页面
error_page 404 /404.html;
error_page 500 502 503 504 /500.html;
# 禁用访问隐藏文件
location ~ /\. {
deny all;
}
}

7433
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@coze/api": "^1.3.7",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.5",
"tailwindcss": "^4",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}