feat: MetonaAI Desktop 初始项目
- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* UserMessage — 用户消息卡片
|
||||
*
|
||||
* 全宽卡片流布局,左侧头像。
|
||||
* 支持附件可视化:图片缩略图、文件卡片。
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box, Typography, Avatar, Stack, TextareaAutosize } from '@mui/material';
|
||||
import { User, FileText, Image as ImageIcon, X } from 'lucide-react';
|
||||
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
|
||||
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
|
||||
|
||||
interface UserMessageProps { message: ChatMessage; }
|
||||
|
||||
export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(message.content);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const updateMessage = useAgentStore((s) => s.updateMessage);
|
||||
|
||||
const handleDoubleClick = useCallback(() => { setEditing(true); setEditContent(message.content); }, [message.content]);
|
||||
const handleEditSave = useCallback(() => {
|
||||
if (editContent.trim() && editContent !== message.content) updateMessage(message.id, { content: editContent.trim() });
|
||||
setEditing(false);
|
||||
}, [editContent, message.content, message.id, updateMessage]);
|
||||
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setEditing(false); setEditContent(message.content); }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleEditSave();
|
||||
}, [handleEditSave, message.content]);
|
||||
useEffect(() => { if (editing) textareaRef.current?.focus(); }, [editing]);
|
||||
|
||||
const hasAttachments = message.attachments && message.attachments.length > 0;
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }}>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'action.hover', color: 'primary.main', flexShrink: 0 }}><User size={16} /></Avatar>
|
||||
<Box
|
||||
sx={{ maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, cursor: 'default', bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
|
||||
>
|
||||
{/* 附件预览区 */}
|
||||
{hasAttachments && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}>
|
||||
{message.attachments!.map((att) => (
|
||||
<AttachmentPreview key={att.id} attachment={att} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* 文本内容 */}
|
||||
{editing ? (
|
||||
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={handleEditSave} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
|
||||
) : message.content ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}>{message.content}</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
|
||||
</Box>
|
||||
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 附件预览组件 =====
|
||||
|
||||
function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
|
||||
const { type, name, size, preview } = attachment;
|
||||
|
||||
if (type === 'image' && preview) {
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: 120, height: 120, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Box component="img" src={preview} alt={name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', px: 1, py: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#fff', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 文本文件
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
<FileText size={20} style={{ color: '#22d3ee', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他文件
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
<ImageIcon size={20} style={{ color: '#8b8fa7', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user