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