feat: pixel-level DeepSeek UI clone
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 6s

This commit is contained in:
WangDL 2026-05-22 19:09:17 +08:00
parent a4699c1a78
commit 427691290b

View File

@ -1,8 +1,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Input, Button, theme, Typography, App } from 'antd'
import {
SendOutlined, RobotOutlined, PlusOutlined, ToolOutlined,
SendOutlined, PlusOutlined, ToolOutlined,
DeleteOutlined, StopOutlined, MessageOutlined,
ArrowUpOutlined,
} from '@ant-design/icons'
import { streamChat, resolveApproval, type StreamEvent } from '@/services/ai-chat'
import {
@ -30,7 +31,6 @@ function ChatPage() {
const [waitingApproval, setWaitingApproval] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState('')
const [deleting, setDeleting] = useState(false)
const composingRef = useRef(false)
const abortRef = useRef<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
@ -54,11 +54,9 @@ function ChatPage() {
}
const handleDelete = (id: string) => modal.confirm({
title: '删除对话', okText: '删除', okType: 'danger', cancelText: '取消',
title: 'Delete conversation?', okText: 'Delete', okType: 'danger', cancelText: 'Cancel',
onOk: async () => {
setDeleting(true)
try { await deleteConversation(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch {}
setDeleting(false)
},
})
@ -73,7 +71,7 @@ function ChatPage() {
setMessages(prev => prev.map(m => m.id === approvalMsg.id ? { ...m, approval: { ...m.approval!, resolved: true } } : m))
setWaitingApproval(false)
await resolveApproval(approvalMsg.approval!.runId, choice)
message.success(choice === 'deny' ? '已拒绝' : '已批准')
message.success(choice === 'deny' ? 'Denied' : 'Approved')
}
const handleSend = async () => {
@ -136,134 +134,161 @@ function ChatPage() {
const handleStop = () => { abortRef.current?.abort(); setStreaming(false) }
return (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)' }}>
{/* Sidebar */}
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: token.colorBgContainer, borderRight: '1px solid ' + token.colorBorderSecondary }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid ' + token.colorBorderSecondary }}>
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}></Button>
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', background: '#fff' }}>
{/* Sidebar — DeepSeek style: no border, subtle bg */}
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: '#f9fafb' }}>
<div style={{ padding: '16px' }}>
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}
style={{ borderRadius: 8, height: 40, fontWeight: 500 }}>New chat</Button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 8px 8px' }}>
{conversations.map(conv => (
<div key={conv.id} onClick={() => activeId !== conv.id && switchConversation(conv.id)}
style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 2,
background: activeId === conv.id ? token.colorFillSecondary : 'transparent' }}>
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 13, marginRight: 8, flexShrink: 0 }} />
<div key={conv.id}
onClick={() => activeId !== conv.id && switchConversation(conv.id)}
style={{
display: 'flex', alignItems: 'center', padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 2,
background: activeId === conv.id ? '#e8e8ed' : 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={e => { if (activeId !== conv.id) e.currentTarget.style.background = '#ececf0' }}
onMouseLeave={e => { if (activeId !== conv.id) e.currentTarget.style.background = 'transparent' }}>
<MessageOutlined style={{ color: '#999', fontSize: 13, marginRight: 10, flexShrink: 0 }} />
{editingId === conv.id ? (
<Input ref={editInputRef} size="small" value={editTitle} onChange={e => setEditTitle(e.target.value)}
onBlur={() => saveTitle(conv.id)} onPressEnter={() => saveTitle(conv.id)}
onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} bordered={false} />
) : (
<Text ellipsis style={{ flex: 1, fontSize: 13 }} onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
<Text ellipsis style={{ flex: 1, fontSize: 13, color: '#333' }}
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
)}
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled={deleting}
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }} style={{ opacity: 0.4 }} />
<DeleteOutlined onClick={e => { e.stopPropagation(); handleDelete(conv.id) }}
style={{ fontSize: 12, color: '#bbb', cursor: 'pointer', opacity: 0, transition: 'opacity 0.15s' }}
className="delete-icon" />
</div>
))}
</div>
<style>{'.delete-icon:hover { opacity: 1 !important; } div:hover > .delete-icon { opacity: 0.6 !important; }'}</style>
</div>
{/* Chat area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '32px 0' }}>
{/* Chat */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, background: '#fff' }}>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{messages.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
<div style={{ width: 64, height: 64, borderRadius: 16, background: token.colorFillSecondary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<RobotOutlined style={{ fontSize: 28, color: token.colorPrimary }} />
</div>
<Text style={{ fontSize: 16, fontWeight: 500 }}></Text>
<Text type="secondary" style={{ fontSize: 13 }}>Hermes Agent · </Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 8 }}>
<Text style={{ fontSize: 24, fontWeight: 600, color: '#1a1a1a' }}>What can I help with?</Text>
</div>
) : (
<div style={{ maxWidth: 768, margin: '0 auto', width: '100%', padding: '0 24px' }}>
<div style={{ maxWidth: 800, margin: '0 auto', width: '100%', padding: '0 24px' }}>
{messages.map(msg => (
<div key={msg.id} style={{ marginBottom: 32 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 700,
background: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, color: '#fff' }}>
{msg.role === 'user' ? 'Y' : 'H'}
</div>
<Text strong style={{ fontSize: 13, color: token.colorText }}>{msg.role === 'user' ? 'You' : 'Hermes'}</Text>
{msg.streaming && !msg.content && <Text type="secondary" style={{ fontSize: 12 }}>Thinking...</Text>}
<div key={msg.id} style={{ padding: '24px 0', borderBottom: '1px solid #f0f0f0' }}>
{/* Role label — DeepSeek style: small bold text, no avatar */}
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 13, color: '#1a1a1a', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{msg.role === 'user' ? 'You' : 'Hermes'}
</Text>
{msg.streaming && !msg.content && !msg.toolCalls?.length && !msg.approval && (
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: '#999', marginLeft: 8, animation: 'pulse 1.5s infinite' }} />
)}
</div>
{/* Approval card */}
{/* Approval */}
{msg.approval && !msg.approval.resolved && (
<div style={{ marginLeft: 40, marginBottom: 12, padding: 16, borderRadius: 12, background: token.colorWarningBg, border: '1px solid ' + token.colorWarningBorder }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: token.colorWarning, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<ToolOutlined style={{ color: '#fff', fontSize: 14 }} />
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14 }}></Text>
<div style={{ marginTop: 6, padding: '8px 12px', borderRadius: 8, background: token.colorBgContainer, fontFamily: 'monospace', fontSize: 13 }}>
{msg.approval.command}
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 6, display: 'block' }}>{msg.approval.description}</Text>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
{msg.approval.choices.map(c => (
<Button key={c} size="small" type={c === 'deny' ? 'default' : 'primary'}
danger={c === 'deny'}
onClick={() => handleApprove(msg, c)}>
{c === 'deny' ? '拒绝' : c === 'once' ? '允许本次' : c === 'session' ? '本次对话始终允许' : c === 'always' ? '始终允许' : c}
</Button>
))}
</div>
</div>
<div style={{ marginBottom: 16, padding: 16, borderRadius: 12, background: '#fff7e6', border: '1px solid #ffd591' }}>
<Text strong style={{ fontSize: 14 }}>Approval Required</Text>
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 6, background: '#fff', fontFamily: 'SF Mono, Monaco, Menlo, monospace', fontSize: 13, border: '1px solid #eee' }}>
$ {msg.approval.command}
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 6, display: 'block' }}>{msg.approval.description}</Text>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
{msg.approval.choices.map(c => (
<Button key={c} size="small" type={c === 'deny' ? 'default' : 'primary'}
danger={c === 'deny'}
style={{ borderRadius: 6 }}
onClick={() => handleApprove(msg, c)}>
{c === 'deny' ? 'Deny' : c === 'once' ? 'Approve' : c === 'session' ? 'Approve Session' : c === 'always' ? 'Approve Always' : c}
</Button>
))}
</div>
</div>
)}
{/* Tool calls */}
{/* Tool calls — subtle pills */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div style={{ marginLeft: 40, marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<div style={{ marginBottom: 12, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{msg.toolCalls.map((t, i) => (
<div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 10px', borderRadius: 6, fontSize: 12,
background: t.done ? (t.error ? token.colorErrorBg : token.colorSuccessBg) : token.colorFillSecondary,
border: '1px solid ' + (t.done ? (t.error ? token.colorErrorBorder : token.colorSuccessBorder) : token.colorBorderSecondary) }}>
<ToolOutlined style={{ fontSize: 11, color: t.done ? (t.error ? token.colorError : token.colorSuccess) : token.colorTextSecondary }} />
<Text style={{ fontSize: 12 }}>{t.preview || t.tool}</Text>
{t.done && <Text style={{ fontSize: 11, color: t.error ? token.colorError : token.colorSuccess }}>{t.error ? 'failed' : t.duration?.toFixed(1) + 's'}</Text>}
<div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 6, fontSize: 12,
background: t.done ? '#f6ffed' : '#f5f5f5', border: '1px solid ' + (t.done ? '#b7eb8f' : '#e8e8e8'), color: '#666' }}>
<ToolOutlined style={{ fontSize: 11 }} />
{t.preview || t.tool}
{t.done && <span style={{ color: t.error ? '#ff4d4f' : '#52c41a', marginLeft: 4 }}>{t.error ? 'failed' : 'done'}</span>}
</div>
))}
</div>
)}
{/* Content */}
<div style={{ marginLeft: 40, maxWidth: '100%' }}>
<div style={{ fontSize: 14, lineHeight: 1.85, color: token.colorText }}>
{msg.role === 'assistant'
? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : (!msg.toolCalls?.length && !msg.approval ? <Text type="secondary">Thinking...</Text> : null))
: <div style={{ padding: '10px 16px', borderRadius: 12, background: token.colorPrimary, color: '#fff', display: 'inline-block', maxWidth: '85%' }}>{msg.content}</div>}
</div>
{/* Content — DeepSeek style: no bubble, just clean text */}
<div style={{ fontSize: 15, lineHeight: 1.75, color: '#1a1a1a' }}>
{msg.role === 'assistant'
? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : null)
: <span>{msg.content}</span>}
</div>
</div>
))}
<div ref={messagesEndRef} />
<div ref={messagesEndRef} style={{ height: 32 }} />
</div>
)}
</div>
{/* Input */}
<div style={{ padding: '16px 0 24px', background: token.colorBgLayout }}>
<div style={{ maxWidth: 768, margin: '0 auto', padding: '0 24px' }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, background: token.colorBgContainer, borderRadius: 16, padding: '10px 10px 10px 20px', border: '1px solid ' + token.colorBorderSecondary, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Input.TextArea value={input} onChange={e => setInput(e.target.value)}
onCompositionStart={() => { composingRef.current = true }} onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={e => { if (composingRef.current) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
placeholder={activeId ? 'Message Hermes...' : 'Select or create a conversation'}
autoSize={{ minRows: 1, maxRows: 6 }} disabled={streaming || !activeId || waitingApproval}
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
{/* Input — DeepSeek style: minimal, centered */}
<div style={{ padding: '0 24px 24px' }}>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 8,
padding: '12px 12px 12px 20px', borderRadius: 16,
border: '1px solid #e5e5e5', background: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
transition: 'border-color 0.2s, box-shadow 0.2s',
}}>
<Input.TextArea
value={input} onChange={e => setInput(e.target.value)}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={e => {
if (composingRef.current) return
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}}
placeholder={activeId ? 'Ask anything' : 'Select a conversation'}
autoSize={{ minRows: 1, maxRows: 6 }}
disabled={streaming || !activeId || waitingApproval}
variant="borderless"
style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 15, lineHeight: 1.5 }}
/>
{streaming ? (
<Button danger icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 10, height: 36, width: 36 }} />
<div onClick={handleStop} style={{ width: 36, height: 36, borderRadius: 10, background: '#ff4d4f', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<div style={{ width: 12, height: 12, background: '#fff', borderRadius: 2 }} />
</div>
) : (
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
disabled={!input.trim() || !activeId || waitingApproval} style={{ borderRadius: 10, height: 36, width: 36 }} />
<div onClick={handleSend}
style={{
width: 36, height: 36, borderRadius: 10,
background: !input.trim() || !activeId || waitingApproval ? '#e5e5e5' : '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: !input.trim() || !activeId || waitingApproval ? 'default' : 'pointer',
transition: 'background 0.2s',
}}>
<ArrowUpOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
)}
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', textAlign: 'center', marginTop: 8 }}>
Hermes Agent · DeepSeek · xhigh reasoning
</Text>
</div>
</div>
</div>
<style>{'@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }'}</style>
</div>
)
}