feat: change h5 chat page ui

feat/unify-api-and-responsive-pages
yannyang 2026-06-23 21:51:05 +08:00
parent fdaaa371c9
commit 25221f7b5e
11 changed files with 743 additions and 184 deletions

View File

@ -81,7 +81,7 @@ export default function App() {
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? ( {!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
<Sidebar onOpenPalette={() => setPaletteOpen(true)} /> <Sidebar onOpenPalette={() => setPaletteOpen(true)} />
) : null} ) : null}
<main className="main"> <main className={`main${isMobile ? ' is-h5' : ''}`}>
{isMobile && ( {isMobile && (
<div className="mobile-topbar"> <div className="mobile-topbar">
<Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} /> <Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} />

View File

@ -21,8 +21,9 @@ export default function ChatBody(props: {
onRegenerate: (assistantId: string) => void; onRegenerate: (assistantId: string) => void;
onSwitchBranch: (userMsgId: string, branchId: string) => void; onSwitchBranch: (userMsgId: string, branchId: string) => void;
onCopy: (text: string, mode: CopyMode) => void; onCopy: (text: string, mode: CopyMode) => void;
isMobile?: boolean;
}) { }) {
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy } = props; const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
return ( return (
<div ref={bodyRef} className="chat-body"> <div ref={bodyRef} className="chat-body">
@ -55,6 +56,7 @@ export default function ChatBody(props: {
<> <>
{messages.map((m) => ( {messages.map((m) => (
<MessageItem <MessageItem
isMobile={isMobile}
key={m.id} key={m.id}
message={m} message={m}
agentList={agentList} agentList={agentList}

View File

@ -1,6 +1,7 @@
import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons'; import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined, EllipsisOutlined } from '@ant-design/icons';
import { Button, Dropdown, Modal, Space, Switch } from 'antd'; import { Button, Dropdown, Modal, Space, Switch } from 'antd';
import type { Agent } from '../../../api'; import type { Agent } from '../../../api';
import { useIsMobile } from '../../../hooks/useIsMobile';
function formatAgentModel(raw: string | null | undefined) { function formatAgentModel(raw: string | null | undefined) {
const s = String(raw ?? '').trim(); const s = String(raw ?? '').trim();
@ -34,9 +35,10 @@ export default function ChatHeader(props: {
}) { }) {
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props; const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
const modelText = formatAgentModel(agent.model); const modelText = formatAgentModel(agent.model);
const isMobile = useIsMobile();
return ( return (
<div className="chat-header"> <div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}>
<div className="chat-header-agent"> <div className="chat-header-agent">
<div className="chat-header-agent-title"> <div className="chat-header-agent-title">
<span className="chat-header-agent-name">{agent.name}</span> <span className="chat-header-agent-name">{agent.name}</span>
@ -51,9 +53,11 @@ export default function ChatHeader(props: {
<span className="chat-header-stream-label"></span> <span className="chat-header-stream-label"></span>
<Switch size="small" checked={useStream} onChange={setUseStream} /> <Switch size="small" checked={useStream} onChange={setUseStream} />
</div> </div>
<Button size="small" onClick={onOpenHistory}> {!isMobile && (
<Button size="small" onClick={onOpenHistory}>
</Button>
</Button>
)}
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
@ -73,8 +77,8 @@ export default function ChatHeader(props: {
] ]
}} }}
> >
<Button size="small"> <Button size="small" type={isMobile ? 'text' : 'primary'}>
<DownOutlined /> {isMobile ? <EllipsisOutlined /> : <> <DownOutlined /></>}
</Button> </Button>
</Dropdown> </Dropdown>
</Space> </Space>

View File

@ -0,0 +1,181 @@
.chat-input-h5-container {
padding: 8px 12px calc(12px + env(safe-area-inset-bottom));
background: var(--color-bg);
width: 100%;
z-index: 100;
position: sticky;
bottom: 0;
}
.chat-input-h5-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.chat-input-h5-attachment-tag {
border-radius: 6px;
padding: 4px 8px;
}
.chat-input-h5-image-item {
position: relative;
}
.chat-input-h5-image {
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--color-border);
}
.chat-input-h5-image-close {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-input-h5-image-close .anticon {
font-size: 8px;
}
.chat-input-h5-toolbar {
margin-bottom: 8px;
overflow: hidden;
position: relative;
}
.chat-input-h5-toolbar-scroll {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding: 0 4px 4px;
scrollbar-width: none; /* Firefox */
}
.chat-input-h5-toolbar-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.chat-input-h5-model-select {
flex: 0 0 auto;
min-width: 100px;
max-width: 150px;
}
.chat-input-h5-toolbar-divider {
width: 1px;
height: 14px;
background: var(--color-border);
flex: 0 0 auto;
margin: 0 4px;
}
.chat-input-h5-action-icon {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px !important;
color: var(--color-text-secondary) !important;
font-size: 13px !important;
}
.chat-input-h5-action-icon .anticon {
font-size: 16px;
}
.chat-input-h5-action-icon:disabled {
opacity: 0.5;
}
.chat-input-h5-main {
display: flex;
align-items: center;
gap: 8px;
background: var(--color-surface);
border-radius: 24px;
padding: 6px 12px 6px 18px;
border: 1px solid var(--color-border);
box-shadow: 0px 4px 20px 0px #7DB8FF4A;
}
.chat-input-h5-upload-btn {
padding: 4px;
height: auto;
}
.chat-input-h5-upload-btn .anticon {
font-size: 20px;
color: var(--color-text-secondary);
}
.chat-input-h5-textarea {
padding: 4px 0 !important;
font-size: 16px !important;
}
.chat-main .chat-input-h5-textarea:focus {
outline: none !important;
box-shadow: none !important;
}
.chat-input-h5-actions {
display: flex;
align-items: center;
gap: 4px;
}
.chat-input-h5-tool-btn {
padding: 4px;
height: auto;
}
.chat-input-h5-tool-btn .anticon {
font-size: 20px;
color: var(--color-text-secondary);
}
.chat-input-h5-main .ant-btn-variant-solid:disabled {
border: 0;
}
/* 提及选择器样式 */
.chat-input-h5-mention-popover {
position: fixed;
z-index: 10000;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
max-height: 200px;
overflow-y: auto;
min-width: 150px;
}
.chat-input-h5-mention-empty {
padding: 8px;
color: var(--color-text-tertiary);
}
.chat-input-h5-mention-item {
padding: 6px 10px;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
}
.chat-input-h5-mention-item:last-child {
border-bottom: none;
}
.chat-input-h5-mention-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
}

View File

@ -0,0 +1,303 @@
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined, CameraOutlined, PlusCircleOutlined, AudioOutlined, UserOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import type { ChatAttachment } from '../../../api';
import type { Agent } from '../../../api/agents';
import { useState, useRef } from 'react';
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
import './ChatInputH5.css';
export default function ChatInputH5(props: {
input: string;
setInput: (v: string) => void;
sending: boolean;
attachments: ChatAttachment[];
setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => void;
imageUrls: string[];
setImageUrls: (updater: (prev: string[]) => string[]) => void;
onSend: () => void;
onStop: () => void;
onAttach: (files: File[]) => void;
onOpenTpl: () => void;
modelOptions: Array<{ value: string; label: string }>;
activeModelValue: string;
onChangeModel: (modelId: string) => void;
agentList: Agent[];
onInsertMention?: (agentName: string) => void;
onOpenHistory?: () => void;
onNewSession?: () => void;
onOpenAgents?: () => void;
onOpenOutline?: () => void;
hasAgent?: boolean;
hasId?: boolean;
showActions?: boolean;
}) {
const {
input,
setInput,
sending,
attachments,
setAttachments,
imageUrls,
setImageUrls,
onSend,
onStop,
onAttach,
onOpenTpl,
modelOptions,
activeModelValue,
onChangeModel,
agentList,
onInsertMention,
onOpenHistory,
onNewSession,
onOpenAgents,
onOpenOutline,
hasAgent = false,
hasId = false,
showActions = false
} = props;
const [showMentionPopover, setShowMentionPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionPos, setMentionPos] = useState<{ top: number; left: number } | null>(null);
const inputRef = useRef<TextAreaRef>(null);
const getCaretPos = (textarea: HTMLTextAreaElement, caretIndex: number) => {
const rect = textarea.getBoundingClientRect();
const cs = window.getComputedStyle(textarea);
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.top = '0';
div.style.left = '-9999px';
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
div.style.overflow = 'hidden';
div.style.boxSizing = cs.boxSizing;
div.style.width = rect.width + 'px';
div.style.fontFamily = cs.fontFamily;
div.style.fontSize = cs.fontSize;
div.style.fontWeight = cs.fontWeight;
div.style.fontStyle = cs.fontStyle;
div.style.letterSpacing = cs.letterSpacing;
div.style.textTransform = cs.textTransform;
div.style.lineHeight = cs.lineHeight;
div.style.padding = cs.padding;
div.style.border = cs.border;
div.style.tabSize = (cs as any).tabSize || '8';
const before = textarea.value.slice(0, caretIndex).replace(/ /g, '\u00a0');
div.textContent = before;
const span = document.createElement('span');
span.textContent = '\u200b';
div.appendChild(span);
document.body.appendChild(div);
const divRect = div.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
document.body.removeChild(div);
const lineHeight = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.2;
return {
top: rect.top + (spanRect.top - divRect.top) - textarea.scrollTop,
left: rect.left + (spanRect.left - divRect.left) - textarea.scrollLeft,
lineHeight
};
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setInput(value);
const cursorPos = e.target.selectionStart ?? value.length;
const textBeforeCursor = value.slice(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
const query = textBeforeCursor.slice(atIndex + 1);
if (!query.includes(' ')) {
setMentionQuery(query);
requestAnimationFrame(() => {
const textarea = inputRef.current?.resizableTextArea?.textArea;
if (!textarea) return;
const caret = getCaretPos(textarea, cursorPos);
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
setMentionPos({ top, left });
setShowMentionPopover(true);
});
return;
}
}
setShowMentionPopover(false);
};
const filteredAgents = agentList.filter(a =>
a.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
const handleSelectAgent = (agent: Agent) => {
const textarea = inputRef.current?.resizableTextArea?.textArea;
if (!textarea) return;
const value = input;
const cursorPos = textarea.selectionStart ?? value.length;
const textBefore = value.slice(0, cursorPos);
const atIndex = textBefore.lastIndexOf('@');
if (atIndex === -1) {
setShowMentionPopover(false);
return;
}
const newValue = value.slice(0, atIndex) + `@${agent.name} ` + value.slice(cursorPos);
setInput(newValue);
setShowMentionPopover(false);
requestAnimationFrame(() => {
textarea.focus();
const newCursor = atIndex + agent.name.length + 2;
textarea.setSelectionRange(newCursor, newCursor);
});
};
return (
<div className="chat-input-h5-container">
{/* 顶部附件展示 */}
<div className="chat-input-h5-attachments">
{attachments.map((a, i) => (
<Tag key={i} color="blue" closable className="chat-input-h5-attachment-tag" onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
📎 {a.name}
</Tag>
))}
{imageUrls.map((u, i) => (
<div key={i} className="chat-input-h5-image-item">
<AntImage src={u} width={48} height={48} className="chat-input-h5-image" />
<Button
size="small"
type="primary"
shape="circle"
icon={<CloseOutlined />}
className="chat-input-h5-image-close"
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
/>
</div>
))}
</div>
{/* 模型切换与工具栏 */}
<div className="chat-input-h5-toolbar">
<div className="chat-input-h5-toolbar-scroll">
<Select
value={activeModelValue || undefined}
size="small"
className="chat-input-h5-model-select"
variant="borderless"
options={modelOptions}
suffixIcon={<DownOutlined style={{ fontSize: 10 }} />}
placeholder="选择模型"
onChange={onChangeModel}
dropdownStyle={{ minWidth: 160 }}
/>
<div className="chat-input-h5-toolbar-divider" />
<Button size="small" className="chat-input-h5-action-icon" icon={<UserOutlined />} onClick={onOpenAgents}>
</Button>
<Button size="small" className="chat-input-h5-action-icon" disabled={!hasAgent} icon={<UnorderedListOutlined />} onClick={onOpenOutline}>
</Button>
<Button size="small" className="chat-input-h5-action-icon" disabled={!hasId} icon={<HistoryIcon />} onClick={onOpenHistory}>
</Button>
<Button size="small" className="chat-input-h5-action-icon" disabled={!hasId} icon={<NewChatIcon />} onClick={onNewSession}>
</Button>
<Button size="small" className="chat-input-h5-action-icon" icon={<BookOutlined />} onClick={onOpenTpl}>
</Button>
</div>
</div>
{/* 主输入框区域 */}
<div className="chat-input-h5-main" style={{ background: 'var(--color-surface)' }}>
<Input.TextArea
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder="发消息或按住说话..."
autoSize={{ minRows: 1, maxRows: 5 }}
variant="borderless"
className="chat-input-h5-textarea"
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if ((e as any).isComposing) return;
if (!e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
onSend();
}
}}
disabled={sending}
/>
<div className="chat-input-h5-actions">
{sending ? (
<Button danger shape="circle" size="small" onClick={onStop} icon={<span className="chat-stop-icon" />} />
) : (
<>
<Upload
multiple
beforeUpload={(_f, files) => {
onAttach(files as File[]);
return false;
}}
showUploadList={false}
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
>
<Button type="text" icon={<PlusCircleOutlined />} className="chat-input-h5-upload-btn" />
</Upload>
<Button
type="primary"
shape="circle"
size="small"
onClick={onSend}
icon={<ArrowUpOutlined />}
disabled={!input.trim()}
/>
</>
)}
</div>
</div>
{/* 提及选择器 */}
{showMentionPopover && mentionPos && (
<div
className="chat-input-h5-mention-popover"
style={{
top: mentionPos.top,
left: mentionPos.left,
}}
>
{filteredAgents.length === 0 ? (
<div className="chat-input-h5-mention-empty">
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="chat-input-h5-mention-item"
onClick={() => handleSelectAgent(agent)}
>
<div className="chat-input-h5-mention-name">
{agent.name}
</div>
</div>
))
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,21 @@
@media (max-width: 768px) {
.h5-chat-shell {
/* 移除固定高度,允许跟随全局滚动容器 */
display: flex;
flex-direction: column;
}
.h5-chat-main {
display: flex;
flex-direction: column;
}
.h5-chat-content-row {
/* 移除内部滚动,改为由 App 的 main-content 统一处理 */
min-height: 0;
}
.h5-chat-content-row .chat-body .messages-container {
padding-bottom: 2rem;
}
}

View File

@ -7,8 +7,9 @@ import AgentSidebar from './AgentSidebar';
import ChatBody from './ChatBody'; import ChatBody from './ChatBody';
import ChatDrawers from './ChatDrawers'; import ChatDrawers from './ChatDrawers';
import ChatHeader from './ChatHeader'; import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput'; import ChatInputH5 from './ChatInputH5';
import ChatOutline from './ChatOutline'; import ChatOutline from './ChatOutline';
import './ChatPageH5.css';
export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) { export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
@ -86,23 +87,6 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
</Drawer> </Drawer>
<section className="chat-main h5-chat-main"> <section className="chat-main h5-chat-main">
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--color-border)' }}>
<Space>
<Button size="small" onClick={() => setAgentDrawerOpen(true)}>
</Button>
<Button size="small" disabled={!agent} onClick={() => setOutlineDrawerOpen(true)}>
</Button>
<Button size="small" disabled={!id} onClick={() => setHistoryDrawerOpen(true)}>
</Button>
<Button size="small" type="primary" disabled={!id} onClick={handleNewSession}>
</Button>
</Space>
</div>
{!agent ? ( {!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请选择一个智能体开始对话" /> <Empty description="请选择一个智能体开始对话" />
@ -122,6 +106,7 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
<div className="chat-content-row h5-chat-content-row"> <div className="chat-content-row h5-chat-content-row">
<ChatBody <ChatBody
isMobile
bodyRef={bodyRef} bodyRef={bodyRef}
agent={agent} agent={agent}
agentList={agentList} agentList={agentList}
@ -141,37 +126,40 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
}} }}
/> />
</div> </div>
<ChatInput
input={sender.input}
setInput={sender.setInput}
sending={sender.sending}
attachments={sender.attachments}
setAttachments={sender.setAttachments}
imageUrls={sender.imageUrls}
setImageUrls={sender.setImageUrls}
onSend={sender.handleSend}
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={(modelId) => {
const picked = sender.modelOptions.find((o) => o.value === modelId);
setOverrides((o: ModelOverrides) => ({
...o,
model_id: modelId,
model: picked?.label ?? o.model
}));
}}
agentList={agentList}
onInsertMention={() => {}}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={handleNewSession}
showActions={false}
/>
</> </>
)} )}
<ChatInputH5
input={sender.input}
setInput={sender.setInput}
sending={sender.sending}
attachments={sender.attachments}
setAttachments={sender.setAttachments}
imageUrls={sender.imageUrls}
setImageUrls={sender.setImageUrls}
onSend={sender.handleSend}
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={(modelId: string) => {
const picked = sender.modelOptions.find((o) => o.value === modelId);
setOverrides((o: ModelOverrides) => ({
...o,
model_id: modelId,
model: picked?.label ?? o.model
}));
}}
agentList={agentList}
onInsertMention={() => { }}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={handleNewSession}
onOpenAgents={() => setAgentDrawerOpen(true)}
onOpenOutline={() => setOutlineDrawerOpen(true)}
hasAgent={!!agent}
hasId={!!id}
showActions={false}
/>
</section> </section>
<ChatDrawers <ChatDrawers

View File

@ -0,0 +1,78 @@
.message-item-container {
margin-bottom: 20px;
transition: background 0.4s, padding 0.4s;
}
.message-item-container.highlighted {
padding: 8px;
border-radius: 10px;
background: rgba(254, 243, 199, 0.6);
}
/* Assistant Message Styles */
.message-item-assistant {
display: flex;
gap: 12px;
align-items: flex-start;
}
.message-item-assistant-avatar {
flex-shrink: 0;
margin-top: 2px;
background-color: #52c41a;
}
.message-item-assistant-content {
flex: 1;
min-width: 0;
}
.message-item-assistant-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.message-item-assistant-name {
font-size: 12px;
font-weight: 400;
color: var(--color-text-secondary);
}
/* User Message Styles */
.message-item-user {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: flex-end;
}
.message-item-user-content-wrapper {
flex: 1;
min-width: 0;
max-width: 78%;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-item-user-avatar {
flex-shrink: 0;
margin-top: 2px;
background-color: #1890ff;
}
.mention {
color: var(--color-brand);
font-weight: 500;
}
.message-item-actions {
padding-top: 4px;
border-top: 1px solid var(--color-border);
}
.actions-btn {
color: var(--color-text-secondary);
}

View File

@ -1,10 +1,11 @@
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd'; import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
import type { BranchInfo, ChatMessage } from '../../../../api'; import type { BranchInfo, ChatMessage } from '../../../../api';
import type { Agent } from '../../../../api/agents'; import type { Agent } from '../../../../api/agents';
import Markdown from '../../../../components/Markdown'; import Markdown from '../../../../components/Markdown';
import { ProductCopyIcon } from '../../../../components/icons';
import type { CopyMode } from '../../utils/copy'; import type { CopyMode } from '../../utils/copy';
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews'; import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
import './MessageItem.css';
export default function MessageItem(props: { export default function MessageItem(props: {
message: ChatMessage; message: ChatMessage;
@ -16,8 +17,9 @@ export default function MessageItem(props: {
onRegenerate?: (id: string) => void; onRegenerate?: (id: string) => void;
onSwitchBranch?: (userMsgId: string, branchId: string) => void; onSwitchBranch?: (userMsgId: string, branchId: string) => void;
onCopy?: (text: string, mode: CopyMode) => void; onCopy?: (text: string, mode: CopyMode) => void;
isMobile?: boolean;
}) { }) {
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy } = props; const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined); const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
const speakerId = (message as any)?.speaker?.id as string | undefined; const speakerId = (message as any)?.speaker?.id as string | undefined;
@ -46,78 +48,59 @@ export default function MessageItem(props: {
return ( return (
<div <div
id={'msg-' + message.id} id={'msg-' + message.id}
style={{ className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
marginBottom: 20,
padding: highlighted ? 8 : 0,
borderRadius: highlighted ? 10 : 0,
background: highlighted ? 'rgba(254, 243, 199, 0.6)' : 'transparent',
transition: 'background 0.4s, padding 0.4s'
}}
> >
{!isUser ? ( {!isUser ? (
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}> <div className="message-item-assistant">
<Avatar <Avatar
src={answerAgent?.avatar} src={answerAgent?.avatar}
size={36} size={36}
style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }} className="message-item-assistant-avatar"
> >
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'} {answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
</Avatar> </Avatar>
<div style={{ flex: 1, minWidth: 0 }}> <div className="message-item-assistant-content">
<div style={{ <div className="message-item-assistant-header">
display: 'flex', <span className="message-item-assistant-name">
alignItems: 'center',
gap: 8,
marginBottom: 6
}}>
<span style={{
fontSize: 12,
fontWeight: 400,
color: 'var(--color-text-secondary)'
}}>
{answerAgent?.name || 'AI'} {answerAgent?.name || 'AI'}
</span> </span>
</div> </div>
<div className={`bubble ${bubbleRole}`}> <div className={`bubble ${bubbleRole}`}>
<Markdown>{message.content}</Markdown> <Markdown>{message.content}</Markdown>
</div> <div className="message-item-actions">
<div className="monica-msg-actions"> {hasBranches && (
{hasBranches && ( <Space size={2}>
<Space size={2}> <Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
</Button>
</Button> <span>
<span> {activeIdx + 1} / {total}
{activeIdx + 1} / {total} </span>
</span> <Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
</Button>
</Button> </Space>
</Space> )}
)} {message.meta?.aborted && <Tag color="orange"></Tag>}
{message.meta?.aborted && <Tag color="orange"></Tag>} <Dropdown
<Dropdown trigger={['click']}
trigger={['click']} menu={{
menu={{ items: [
items: [ { key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') }, { key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') } ]
] }}
}} >
> <Tooltip title="复制">
<Tooltip title="复制"> <Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
<span className="chat-copy-icon" aria-label="copy"> </Tooltip>
<ProductCopyIcon /> </Dropdown>
</span> <Tooltip title="重新生成(开新分支)">
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
</Tooltip> </Tooltip>
</Dropdown> </div>
<Tooltip title="重新生成(开新分支)">
<Button size="small" type="text" disabled={busy} onClick={() => onRegenerate?.(message.id)}>
🔄
</Button>
</Tooltip>
</div> </div>
{message.meta && ( {!isMobile && message.meta && (
<div> <div>
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />} {!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />} {!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
@ -127,8 +110,8 @@ export default function MessageItem(props: {
</div> </div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', justifyContent: 'flex-end' }}> <div className="message-item-user">
<div style={{ flex: 1, minWidth: 0, maxWidth: '78%', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}> <div className="message-item-user-content-wrapper">
<div className={`bubble ${bubbleRole}`}> <div className={`bubble ${bubbleRole}`}>
{message.content.includes('![image](') ? ( {message.content.includes('![image](') ? (
<Markdown>{message.content}</Markdown> <Markdown>{message.content}</Markdown>
@ -139,7 +122,7 @@ export default function MessageItem(props: {
)} )}
</div> </div>
</div> </div>
<Avatar style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#1890ff' }} size={36}></Avatar> <Avatar className="message-item-user-avatar" size={36}></Avatar>
</div> </div>
)} )}
</div> </div>

View File

@ -273,8 +273,19 @@ body {
.main { .main {
flex: 1; flex: 1;
overflow: auto;
background: var(--color-bg); background: var(--color-bg);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
-webkit-overflow-scrolling: touch;
} }
.main-chat { .main-chat {
@ -536,6 +547,10 @@ body {
margin-right: 12px; margin-right: 12px;
} }
.is-h5 .chat-header-stream-toggle {
margin-right: 4px;;
}
.chat-header-stream-label { .chat-header-stream-label {
font-size: 12px; font-size: 12px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@ -619,6 +634,7 @@ body {
.main { .main {
width: 100vw; width: 100vw;
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@ -631,7 +647,6 @@ body {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0 0.5rem; padding: 0 0.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface); background: var(--color-surface);
} }
@ -647,8 +662,10 @@ body {
.main-content { .main-content {
flex: 1; flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; min-height: 0;
overflow: auto; -webkit-overflow-scrolling: touch;
} }
.chat-shell { .chat-shell {
@ -680,12 +697,6 @@ body {
padding: 1.125rem 0.75rem 6rem; padding: 1.125rem 0.75rem 6rem;
} }
.bubble {
max-width: 92%;
padding: 0.75rem 0.875rem;
font-size: 0.875rem;
}
.chat-input-wrapper { .chat-input-wrapper {
max-width: 100%; max-width: 100%;
padding: 0 0.75rem 1rem; padding: 0 0.75rem 1rem;
@ -743,15 +754,18 @@ body {
.bubble { .bubble {
max-width: 78%; max-width: 78%;
display: inline-block; display: inline-block;
padding: 14px 18px; padding: 8px;
border-radius: 14px; border-radius: 14px;
margin-bottom: 14px;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
line-height: 1.65; line-height: 1.5;
font-size: 14.5px; font-size: 14.5px;
} }
.is-h5 .bubble {
max-width: 94%;
}
.bubble.user { .bubble.user {
background: #0a84ff; background: #0a84ff;
color: #ffffff; color: #ffffff;
@ -760,7 +774,7 @@ body {
} }
.bubble.assistant { .bubble.assistant {
background: #e9e9eb; background: #edf1f6;
color: #111827; color: #111827;
border: 0; border: 0;
border-bottom-left-radius: 5px; border-bottom-left-radius: 5px;
@ -815,20 +829,6 @@ body {
margin: 0.2em 0; margin: 0.2em 0;
} }
.chat-copy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
cursor: pointer;
color: var(--color-text-secondary);
}
.chat-copy-icon:hover {
color: var(--color-text);
}
.bubble.assistant table { .bubble.assistant table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;

View File

@ -5,7 +5,6 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0 0.625rem; padding: 0 0.625rem;
border-bottom: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(1.125rem); backdrop-filter: blur(1.125rem);
} }