Compare commits
10 Commits
75e7c957aa
...
bc1ad129d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1ad129d0 | ||
|
|
9356c87180 | ||
|
|
01777b3786 | ||
|
|
d055ec7473 | ||
|
|
c2a48986b4 | ||
|
|
5347bc7c2f | ||
|
|
ee9dc67708 | ||
|
|
fefacff0d1 | ||
|
|
05fd8e17f5 | ||
|
|
db418d0f0d |
@ -9,6 +9,8 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.561.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1"
|
"react-dom": "19.2.1"
|
||||||
|
|||||||
@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.561.0
|
||||||
|
version: 0.561.0(react@19.2.1)
|
||||||
next:
|
next:
|
||||||
specifier: 16.0.10
|
specifier: 16.0.10
|
||||||
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||||
@ -799,6 +805,10 @@ packages:
|
|||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@ -1450,6 +1460,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lucide-react@0.561.0:
|
||||||
|
resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@ -2649,6 +2664,8 @@ snapshots:
|
|||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@ -3427,6 +3444,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@0.561.0(react@19.2.1):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.1
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
151
src/app/chat/[id]/page.tsx
Normal file
151
src/app/chat/[id]/page.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Share2, MoreHorizontal } from 'lucide-react';
|
||||||
|
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
|
||||||
|
import { ChatInput } from '@/components/features/ChatInput';
|
||||||
|
import { MessageBubble } from '@/components/features/MessageBubble';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
models,
|
||||||
|
tools as initialTools,
|
||||||
|
chatHistories,
|
||||||
|
sampleMessages,
|
||||||
|
currentUser
|
||||||
|
} from '@/data/mock';
|
||||||
|
import type { Model, Tool, Message } from '@/types';
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const chatId = params.id as string;
|
||||||
|
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<Model>(models[1]);
|
||||||
|
const [tools, setTools] = useState<Tool[]>(initialTools);
|
||||||
|
const [messages, setMessages] = useState<Message[]>(sampleMessages);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 获取当前聊天信息
|
||||||
|
const currentChat = chatHistories.find(c => c.id === chatId);
|
||||||
|
const chatTitle = currentChat?.title || '新对话';
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleToolToggle = (toolId: string) => {
|
||||||
|
setTools((prev) =>
|
||||||
|
prev.map((tool) =>
|
||||||
|
tool.id === toolId ? { ...tool, enabled: !tool.enabled } : tool
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnableAllTools = (enabled: boolean) => {
|
||||||
|
setTools((prev) =>
|
||||||
|
prev.map((tool) => ({ ...tool, enabled }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = (message: string) => {
|
||||||
|
// 添加用户消息
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
|
||||||
|
// 模拟 AI 回复
|
||||||
|
setTimeout(() => {
|
||||||
|
const aiMessage: Message = {
|
||||||
|
id: `msg-${Date.now() + 1}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '感谢您的消息!这是一个模拟的 AI 回复。在实际应用中,这里会调用 AI API 生成真实的回复内容。\n\n您可以继续与我对话,我会尽力帮助您解决问题。',
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, aiMessage]);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<Sidebar
|
||||||
|
user={currentUser}
|
||||||
|
chatHistories={chatHistories}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex flex-col min-h-screen transition-all duration-300',
|
||||||
|
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 固定顶部 Header */}
|
||||||
|
<header className="h-[var(--header-height)] px-4 flex items-center justify-between border-b border-[var(--color-border)] bg-white sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
<h1 className="text-base font-medium text-[var(--color-text-primary)] truncate max-w-[300px]">
|
||||||
|
{chatTitle}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||||
|
title="分享对话"
|
||||||
|
>
|
||||||
|
<Share2 size={16} />
|
||||||
|
<span>分享</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||||
|
title="更多选项"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 消息列表区域 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[900px] mx-auto px-4 py-6">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
user={message.role === 'user' ? currentUser : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 固定底部输入框 */}
|
||||||
|
<div className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent pt-4">
|
||||||
|
<div className="max-w-[900px] mx-auto px-4 pb-4">
|
||||||
|
<ChatInput
|
||||||
|
models={models}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelSelect={setSelectedModel}
|
||||||
|
tools={tools}
|
||||||
|
onToolToggle={handleToolToggle}
|
||||||
|
onEnableAllTools={handleEnableAllTools}
|
||||||
|
onSend={handleSend}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,26 +1,264 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
cchcode UI - 设计令牌
|
||||||
|
基于原型图: https://openclaude.me/chat
|
||||||
|
======================================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
/* 品牌色 */
|
||||||
--foreground: #171717;
|
--color-primary: #E06B3E;
|
||||||
|
--color-primary-hover: #D4643E;
|
||||||
|
--color-primary-light: #FEF3EF;
|
||||||
|
--color-primary-alpha: rgba(224, 107, 62, 0.1);
|
||||||
|
|
||||||
|
/* 背景色 */
|
||||||
|
--color-bg-primary: #FFFFFF;
|
||||||
|
--color-bg-secondary: #FAFAF9;
|
||||||
|
--color-bg-tertiary: #F7F7F8;
|
||||||
|
--color-bg-hover: #F3F4F6;
|
||||||
|
|
||||||
|
/* 文字色 */
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #6B7280;
|
||||||
|
--color-text-tertiary: #9CA3AF;
|
||||||
|
--color-text-placeholder: #A0AEC0;
|
||||||
|
|
||||||
|
/* 边框色 */
|
||||||
|
--color-border: #E5E5E5;
|
||||||
|
--color-border-light: #F0F0F0;
|
||||||
|
--color-border-focus: #E06B3E;
|
||||||
|
|
||||||
|
/* 消息气泡色 */
|
||||||
|
--color-message-user: #EDE4DE;
|
||||||
|
--color-message-assistant-bg: #F7F7F6;
|
||||||
|
--color-message-assistant-border: #E8E8E8;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-dropdown: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-input: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-input-focus: 0 4px 16px rgba(224, 107, 62, 0.1);
|
||||||
|
|
||||||
|
/* 布局 */
|
||||||
|
--sidebar-width: 260px;
|
||||||
|
--header-height: 56px;
|
||||||
|
--input-max-width: 900px;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-2xl: 18px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-base: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
/* 品牌色 */
|
||||||
--color-foreground: var(--foreground);
|
--color-primary: var(--color-primary);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-primary-hover: var(--color-primary-hover);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-primary-light: var(--color-primary-light);
|
||||||
|
|
||||||
|
/* 背景色 */
|
||||||
|
--color-background: var(--color-bg-secondary);
|
||||||
|
--color-foreground: var(--color-text-primary);
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--font-mono: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* ========================================
|
||||||
:root {
|
全局样式
|
||||||
--background: #0a0a0a;
|
======================================== */
|
||||||
--foreground: #ededed;
|
* {
|
||||||
}
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background-color: var(--color-bg-secondary);
|
||||||
color: var(--foreground);
|
color: var(--color-text-primary);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
自定义滚动条
|
||||||
|
======================================== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
动画
|
||||||
|
======================================== */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slideUp var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-down {
|
||||||
|
animation: slideDown var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
响应式设计
|
||||||
|
======================================== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
按钮样式
|
||||||
|
======================================== */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #DC2626;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #B91C1C;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
设置页面选择器
|
||||||
|
======================================== */
|
||||||
|
.settings-select {
|
||||||
|
appearance: none;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 32px 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-input-focus);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "cchcode - AI 智能助手",
|
||||||
description: "Generated by create next app",
|
description: "基于 Claude 的智能对话助手",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.ico",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -23,10 +15,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body
|
<body className="antialiased">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
125
src/app/page.tsx
125
src/app/page.tsx
@ -1,65 +1,70 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AppLayout } from '@/components/layout/AppLayout';
|
||||||
|
import { Welcome } from '@/components/features/Welcome';
|
||||||
|
import { ChatInput } from '@/components/features/ChatInput';
|
||||||
|
import { QuickActions } from '@/components/features/QuickActions';
|
||||||
|
import { models, tools as initialTools, quickActions, currentUser, getGreeting } from '@/data/mock';
|
||||||
|
import type { Model, Tool, QuickAction } from '@/types';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedModel, setSelectedModel] = useState<Model>(models[1]); // Sonnet as default
|
||||||
|
const [tools, setTools] = useState<Tool[]>(initialTools);
|
||||||
|
|
||||||
|
const greeting = getGreeting(currentUser.name);
|
||||||
|
|
||||||
|
const handleToolToggle = (toolId: string) => {
|
||||||
|
setTools((prev) =>
|
||||||
|
prev.map((tool) =>
|
||||||
|
tool.id === toolId ? { ...tool, enabled: !tool.enabled } : tool
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnableAllTools = (enabled: boolean) => {
|
||||||
|
setTools((prev) =>
|
||||||
|
prev.map((tool) => ({ ...tool, enabled }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = (message: string) => {
|
||||||
|
// 在实际应用中,这里会创建新对话并跳转
|
||||||
|
console.log('Sending message:', message);
|
||||||
|
router.push('/chat/1');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickAction = (action: QuickAction) => {
|
||||||
|
if (action.prompt) {
|
||||||
|
handleSend(action.prompt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<AppLayout>
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="w-full max-w-[900px] mx-auto">
|
||||||
<Image
|
{/* 欢迎区域 */}
|
||||||
className="dark:invert"
|
<Welcome greeting={greeting} className="mb-8" />
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
{/* 聊天输入框 */}
|
||||||
width={100}
|
<ChatInput
|
||||||
height={20}
|
models={models}
|
||||||
priority
|
selectedModel={selectedModel}
|
||||||
|
onModelSelect={setSelectedModel}
|
||||||
|
tools={tools}
|
||||||
|
onToolToggle={handleToolToggle}
|
||||||
|
onEnableAllTools={handleEnableAllTools}
|
||||||
|
onSend={handleSend}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
{/* 快捷操作 */}
|
||||||
To get started, edit the page.tsx file.
|
<QuickActions
|
||||||
</h1>
|
actions={quickActions}
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
onSelect={handleQuickAction}
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
/>
|
||||||
<a
|
</div>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</AppLayout>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
317
src/app/settings/page.tsx
Normal file
317
src/app/settings/page.tsx
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Zap, Download } from 'lucide-react';
|
||||||
|
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
|
||||||
|
import { Toggle } from '@/components/ui/Toggle';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { currentUser, chatHistories, models } from '@/data/mock';
|
||||||
|
import type { Settings } from '@/types';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [settings, setSettings] = useState<Settings>({
|
||||||
|
defaultModel: 'sonnet',
|
||||||
|
theme: 'light',
|
||||||
|
language: 'zh-CN',
|
||||||
|
enableWebSearch: true,
|
||||||
|
enableCodeExecution: false,
|
||||||
|
saveChatHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = (key: keyof Settings) => {
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (key: keyof Settings, value: string) => {
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<Sidebar
|
||||||
|
user={currentUser}
|
||||||
|
chatHistories={chatHistories}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-h-screen transition-all duration-300',
|
||||||
|
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-[var(--header-height)] px-4 flex items-center">
|
||||||
|
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-8 pb-8">
|
||||||
|
<div className="max-w-[640px] mx-auto">
|
||||||
|
{/* 返回链接 */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-[var(--color-text-secondary)] text-sm mb-4 hover:text-[var(--color-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span>返回聊天</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-[var(--color-text-primary)] mb-2">
|
||||||
|
设置
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||||
|
管理您的账户设置和偏好
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 账户设置 */}
|
||||||
|
<SettingsSection
|
||||||
|
title="账户"
|
||||||
|
description="管理您的账户信息"
|
||||||
|
>
|
||||||
|
<SettingsItem
|
||||||
|
label="邮箱"
|
||||||
|
description={currentUser.email}
|
||||||
|
>
|
||||||
|
<button className="btn-ghost">更改</button>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="当前计划"
|
||||||
|
description={
|
||||||
|
<span className="inline-flex items-center gap-2 px-4 py-2 bg-[var(--color-primary-light)] rounded-full text-sm text-[var(--color-primary)] font-medium">
|
||||||
|
<Zap size={14} />
|
||||||
|
{currentUser.plan === 'free' ? '免费计划' : '专业计划'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button className="btn-primary">升级</button>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="使用量"
|
||||||
|
description="本月消息数"
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-2 bg-[var(--color-bg-tertiary)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-primary)] rounded-full transition-all duration-500"
|
||||||
|
style={{ width: '35%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
<span>35 / 100 条消息</span>
|
||||||
|
<span>35%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* 偏好设置 */}
|
||||||
|
<SettingsSection
|
||||||
|
title="偏好设置"
|
||||||
|
description="自定义您的体验"
|
||||||
|
>
|
||||||
|
<SettingsItem
|
||||||
|
label="默认模型"
|
||||||
|
description="为新对话选择默认 AI 模型"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={settings.defaultModel}
|
||||||
|
onChange={(e) => handleSelectChange('defaultModel', e.target.value)}
|
||||||
|
>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="主题"
|
||||||
|
description="选择您喜欢的主题"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={settings.theme}
|
||||||
|
onChange={(e) => handleSelectChange('theme', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="system">跟随系统</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
<option value="dark">深色</option>
|
||||||
|
</select>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="语言"
|
||||||
|
description="选择您的首选语言"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={settings.language}
|
||||||
|
onChange={(e) => handleSelectChange('language', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="zh-TW">繁體中文</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
</select>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="启用网络搜索"
|
||||||
|
description="允许 AI 搜索网络获取信息"
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
checked={settings.enableWebSearch}
|
||||||
|
onChange={() => handleToggle('enableWebSearch')}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="启用代码执行"
|
||||||
|
description="允许 AI 运行代码片段"
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
checked={settings.enableCodeExecution}
|
||||||
|
onChange={() => handleToggle('enableCodeExecution')}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* 数据与隐私 */}
|
||||||
|
<SettingsSection
|
||||||
|
title="数据与隐私"
|
||||||
|
description="管理您的数据和隐私设置"
|
||||||
|
>
|
||||||
|
<SettingsItem
|
||||||
|
label="聊天历史"
|
||||||
|
description="保存您的对话记录"
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
checked={settings.saveChatHistory}
|
||||||
|
onChange={() => handleToggle('saveChatHistory')}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="导出数据"
|
||||||
|
description="下载所有聊天历史"
|
||||||
|
>
|
||||||
|
<button className="btn-ghost inline-flex items-center gap-2">
|
||||||
|
<Download size={16} />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="清除所有聊天"
|
||||||
|
description="删除所有对话历史"
|
||||||
|
>
|
||||||
|
<button className="btn-ghost text-red-600 hover:text-red-700">
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* 危险区域 */}
|
||||||
|
<SettingsSection
|
||||||
|
title="危险区域"
|
||||||
|
description="不可逆操作"
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
<SettingsItem
|
||||||
|
label="删除账户"
|
||||||
|
description="永久删除您的账户和所有数据"
|
||||||
|
>
|
||||||
|
<button className="btn-danger">删除账户</button>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置区域组件
|
||||||
|
interface SettingsSectionProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSection({ title, description, children, variant = 'default' }: SettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
'bg-white border rounded-xl mb-6 overflow-hidden',
|
||||||
|
variant === 'danger' ? 'border-red-200' : 'border-[var(--color-border)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-5 py-4 border-b',
|
||||||
|
variant === 'danger'
|
||||||
|
? 'bg-red-50 border-red-200'
|
||||||
|
: 'border-[var(--color-border-light)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold',
|
||||||
|
variant === 'danger' ? 'text-red-600' : 'text-[var(--color-text-primary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置项组件
|
||||||
|
interface SettingsItemProps {
|
||||||
|
label: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsItem({ label, description, children }: SettingsItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center px-5 py-4 border-b border-[var(--color-border-light)] last:border-b-0 hover:bg-[var(--color-bg-tertiary)] transition-colors">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/features/ChatInput.tsx
Normal file
126
src/components/features/ChatInput.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Clock, ArrowUp } from 'lucide-react';
|
||||||
|
import { ModelSelector } from './ModelSelector';
|
||||||
|
import { ToolsDropdown } from './ToolsDropdown';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Model, Tool } from '@/types';
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
models: Model[];
|
||||||
|
selectedModel: Model;
|
||||||
|
onModelSelect: (model: Model) => void;
|
||||||
|
tools: Tool[];
|
||||||
|
onToolToggle: (toolId: string) => void;
|
||||||
|
onEnableAllTools: (enabled: boolean) => void;
|
||||||
|
onSend: (message: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({
|
||||||
|
models,
|
||||||
|
selectedModel,
|
||||||
|
onModelSelect,
|
||||||
|
tools,
|
||||||
|
onToolToggle,
|
||||||
|
onEnableAllTools,
|
||||||
|
onSend,
|
||||||
|
placeholder = 'How can I help you today?',
|
||||||
|
className,
|
||||||
|
}: ChatInputProps) {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (message.trim()) {
|
||||||
|
onSend(message);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col bg-white border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]',
|
||||||
|
'transition-all duration-150',
|
||||||
|
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 第一行:输入区域 */}
|
||||||
|
<div className="w-full mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full border-none outline-none text-base text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第二行:功能按钮区域 */}
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
{/* 左侧按钮 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* 添加附件 */}
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
title="Add attachment"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 工具下拉 */}
|
||||||
|
<ToolsDropdown
|
||||||
|
tools={tools}
|
||||||
|
onToolToggle={onToolToggle}
|
||||||
|
onEnableAllToggle={onEnableAllTools}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 历史记录 */}
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
title="History"
|
||||||
|
>
|
||||||
|
<Clock size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧按钮 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 模型选择器 */}
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onSelect={onModelSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 发送按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!message.trim()}
|
||||||
|
className={cn(
|
||||||
|
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150',
|
||||||
|
message.trim()
|
||||||
|
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
|
||||||
|
: 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
title="Send message"
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/features/MessageBubble.tsx
Normal file
140
src/components/features/MessageBubble.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Copy, ThumbsUp, ThumbsDown, RefreshCw } from 'lucide-react';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { AILogo } from '@/components/ui/AILogo';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Message, User } from '@/types';
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: Message;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message, user }: MessageBubbleProps) {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
// 简单的 Markdown 渲染
|
||||||
|
const renderContent = (content: string) => {
|
||||||
|
// 处理代码块
|
||||||
|
const parts = content.split(/(`[^`]+`)/g);
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
key={index}
|
||||||
|
className="bg-red-50 text-red-700 px-1.5 py-0.5 rounded text-sm font-mono"
|
||||||
|
>
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 处理粗体
|
||||||
|
const boldParts = part.split(/(\*\*[^*]+\*\*)/g);
|
||||||
|
return boldParts.map((boldPart, boldIndex) => {
|
||||||
|
if (boldPart.startsWith('**') && boldPart.endsWith('**')) {
|
||||||
|
return (
|
||||||
|
<strong key={`${index}-${boldIndex}`} className="font-semibold">
|
||||||
|
{boldPart.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={`${index}-${boldIndex}`}>{boldPart}</span>;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in">
|
||||||
|
<div className="max-w-[70%]">
|
||||||
|
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-[18px] text-base leading-relaxed">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{user && <Avatar name={user.name} size="md" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-4 mb-8 animate-fade-in">
|
||||||
|
{/* AI 图标 */}
|
||||||
|
<div className="flex-shrink-0 mt-4">
|
||||||
|
<AILogo size={28} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息内容 */}
|
||||||
|
<div className="flex-1 max-w-full">
|
||||||
|
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-2xl px-6 py-5 shadow-sm">
|
||||||
|
<div className="text-base text-[var(--color-text-primary)] leading-[1.8] whitespace-pre-wrap">
|
||||||
|
{message.content.split('\n\n').map((paragraph, index) => {
|
||||||
|
// 处理列表
|
||||||
|
if (paragraph.startsWith('- ') || paragraph.startsWith('* ')) {
|
||||||
|
const items = paragraph.split('\n');
|
||||||
|
return (
|
||||||
|
<ul key={index} className="list-disc pl-5 my-3 space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li key={i} className="leading-relaxed">
|
||||||
|
{renderContent(item.replace(/^[-*]\s/, ''))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 处理有序列表
|
||||||
|
if (/^\d+\.\s/.test(paragraph)) {
|
||||||
|
const items = paragraph.split('\n');
|
||||||
|
return (
|
||||||
|
<ol key={index} className="list-decimal pl-5 my-3 space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li key={i} className="leading-relaxed marker:text-[var(--color-primary)] marker:font-medium">
|
||||||
|
{renderContent(item.replace(/^\d+\.\s/, ''))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p key={index} className="mb-4 last:mb-0">
|
||||||
|
{renderContent(paragraph)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center gap-1 mt-4 pt-3">
|
||||||
|
<ActionButton icon={Copy} title="Copy" />
|
||||||
|
<ActionButton icon={ThumbsUp} title="Good response" />
|
||||||
|
<ActionButton icon={ThumbsDown} title="Bad response" />
|
||||||
|
<ActionButton icon={RefreshCw} title="Regenerate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 免责声明 */}
|
||||||
|
<div className="text-xs text-[var(--color-text-tertiary)] text-right mt-2">
|
||||||
|
cchcode can make mistakes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionButtonProps {
|
||||||
|
icon: React.ComponentType<{ size?: number }>;
|
||||||
|
title: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ icon: Icon, title, onClick }: ActionButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/features/ModelSelector.tsx
Normal file
80
src/components/features/ModelSelector.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Model } from '@/types';
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
models: Model[];
|
||||||
|
selectedModel: Model;
|
||||||
|
onSelect: (model: Model) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ models, selectedModel, onSelect }: ModelSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', isOpen && 'model-selector--open')} ref={dropdownRef}>
|
||||||
|
{/* 触发按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-sm font-medium text-[var(--color-text-secondary)] rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<span>{selectedModel.name}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={cn('transition-transform duration-150', isOpen && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 下拉菜单 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-full right-0 mb-2 min-w-[160px] bg-white border border-[var(--color-border)] rounded-xl shadow-lg p-1 z-50',
|
||||||
|
'transition-all duration-150',
|
||||||
|
isOpen
|
||||||
|
? 'opacity-100 visible translate-y-0'
|
||||||
|
: 'opacity-0 invisible translate-y-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{models.map((model) => {
|
||||||
|
const isSelected = model.id === selectedModel.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(model);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between w-full px-3 py-2 rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-medium text-sm',
|
||||||
|
isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-primary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">{model.tag}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/features/QuickActions.tsx
Normal file
45
src/components/features/QuickActions.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Code, PenTool, GraduationCap, Home, Sparkles } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { QuickAction } from '@/types';
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
|
Code,
|
||||||
|
PenTool,
|
||||||
|
GraduationCap,
|
||||||
|
Home,
|
||||||
|
Sparkles,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuickActionsProps {
|
||||||
|
actions: QuickAction[];
|
||||||
|
onSelect: (action: QuickAction) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActions({ actions, onSelect, className }: QuickActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-wrap justify-center gap-3 mt-6', className)}>
|
||||||
|
{actions.map((action) => {
|
||||||
|
const Icon = iconMap[action.icon];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => onSelect(action)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2',
|
||||||
|
'bg-white border border-[var(--color-border)] rounded-full',
|
||||||
|
'text-sm text-[var(--color-text-secondary)]',
|
||||||
|
'hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)]',
|
||||||
|
'transition-all duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={16} className="quick-action__icon" />}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/features/ToolsDropdown.tsx
Normal file
105
src/components/features/ToolsDropdown.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Wrench, Search, Terminal, Globe, Check } from 'lucide-react';
|
||||||
|
import { Toggle } from '@/components/ui/Toggle';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Tool } from '@/types';
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
|
Search,
|
||||||
|
Terminal,
|
||||||
|
Globe,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToolsDropdownProps {
|
||||||
|
tools: Tool[];
|
||||||
|
onToolToggle: (toolId: string) => void;
|
||||||
|
onEnableAllToggle: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolsDropdown({ tools, onToolToggle, onEnableAllToggle }: ToolsDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const enabledCount = tools.filter((t) => t.enabled).length;
|
||||||
|
const allEnabled = enabledCount > 0;
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* 触发按钮 */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors',
|
||||||
|
enabledCount > 0
|
||||||
|
? 'text-[var(--color-primary)] bg-[var(--color-primary-light)]'
|
||||||
|
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
)}
|
||||||
|
title="Tools"
|
||||||
|
>
|
||||||
|
<Wrench size={20} />
|
||||||
|
</button>
|
||||||
|
{enabledCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 bg-[var(--color-primary)] text-white text-[10px] font-semibold rounded-full flex items-center justify-center px-1">
|
||||||
|
{enabledCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下拉菜单 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-full left-0 mb-2 min-w-[220px] bg-white border border-[var(--color-border)] rounded-xl shadow-lg p-2 z-50',
|
||||||
|
'transition-all duration-150',
|
||||||
|
isOpen
|
||||||
|
? 'opacity-100 visible translate-y-0'
|
||||||
|
: 'opacity-0 invisible translate-y-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--color-border-light)] mb-2">
|
||||||
|
<span className="text-sm font-medium text-[var(--color-text-primary)]">Enable Tools</span>
|
||||||
|
<Toggle checked={allEnabled} onChange={onEnableAllToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 工具列表 */}
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const Icon = iconMap[tool.icon];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool.id}
|
||||||
|
onClick={() => onToolToggle(tool.id)}
|
||||||
|
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={20} className="text-[var(--color-text-tertiary)]" />}
|
||||||
|
<span className="flex-1 text-sm text-[var(--color-text-primary)] text-left">
|
||||||
|
{tool.name}
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
size={16}
|
||||||
|
className={cn(
|
||||||
|
'text-[var(--color-primary)] transition-opacity',
|
||||||
|
tool.enabled ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/features/Welcome.tsx
Normal file
25
src/components/features/Welcome.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AILogo } from '@/components/ui/AILogo';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface WelcomeProps {
|
||||||
|
greeting: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Welcome({ greeting, className }: WelcomeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('text-center animate-fade-in', className)}>
|
||||||
|
{/* 装饰图标 */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<AILogo size={48} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 问候语 */}
|
||||||
|
<h1 className="text-4xl font-normal text-[var(--color-text-primary)]">
|
||||||
|
{greeting}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/layout/AppLayout.tsx
Normal file
49
src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
|
||||||
|
import { currentUser, chatHistories } from '@/data/mock';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
showHeader?: boolean;
|
||||||
|
headerContent?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLayout({ children, showHeader = true, headerContent }: AppLayoutProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<Sidebar
|
||||||
|
user={currentUser}
|
||||||
|
chatHistories={chatHistories}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex flex-col min-h-screen transition-all duration-300',
|
||||||
|
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{showHeader && (
|
||||||
|
<header className="h-[var(--header-height)] px-4 flex items-center justify-start">
|
||||||
|
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
{headerContent}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 flex flex-col justify-center items-center p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/layout/Sidebar.tsx
Normal file
107
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Plus, ChevronDown, PanelLeft } from 'lucide-react';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ChatHistory, User } from '@/types';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
user: User;
|
||||||
|
chatHistories: ChatHistory[];
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ user, chatHistories, isOpen = true }: SidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 left-0 bottom-0 z-50 bg-white border-r border-[var(--color-border-light)] flex flex-col transition-all duration-300 ease-in-out overflow-hidden',
|
||||||
|
isOpen ? 'w-[var(--sidebar-width)]' : 'w-0 border-r-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 品牌 Header */}
|
||||||
|
<header className="p-4">
|
||||||
|
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">cchcode</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 新建对话按钮 */}
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-[var(--color-primary)] text-sm font-medium py-2 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
<span>New chat</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recents 标签 */}
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<h2 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">
|
||||||
|
Recents
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 聊天列表 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1">
|
||||||
|
{chatHistories.map((chat) => {
|
||||||
|
const isActive = pathname === `/chat/${chat.id}`;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={chat.id}
|
||||||
|
href={`/chat/${chat.id}`}
|
||||||
|
className={cn(
|
||||||
|
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate',
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{chat.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 用户信息 Footer */}
|
||||||
|
<footer className="p-4 border-t border-[var(--color-border-light)] mt-auto">
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||||
|
<Avatar name={user.name} size="md" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-[var(--color-text-primary)] truncate">{user.email}</div>
|
||||||
|
<div className="text-xs text-[var(--color-text-tertiary)] capitalize">{user.plan} plan</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} className="text-[var(--color-text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 移动端遮罩 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/20 z-40 md:hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧边栏切换按钮
|
||||||
|
export function SidebarToggle({ onClick }: { onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
title="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<PanelLeft size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/ui/AILogo.tsx
Normal file
26
src/components/ui/AILogo.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AILogoProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AILogo({ size = 48, className }: AILogoProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={cn('text-[var(--color-primary)]', className)}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M24 4L26.5 18.5L41 16L29.5 24L41 32L26.5 29.5L24 44L21.5 29.5L7 32L18.5 24L7 16L21.5 18.5L24 4Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/ui/Avatar.tsx
Normal file
31
src/components/ui/Avatar.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
name: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ name, size = 'md', className }: AvatarProps) {
|
||||||
|
const initial = name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-6 h-6 text-xs',
|
||||||
|
md: 'w-8 h-8 text-sm',
|
||||||
|
lg: 'w-10 h-10 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center rounded-full bg-[var(--color-primary)] text-white font-semibold',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/ui/Toggle.tsx
Normal file
32
src/components/ui/Toggle.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ToggleProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, className }: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
'relative w-11 h-6 rounded-full transition-colors duration-150 ease-in-out',
|
||||||
|
checked ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-border)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-sm transition-transform duration-150 ease-in-out',
|
||||||
|
checked && 'translate-x-5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/data/mock.ts
Normal file
82
src/data/mock.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import type { Model, Tool, ChatHistory, Message, User, QuickAction } from '@/types';
|
||||||
|
|
||||||
|
// 模型列表
|
||||||
|
export const models: Model[] = [
|
||||||
|
{ id: 'haiku', name: 'Haiku 4.5', tag: 'Fast' },
|
||||||
|
{ id: 'sonnet', name: 'Sonnet 4.5', tag: 'Balanced' },
|
||||||
|
{ id: 'opus', name: 'Opus 4.5', tag: 'Powerful' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 工具列表
|
||||||
|
export const tools: Tool[] = [
|
||||||
|
{ id: 'web-search', name: 'Web Search', icon: 'Search', enabled: true },
|
||||||
|
{ id: 'code-execution', name: 'Code Execution', icon: 'Terminal', enabled: false },
|
||||||
|
{ id: 'web-fetch', name: 'Web Fetch', icon: 'Globe', enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 聊天历史
|
||||||
|
export const chatHistories: ChatHistory[] = [
|
||||||
|
{ id: '1', title: '你好', createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
{ id: '2', title: 'React组件开发', createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
{ id: '3', title: 'TypeScript类型问题', createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 示例消息
|
||||||
|
export const sampleMessages: Message[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
role: 'user',
|
||||||
|
content: '你的模型id',
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
role: 'assistant',
|
||||||
|
content: `我是 **Claude 4.5 Sonnet**,由 Anthropic 制作。
|
||||||
|
|
||||||
|
不过需要说明的是,我目前运行在 cchcode (openclaude.me) 这个平台上,而不是 Anthropic 的官方平台。具体的模型 ID(如 API 调用时使用的标识符)可能是:
|
||||||
|
|
||||||
|
- \`claude-4.5-sonnet\`(如果这是实际的模型版本)
|
||||||
|
- 或类似的标识符
|
||||||
|
|
||||||
|
但我需要诚实地说:根据我的知识(截至 2024 年底),Anthropic 官方发布的最新版本是 **Claude 3.5 Sonnet**(模型 ID 通常是 \`claude-3-5-sonnet-20241022\` 或类似格式)。"Claude 4.5 Sonnet" 这个名称可能是:
|
||||||
|
|
||||||
|
1. cchcode 平台的内部命名
|
||||||
|
2. 我被告知的身份信息
|
||||||
|
3. 或者是在我知识截止日期之后发布的新版本
|
||||||
|
|
||||||
|
如果你想确认具体使用的是哪个模型版本,建议查看 cchcode 平台的设置或文档说明。你想了解这个信息是为了什么目的呢?也许我可以帮你解决实际问题。`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 当前用户
|
||||||
|
export const currentUser: User = {
|
||||||
|
id: '1',
|
||||||
|
email: 'cisyamgao@gmail.com',
|
||||||
|
name: 'cisyamgao',
|
||||||
|
plan: 'free',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 快捷操作
|
||||||
|
export const quickActions: QuickAction[] = [
|
||||||
|
{ id: 'code', label: 'Code', icon: 'Code', prompt: '帮我写一段代码...' },
|
||||||
|
{ id: 'write', label: 'Write', icon: 'PenTool', prompt: '帮我写一篇...' },
|
||||||
|
{ id: 'learn', label: 'Learn', icon: 'GraduationCap', prompt: '帮我学习...' },
|
||||||
|
{ id: 'life', label: 'Life stuff', icon: 'Home', prompt: '帮我处理生活中的事情...' },
|
||||||
|
{ id: 'surprise', label: 'Surprise me', icon: 'Sparkles', prompt: '给我一些惊喜...' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 获取问候语
|
||||||
|
export function getGreeting(name: string): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
let greeting = 'Good morning';
|
||||||
|
|
||||||
|
if (hour >= 12 && hour < 18) {
|
||||||
|
greeting = 'Good afternoon';
|
||||||
|
} else if (hour >= 18) {
|
||||||
|
greeting = 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${greeting}, ${name}`;
|
||||||
|
}
|
||||||
5
src/lib/utils.ts
Normal file
5
src/lib/utils.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return clsx(inputs);
|
||||||
|
}
|
||||||
57
src/types/index.ts
Normal file
57
src/types/index.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// 模型类型
|
||||||
|
export interface Model {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具类型
|
||||||
|
export interface Tool {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天记录类型
|
||||||
|
export interface ChatHistory {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息类型
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户类型
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
plan: 'free' | 'pro' | 'enterprise';
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置类型
|
||||||
|
export interface Settings {
|
||||||
|
defaultModel: string;
|
||||||
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
language: string;
|
||||||
|
webSearchEnabled: boolean;
|
||||||
|
codeExecutionEnabled: boolean;
|
||||||
|
chatHistoryEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷操作类型
|
||||||
|
export interface QuickAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user