feat: change h5 chat page ui
parent
fdaaa371c9
commit
25221f7b5e
|
|
@ -81,7 +81,7 @@ export default function App() {
|
|||
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
|
||||
<Sidebar onOpenPalette={() => setPaletteOpen(true)} />
|
||||
) : null}
|
||||
<main className="main">
|
||||
<main className={`main${isMobile ? ' is-h5' : ''}`}>
|
||||
{isMobile && (
|
||||
<div className="mobile-topbar">
|
||||
<Button type="text" icon={<MenuOutlined />} onClick={() => setMobileNavOpen(true)} />
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ export default function ChatBody(props: {
|
|||
onRegenerate: (assistantId: string) => void;
|
||||
onSwitchBranch: (userMsgId: string, branchId: string) => 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 (
|
||||
<div ref={bodyRef} className="chat-body">
|
||||
|
|
@ -55,6 +56,7 @@ export default function ChatBody(props: {
|
|||
<>
|
||||
{messages.map((m) => (
|
||||
<MessageItem
|
||||
isMobile={isMobile}
|
||||
key={m.id}
|
||||
message={m}
|
||||
agentList={agentList}
|
||||
|
|
|
|||
|
|
@ -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 type { Agent } from '../../../api';
|
||||
import { useIsMobile } from '../../../hooks/useIsMobile';
|
||||
|
||||
function formatAgentModel(raw: string | null | undefined) {
|
||||
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 modelText = formatAgentModel(agent.model);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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-title">
|
||||
<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>
|
||||
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
||||
</div>
|
||||
<Button size="small" onClick={onOpenHistory}>
|
||||
历史对话
|
||||
</Button>
|
||||
{!isMobile && (
|
||||
<Button size="small" onClick={onOpenHistory}>
|
||||
历史对话
|
||||
</Button>
|
||||
)}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
|
|
@ -73,8 +77,8 @@ export default function ChatHeader(props: {
|
|||
]
|
||||
}}
|
||||
>
|
||||
<Button size="small">
|
||||
更多 <DownOutlined />
|
||||
<Button size="small" type={isMobile ? 'text' : 'primary'}>
|
||||
{isMobile ? <EllipsisOutlined /> : <>更多 <DownOutlined /></>}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import AgentSidebar from './AgentSidebar';
|
|||
import ChatBody from './ChatBody';
|
||||
import ChatDrawers from './ChatDrawers';
|
||||
import ChatHeader from './ChatHeader';
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatInputH5 from './ChatInputH5';
|
||||
import ChatOutline from './ChatOutline';
|
||||
import './ChatPageH5.css';
|
||||
|
||||
export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
|
||||
const { message } = AntApp.useApp();
|
||||
|
|
@ -86,23 +87,6 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
</Drawer>
|
||||
|
||||
<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 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Empty description="请选择一个智能体开始对话" />
|
||||
|
|
@ -122,6 +106,7 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
|
||||
<div className="chat-content-row h5-chat-content-row">
|
||||
<ChatBody
|
||||
isMobile
|
||||
bodyRef={bodyRef}
|
||||
agent={agent}
|
||||
agentList={agentList}
|
||||
|
|
@ -141,37 +126,40 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
|
|||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<ChatDrawers
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
|
||||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||||
import type { Agent } from '../../../../api/agents';
|
||||
import Markdown from '../../../../components/Markdown';
|
||||
import { ProductCopyIcon } from '../../../../components/icons';
|
||||
import type { CopyMode } from '../../utils/copy';
|
||||
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
||||
import './MessageItem.css';
|
||||
|
||||
export default function MessageItem(props: {
|
||||
message: ChatMessage;
|
||||
|
|
@ -16,8 +17,9 @@ export default function MessageItem(props: {
|
|||
onRegenerate?: (id: string) => void;
|
||||
onSwitchBranch?: (userMsgId: string, branchId: string) => 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 speakerId = (message as any)?.speaker?.id as string | undefined;
|
||||
|
|
@ -46,78 +48,59 @@ export default function MessageItem(props: {
|
|||
return (
|
||||
<div
|
||||
id={'msg-' + message.id}
|
||||
style={{
|
||||
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'
|
||||
}}
|
||||
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
||||
>
|
||||
{!isUser ? (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div className="message-item-assistant">
|
||||
<Avatar
|
||||
src={answerAgent?.avatar}
|
||||
size={36}
|
||||
style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}
|
||||
className="message-item-assistant-avatar"
|
||||
>
|
||||
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||
</Avatar>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 6
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}>
|
||||
<div className="message-item-assistant-content">
|
||||
<div className="message-item-assistant-header">
|
||||
<span className="message-item-assistant-name">
|
||||
{answerAgent?.name || 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`bubble ${bubbleRole}`}>
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
<div className="monica-msg-actions">
|
||||
{hasBranches && (
|
||||
<Space size={2}>
|
||||
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
|
||||
‹
|
||||
</Button>
|
||||
<span>
|
||||
{activeIdx + 1} / {total}
|
||||
</span>
|
||||
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
|
||||
›
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
|
||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<span className="chat-copy-icon" aria-label="copy">
|
||||
<ProductCopyIcon />
|
||||
</span>
|
||||
<div className="message-item-actions">
|
||||
{hasBranches && (
|
||||
<Space size={2}>
|
||||
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
|
||||
‹
|
||||
</Button>
|
||||
<span>
|
||||
{activeIdx + 1} / {total}
|
||||
</span>
|
||||
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
|
||||
›
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
|
||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Tooltip title="重新生成(开新分支)">
|
||||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Tooltip title="重新生成(开新分支)">
|
||||
<Button size="small" type="text" disabled={busy} onClick={() => onRegenerate?.(message.id)}>
|
||||
🔄
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{message.meta && (
|
||||
{!isMobile && message.meta && (
|
||||
<div>
|
||||
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
||||
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
||||
|
|
@ -127,8 +110,8 @@ export default function MessageItem(props: {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', justifyContent: 'flex-end' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, maxWidth: '78%', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<div className="message-item-user">
|
||||
<div className="message-item-user-content-wrapper">
|
||||
<div className={`bubble ${bubbleRole}`}>
|
||||
{message.content.includes(' ? (
|
||||
<Markdown>{message.content}</Markdown>
|
||||
|
|
@ -139,7 +122,7 @@ export default function MessageItem(props: {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Avatar style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#1890ff' }} size={36}>我</Avatar>
|
||||
<Avatar className="message-item-user-avatar" size={36}>我</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -273,8 +273,19 @@ body {
|
|||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
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 {
|
||||
|
|
@ -536,6 +547,10 @@ body {
|
|||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.is-h5 .chat-header-stream-toggle {
|
||||
margin-right: 4px;;
|
||||
}
|
||||
|
||||
.chat-header-stream-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
|
|
@ -619,6 +634,7 @@ body {
|
|||
|
||||
.main {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
@ -631,7 +647,6 @@ body {
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
|
|
@ -647,8 +662,10 @@ body {
|
|||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
|
|
@ -680,12 +697,6 @@ body {
|
|||
padding: 1.125rem 0.75rem 6rem;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 92%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 0.75rem 1rem;
|
||||
|
|
@ -743,15 +754,18 @@ body {
|
|||
.bubble {
|
||||
max-width: 78%;
|
||||
display: inline-block;
|
||||
padding: 14px 18px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.65;
|
||||
line-height: 1.5;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.is-h5 .bubble {
|
||||
max-width: 94%;
|
||||
}
|
||||
|
||||
.bubble.user {
|
||||
background: #0a84ff;
|
||||
color: #ffffff;
|
||||
|
|
@ -760,7 +774,7 @@ body {
|
|||
}
|
||||
|
||||
.bubble.assistant {
|
||||
background: #e9e9eb;
|
||||
background: #edf1f6;
|
||||
color: #111827;
|
||||
border: 0;
|
||||
border-bottom-left-radius: 5px;
|
||||
|
|
@ -815,20 +829,6 @@ body {
|
|||
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 {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(1.125rem);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue