feat(features): 添加核心功能组件

- ModelSelector: 模型选择下拉框组件
- ToolsDropdown: 工具管理下拉框组件
- MessageBubble: 聊天消息气泡组件
- QuickActions: 快捷操作按钮组件
- Welcome: 欢迎页问候组件
- ChatInput: 聊天输入框组件,集成模型选择和工具管理
This commit is contained in:
gaoziman 2025-12-17 22:54:26 +08:00
parent 5347bc7c2f
commit c2a48986b4
6 changed files with 521 additions and 0 deletions

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

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

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

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

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

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