From 25221f7b5eb99730597acb160f344a2c9cae337b Mon Sep 17 00:00:00 2001 From: yannyang Date: Tue, 23 Jun 2026 21:51:05 +0800 Subject: [PATCH] feat: change h5 chat page ui --- src/App.tsx | 78 ++--- src/pages/chat/components/ChatBody.tsx | 4 +- src/pages/chat/components/ChatHeader.tsx | 18 +- src/pages/chat/components/ChatInputH5.css | 181 +++++++++++ src/pages/chat/components/ChatInputH5.tsx | 303 ++++++++++++++++++ src/pages/chat/components/ChatPageH5.css | 21 ++ src/pages/chat/components/ChatPageH5.tsx | 82 ++--- .../chat/components/messages/MessageItem.css | 78 +++++ .../chat/components/messages/MessageItem.tsx | 107 +++---- src/styles.css | 54 ++-- src/styles/app-shell-h5.css | 1 - 11 files changed, 743 insertions(+), 184 deletions(-) create mode 100644 src/pages/chat/components/ChatInputH5.css create mode 100644 src/pages/chat/components/ChatInputH5.tsx create mode 100644 src/pages/chat/components/ChatPageH5.css create mode 100644 src/pages/chat/components/messages/MessageItem.css diff --git a/src/App.tsx b/src/App.tsx index 667d33b..32125bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; -import { Button, Drawer, Spin } from 'antd'; -import { MenuOutlined, SearchOutlined } from '@ant-design/icons'; +import { Button, Drawer, Spin } from 'antd'; +import { MenuOutlined, SearchOutlined } from '@ant-design/icons'; import Sidebar from './components/Sidebar'; import CommandPalette from './components/CommandPalette'; import AgentList from './pages/AgentList'; @@ -9,16 +9,16 @@ import AgentEditor from './pages/AgentEditor'; import ChatPage from './pages/ChatPage'; import LoginPage from './pages/LoginPage'; import MarketplacePage from './pages/MarketplacePage'; -import PointsMallPage from './pages/PointsMallPage'; +import PointsMallPage from './pages/PointsMallPage'; import TeamsPage from './pages/TeamsPage'; import PromptLibraryPage from './pages/PromptLibraryPage'; import StatsPage from './pages/StatsPage'; import SharedSessionPage from './pages/SharedSessionPage'; -import WorkflowsPage from './pages/WorkflowsPage'; -import { useAuth } from './store/auth'; -import { AgentAPI } from './api'; -import { useIsMobile } from './hooks/useIsMobile'; -import kaiwuIcon from './assets/brand/kaiwu-icon-gradient-transparent.png'; +import WorkflowsPage from './pages/WorkflowsPage'; +import { useAuth } from './store/auth'; +import { AgentAPI } from './api'; +import { useIsMobile } from './hooks/useIsMobile'; +import kaiwuIcon from './assets/brand/kaiwu-icon-gradient-transparent.png'; function HomeRedirect() { return ; @@ -26,10 +26,10 @@ function HomeRedirect() { export default function App() { const { user } = useAuth(); - const location = useLocation(); - const [paletteOpen, setPaletteOpen] = useState(false); - const [mobileNavOpen, setMobileNavOpen] = useState(false); - const isMobile = useIsMobile(); + const location = useLocation(); + const [paletteOpen, setPaletteOpen] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const isMobile = useIsMobile(); // 全局快捷键 Ctrl/⌘ + K useEffect(() => { @@ -43,8 +43,8 @@ export default function App() { window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [user]); - - const mainContent = ( + + const mainContent = ( } /> } /> @@ -54,7 +54,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -78,33 +78,33 @@ export default function App() { ) : (
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */} - {!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? ( + {!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? ( setPaletteOpen(true)} /> ) : null} -
- {isMobile && ( -
-
- )} -
{mainContent}
+
+ {isMobile && ( +
+
+ )} +
{mainContent}
setPaletteOpen(false)} /> - {isMobile && ( - setMobileNavOpen(false)} - width={280} - className="mobile-nav-drawer" - > - setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} /> - - )} + {isMobile && ( + setMobileNavOpen(false)} + width={280} + className="mobile-nav-drawer" + > + setPaletteOpen(true)} onNavigate={() => setMobileNavOpen(false)} /> + + )}
)} diff --git a/src/pages/chat/components/ChatBody.tsx b/src/pages/chat/components/ChatBody.tsx index 2b79f24..92aea5d 100644 --- a/src/pages/chat/components/ChatBody.tsx +++ b/src/pages/chat/components/ChatBody.tsx @@ -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 (
@@ -55,6 +56,7 @@ export default function ChatBody(props: { <> {messages.map((m) => ( +
{agent.name} @@ -51,9 +53,11 @@ export default function ChatHeader(props: { 流式输出
- + {!isMobile && ( + + )} - diff --git a/src/pages/chat/components/ChatInputH5.css b/src/pages/chat/components/ChatInputH5.css new file mode 100644 index 0000000..aee3fbc --- /dev/null +++ b/src/pages/chat/components/ChatInputH5.css @@ -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); +} diff --git a/src/pages/chat/components/ChatInputH5.tsx b/src/pages/chat/components/ChatInputH5.tsx new file mode 100644 index 0000000..2147d5b --- /dev/null +++ b/src/pages/chat/components/ChatInputH5.tsx @@ -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(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) => { + 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 ( +
+ {/* 顶部附件展示 */} +
+ {attachments.map((a, i) => ( + setAttachments((arr) => arr.filter((_, j) => j !== i))}> + 📎 {a.name} + + ))} + {imageUrls.map((u, i) => ( +
+ +
+ ))} +
+ + {/* 模型切换与工具栏 */} +
+
+