feat(features): 添加核心功能组件
- ModelSelector: 模型选择下拉框组件 - ToolsDropdown: 工具管理下拉框组件 - MessageBubble: 聊天消息气泡组件 - QuickActions: 快捷操作按钮组件 - Welcome: 欢迎页问候组件 - ChatInput: 聊天输入框组件,集成模型选择和工具管理
This commit is contained in:
parent
5347bc7c2f
commit
c2a48986b4
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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user