From b47533466887f61f05b0fdb5da7bd1d67019de4d Mon Sep 17 00:00:00 2001 From: ShineKOT <1917095344@qq.com> Date: Mon, 17 Mar 2025 20:24:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=20=E4=BC=98=E5=8C=96=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=AE=BD=E5=BA=A6=E4=B8=8E=E5=81=9C=E9=9D=A0=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E4=BC=98=E5=8C=96=E8=87=AA=E5=8A=A8=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E5=8C=96=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart-minimize/chart-minimize.scss | 29 +++ .../chart-minimize/chart-minimize.tsx | 143 ++++++++++++ .../chat-container/chat-container.scss | 31 --- .../chat-container/chat-container.tsx | 211 +++++++----------- .../chat-messages/chat-messages.tsx | 17 +- src/utils/index.ts | 1 + src/utils/util/drag-util.ts | 39 ++++ 7 files changed, 309 insertions(+), 162 deletions(-) create mode 100644 src/components/chart-minimize/chart-minimize.scss create mode 100644 src/components/chart-minimize/chart-minimize.tsx create mode 100644 src/utils/util/drag-util.ts diff --git a/src/components/chart-minimize/chart-minimize.scss b/src/components/chart-minimize/chart-minimize.scss new file mode 100644 index 0000000..ce13b63 --- /dev/null +++ b/src/components/chart-minimize/chart-minimize.scss @@ -0,0 +1,29 @@ +@include b(chart-minimize) { + width: 56px; + height: 56px; + z-index: 99999; + display: flex; + cursor: pointer; + border-radius: 50%; + position: absolute; + align-items: center; + justify-content: center; + color: #{getCssVar('ai-chat', 'color')}; + background-color: #{getCssVar('ai-chat', 'background-color')}; + border: 1px solid #{getCssVar('ai-chat', 'border-color')}; + + &:hover { + color: #{getCssVar('ai-chat', 'hover-color')}; + background-color: #{getCssVar('ai-chat', 'hover-background-color')}; + } + + svg { + fill: currentcolor; + display: inline-block; + vertical-align: middle; + } + + @include when(hidden) { + display: none; + } +} \ No newline at end of file diff --git a/src/components/chart-minimize/chart-minimize.tsx b/src/components/chart-minimize/chart-minimize.tsx new file mode 100644 index 0000000..b6294a1 --- /dev/null +++ b/src/components/chart-minimize/chart-minimize.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef } from 'preact/hooks'; +import { Namespace, isWithinBounds, limitDraggable } from '../../utils'; +import { AiChatController } from '../../controller'; +import { AISvg } from '../../icons'; +import { AIChatConst } from '../../constants'; +import './chart-minimize.scss'; + +export interface ChatMinimizeProps { + /** + * 单实例聊天总控 + * + * @type {AiChatController} + * @memberof ChatMessageItemProps + */ + controller: AiChatController; + /** + * 标题 + * + * @type {string} + * @memberof ChatMinimizeProps + */ + title: string; + /** + * 是否最小化 + * + * @type {boolean} + * @memberof ChatMinimizeProps + */ + isMinimize: boolean; + /** + * 点击 + * + * @memberof ChatMinimizeProps + */ + onClick: () => void; +} + +const ns = new Namespace('chart-minimize'); + +export const ChatMinimize = (props: ChatMinimizeProps) => { + const ref = useRef(null); + + /** + * 是否在拖拽中 + */ + const isDragging = useRef(false); + + /** + * 最小化样式 + */ + const style = useRef({ + x: (window.innerWidth - 86) / window.innerWidth, + y: (window.innerHeight - 86) / window.innerHeight, + }); + + /** + * 设置样式 + */ + const setStyle = (): void => { + Object.assign(ref.current!.style, { + left: `${style.current.x * 100}%`, + top: `${style.current.y * 100}%`, + }); + localStorage.setItem( + AIChatConst.MINIMIZE_STYLY_CHCHE, + JSON.stringify(style), + ); + }; + + /** + * 注册最小化拖拽 + * + * @returns + */ + const registerDragMinmize = (): void => { + const container = ref.current; + if (!container) return; + container.onmousedown = (e: MouseEvent): void => { + // 禁止选择文本,避免拖动时出现选择效果 + document.body.style.userSelect = 'none'; + const offsetX = e.clientX - container.offsetLeft; + const offsetY = e.clientY - container.offsetTop; + const start = Date.now(); + const onMouseMove = (evt: MouseEvent): void => { + const width = 56 / window.innerWidth; + const height = 56 / window.innerHeight; + const { x, y } = limitDraggable( + evt.clientX - offsetX, + evt.clientY - offsetY, + width, + height, + ); + style.current = { x, y }; + requestAnimationFrame(() => { + setStyle(); + }); + }; + const onMouseUp = (): void => { + // 恢复选择文本功能 + const end = Date.now(); + // 鼠标按下到抬起的时间超过300毫秒则认定为拖拽中 + isDragging.current = end - start > 300; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + }; + + /** + * 处理点击 + * + * @returns + */ + const handleClick = (): void => { + if (isDragging.current) return; + props.onClick(); + }; + + useEffect(() => { + const cache = localStorage.getItem(AIChatConst.MINIMIZE_STYLY_CHCHE); + if (cache) { + const data = JSON.parse(cache); + if (isWithinBounds(data)) style.current = data; + } + setStyle(); + registerDragMinmize(); + }, []); + + return ( +
+ +
+ ); +}; diff --git a/src/components/chat-container/chat-container.scss b/src/components/chat-container/chat-container.scss index f5a2d91..f7e664d 100644 --- a/src/components/chat-container/chat-container.scss +++ b/src/components/chat-container/chat-container.scss @@ -48,37 +48,6 @@ $ai-chat: ( } } - @include e(minimize) { - position: absolute; - z-index: 99999; - display: flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - color: #{getCssVar('ai-chat', 'color')}; - cursor: pointer; - background-color: #{getCssVar('ai-chat', 'background-color')}; - border: 1px solid #{getCssVar('ai-chat', 'border-color')}; - border-radius: 50%; - - &:hover { - color: #{getCssVar('ai-chat', 'hover-color')}; - cursor: pointer; - background-color: #{getCssVar('ai-chat', 'hover-background-color')}; - } - - svg { - display: inline-block; - vertical-align: middle; - fill: currentcolor; - } - - @include when(hidden) { - display: none; - } - } - @include e(toolbar) { display: flex; justify-content: center; diff --git a/src/components/chat-container/chat-container.tsx b/src/components/chat-container/chat-container.tsx index 34416d0..cf9d30a 100644 --- a/src/components/chat-container/chat-container.tsx +++ b/src/components/chat-container/chat-container.tsx @@ -1,11 +1,10 @@ import { Component, createContext, createRef } from 'preact'; import interact from 'interactjs'; -import { Namespace } from '../../utils'; +import { Namespace, isWithinBounds, limitDraggable } from '../../utils'; import { ChatMessages } from '../chat-messages/chat-messages'; import { ChatInput } from '../chat-input/chat-input'; import { AiChatController, AiTopicController } from '../../controller'; import { - AISvg, CloseSvg, MinimizeSvg, FullScreenSvg, @@ -15,6 +14,7 @@ import { AIChatConst } from '../../constants'; import { IChatToolbarItem, IChatContainerOptions } from '../../interface'; import { ChatTopics } from '../chat-topics/chat-topics'; import { ChatToolbar } from '../chat-toolbar/chat-toolbar'; +import { ChatMinimize } from '../chart-minimize/chart-minimize'; import './chat-container.scss'; export interface ChatContainerProps { @@ -175,20 +175,19 @@ export class ChatContainer extends Component< dragHandle = createRef(); - minimizeRef = createRef(); - + /** + * 窗口样式数据 + * + * @memberof ChatContainer + */ data = { y: 0, height: 1, - width: 600 / window.innerWidth, - x: (window.innerWidth - 600) / window.innerWidth, + width: 750 / window.innerWidth, + x: (window.innerWidth - 750) / window.innerWidth, minWidth: 500, minHeight: 300, - }; - - minimizeData = { - x: (window.innerWidth - 86) / window.innerWidth, - y: (window.innerHeight - 86) / window.innerHeight, + showMode: 'side', }; /** @@ -223,6 +222,12 @@ export class ChatContainer extends Component< : true, }; + /** + * 计算AI窗口样式 + * + * @return {*} + * @memberof ChatContainer + */ calcWindowStyle() { return { left: `${this.data.x * 100}%`, @@ -235,109 +240,78 @@ export class ChatContainer extends Component< }; } - calcMinimizeStyle() { - return { - left: `${this.minimizeData.x * 100}%`, - top: `${this.minimizeData.y * 100}%`, - 'z-index': '99999', - }; - } - - setStyle(): void { - Object.assign(this.containerRef.current!.style, this.calcWindowStyle()); - Object.assign(this.minimizeRef.current!.style, this.calcMinimizeStyle()); - } - /** - * 检查是否在窗口内部 + * 计算靠边模式样式 * - * @param {{ x: number; y: number }} data - * @return {*} {boolean} + * @param {('left' | 'right')} side * @memberof ChatContainer */ - isWithinBounds(data: { x: number; y: number }): boolean { - return data.x >= 0 && data.x <= 1 && data.y >= 0 && data.y <= 1; + calcSideModeStyle(side: 'left' | 'right'): void { + Object.assign(this.data, { + y: 0, + x: side === 'left' ? 0 : (window.innerWidth - 750) / window.innerWidth, + height: 1, + width: 750 / window.innerWidth, + showMode: 'side', + }); } /** - * 吸附边缘 - * - 靠近窗口边缘(20px)自动吸附 + * 设置窗口模式样式 + * * @memberof ChatContainer */ - snapToEdge(): void { - const snapWidth = 20 / window.innerWidth; - const snapHeight = 20 / window.innerHeight; - if (this.data.x < snapWidth) this.data.x = 0; - if (this.data.x + this.data.width > 1 - snapWidth) - this.data.x = 1 - this.data.width; - if (this.data.y < snapHeight) this.data.y = 0; - if (this.data.y + this.data.height > 1 - snapHeight) - this.data.y = 1 - this.data.height; - this.setStyle(); + calcWindowModeStyle(): void { + const cache = localStorage.getItem(AIChatConst.STYLE_CACHE); + // 默认窗口样式(宽高:750) + const style = { + width: 750 / window.innerWidth, + height: 750 / window.innerHeight, + }; + if (cache) { + const data = JSON.parse(cache); + if (isWithinBounds(data) && data.showMode === 'window') + Object.assign(style, data); + } + Object.assign(this.data, { + width: style.width, + height: style.height, + showMode: 'window', + }); } /** - * 拖拽限制(边界问题) + * 设置样式 * - * @param {number} left - * @param {number} top - * @param {number} width - * @param {number} height * @memberof ChatContainer */ - limitDraggable( - left: number, - top: number, - width: number, - height: number, - ): { - x: number; - y: number; - } { - const offsetX = left / window.innerWidth; - const offsetY = top / window.innerHeight; - const x = Math.max(0, Math.min(offsetX, 1 - width)); - const y = Math.max(0, Math.min(offsetY, 1 - height)); - return { x, y }; + setStyle(): void { + Object.assign(this.containerRef.current!.style, this.calcWindowStyle()); } /** - * 注册最小化拖拽 - * + * 吸附边缘 + * - 靠近窗口上下边缘(20px) - 自动吸附 + * - 靠近窗口左右边缘(20px) - 靠边模式 * @memberof ChatContainer */ - registerDragMinmize(): void { - this.minimizeRef.current!.onmousedown = (e: MouseEvent): void => { - // 禁止选择文本,避免拖动时出现选择效果 - document.body.style.userSelect = 'none'; - const offsetX = e.clientX - this.minimizeRef.current!.offsetLeft; - const offsetY = e.clientY - this.minimizeRef.current!.offsetTop; - const start = Date.now(); - const onMouseMove = (evt: MouseEvent): void => { - const width = 56 / window.innerWidth; - const height = 56 / window.innerHeight; - const { x, y } = this.limitDraggable( - evt.clientX - offsetX, - evt.clientY - offsetY, - width, - height, - ); - Object.assign(this.minimizeData, { x, y }); - this.setStyle(); - }; - const onMouseUp = (): void => { - // 恢复选择文本功能 - const end = Date.now(); - // 鼠标按下到抬起的时间超过300毫秒则认定为拖拽中 - this.isDragging = end - start > 300; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; + snapToEdge(): void { + const snapWidth = 20 / window.innerWidth; + const snapHeight = 20 / window.innerHeight; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; + // 靠边模式 + if ( + this.data.x < snapWidth || + this.data.x + this.data.width > 1 - snapWidth + ) { + this.calcSideModeStyle(this.data.x < snapWidth ? 'left' : 'right'); + } else { + // 吸附模式 + if (this.data.y < snapHeight) this.data.y = 0; + if (this.data.y + this.data.height > 1 - snapHeight) + this.data.y = 1 - this.data.height; + } + this.setStyle(); } /** @@ -354,13 +328,18 @@ export class ChatContainer extends Component< const offsetY = e.clientY - this.containerRef.current!.offsetTop; const onMouseMove = (evt: MouseEvent): void => { if (this.disabled) return; - const { x, y } = this.limitDraggable( - evt.clientX - offsetX, - evt.clientY - offsetY, - this.data.width, - this.data.height, - ); - Object.assign(this.data, { x, y }); + // 如果是靠边模式进行拖拽,立即变为窗口模式 + if (this.data.showMode === 'side') { + this.calcWindowModeStyle(); + } else { + const { x, y } = limitDraggable( + evt.clientX - offsetX, + evt.clientY - offsetY, + this.data.width, + this.data.height, + ); + Object.assign(this.data, { x, y }); + } this.setStyle(); }; @@ -437,22 +416,14 @@ export class ChatContainer extends Component< componentDidMount(): void { this.handleFullScreenChange = this.handleFullScreenChange.bind(this); - const minimizeCache = localStorage.getItem( - AIChatConst.MINIMIZE_STYLY_CHCHE, - ); - if (minimizeCache) { - const data = JSON.parse(minimizeCache); - if (this.isWithinBounds(data)) this.minimizeData = data; - } const cache = localStorage.getItem(AIChatConst.STYLE_CACHE); if (cache) { const data = JSON.parse(cache); - if (this.isWithinBounds(data)) this.data = data; + if (isWithinBounds(data)) this.data = data; } this.setStyle(); this.registerDragDialog(); this.registerDragDialogBorder(); - this.registerDragMinmize(); document.addEventListener('fullscreenchange', this.handleFullScreenChange); } @@ -462,10 +433,6 @@ export class ChatContainer extends Component< this.handleFullScreenChange, ); localStorage.setItem(AIChatConst.STYLE_CACHE, JSON.stringify(this.data)); - localStorage.setItem( - AIChatConst.MINIMIZE_STYLY_CHCHE, - JSON.stringify(this.minimizeData), - ); } /** @@ -523,7 +490,6 @@ export class ChatContainer extends Component< * @memberof ChatContainer */ exitMinimize(): void { - if (this.isDragging) return; this.setState({ isMinimize: false }); this.props.minimize(false); } @@ -642,8 +608,8 @@ export class ChatContainer extends Component< /> )} -
- -
+ /> ); diff --git a/src/components/chat-messages/chat-messages.tsx b/src/components/chat-messages/chat-messages.tsx index 3b19de0..f115f21 100644 --- a/src/components/chat-messages/chat-messages.tsx +++ b/src/components/chat-messages/chat-messages.tsx @@ -33,17 +33,24 @@ export const ChatMessages = (props: ChatMessageProps) => { const ref = useRef(null); const messages = props.controller.messages; - // 用于标记用户是否手动滚动到了上方 const isUserScrolledUp = useRef(false); useSignalEffect(() => { const container = ref.current; if (!container || messages.value.length === 0) return; - + // 如果最后一个信息是用户提问,则恢复自动滚动 + if (messages.value[messages.value.length - 1].role === 'USER') + isUserScrolledUp.current = false; // 如果用户没有手动滚动到上方,则自动滚动到底部 if (!isUserScrolledUp.current) { - container.scrollTop = container.scrollHeight; + // 延迟 100ms 确保 DOM 元素已经渲染完成 + setTimeout(() => { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }); + }, 100); } }); @@ -51,11 +58,9 @@ export const ChatMessages = (props: ChatMessageProps) => { const handleScroll = () => { const container = ref.current; if (!container) return; - // 判断用户是否滚动到了上方 const { scrollTop, scrollHeight, clientHeight } = container; - const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 50; // 距离底部小于 50px 认为是底部 - + const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 50; // 如果用户滚动到了底部,恢复自动滚动 if (isNearBottom) { isUserScrolledUp.current = false; diff --git a/src/utils/index.ts b/src/utils/index.ts index ddd0709..d8d0659 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,3 +4,4 @@ export { createUUID } from './util/util'; export { IndexedDBUtil } from './util/indexdb-util'; export { FileUploader } from './util/file-uploader'; export { MaterialResourceParser } from './util/material-resource-parser'; +export { isWithinBounds, limitDraggable } from './util/drag-util'; diff --git a/src/utils/util/drag-util.ts b/src/utils/util/drag-util.ts new file mode 100644 index 0000000..3865b57 --- /dev/null +++ b/src/utils/util/drag-util.ts @@ -0,0 +1,39 @@ +/** + * 检查是否在窗口内部 + * + * @export + * @param {{ x: number; y: number }} data + * @return {*} {boolean} + */ +export function isWithinBounds(data: { x: number; y: number }): boolean { + return data.x >= 0 && data.x <= 1 && data.y >= 0 && data.y <= 1; +} + +/** + * 拖拽限制(不能超出窗口) + * + * @export + * @param {number} left + * @param {number} top + * @param {number} width + * @param {number} height + * @return {*} {{ + * x: number; + * y: number; + * }} + */ +export function limitDraggable( + left: number, + top: number, + width: number, + height: number, +): { + x: number; + y: number; +} { + const offsetX = left / window.innerWidth; + const offsetY = top / window.innerHeight; + const x = Math.max(0, Math.min(offsetX, 1 - width)); + const y = Math.max(0, Math.min(offsetY, 1 - height)); + return { x, y }; +} -- Gitee From 31585ca05a731392e5f46490262933daa898502d Mon Sep 17 00:00:00 2001 From: ShineKOT <1917095344@qq.com> Date: Mon, 17 Mar 2025 20:55:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=20=E6=9B=B4=E6=96=B0=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E5=8C=96=E8=BE=93=E5=87=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart-minimize/chart-minimize.scss | 57 ++++++++- .../chart-minimize/chart-minimize.tsx | 96 ++++++++++++++- .../chat-container/chat-container.tsx | 115 ++++++++---------- .../chat-messages/chat-messages.tsx | 27 ++-- 4 files changed, 218 insertions(+), 77 deletions(-) diff --git a/src/components/chart-minimize/chart-minimize.scss b/src/components/chart-minimize/chart-minimize.scss index ce13b63..6e0560e 100644 --- a/src/components/chart-minimize/chart-minimize.scss +++ b/src/components/chart-minimize/chart-minimize.scss @@ -26,4 +26,59 @@ @include when(hidden) { display: none; } -} \ No newline at end of file + + @include e(output) { + font-size: 12px; + @include m(popover) { + position: absolute; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + background-color: #{getCssVar('ai-chat', 'background-color')}; + color: #{getCssVar('ai-chat', 'color')}; + border: 1px solid #{getCssVar('ai-chat', 'border-color')}; + border-radius: 8px; + padding: 8px; + max-width: 200px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: block; + + .typewriter { + direction: rtl; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + animation: typing 3s steps(40, end); + } + } + } +} + +@keyframes blink { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} + +@keyframes typing { + from { + width: 0; + } + to { + width: 100%; + } +} + +@keyframes blink-caret { + from, + to { + border-color: transparent; + } + 50% { + border-color: #{getCssVar('ai-chat', 'color')}; + } +} diff --git a/src/components/chart-minimize/chart-minimize.tsx b/src/components/chart-minimize/chart-minimize.tsx index b6294a1..7b44449 100644 --- a/src/components/chart-minimize/chart-minimize.tsx +++ b/src/components/chart-minimize/chart-minimize.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useComputed } from '@preact/signals'; import { Namespace, isWithinBounds, limitDraggable } from '../../utils'; import { AiChatController } from '../../controller'; import { AISvg } from '../../icons'; @@ -39,7 +40,10 @@ const ns = new Namespace('chart-minimize'); export const ChatMinimize = (props: ChatMinimizeProps) => { const ref = useRef(null); - + // 用于显示的内容 + const [displayedContent, setDisplayedContent] = useState(''); + // 当前显示的字符索引 + const [currentIndex, setCurrentIndex] = useState(0); /** * 是否在拖拽中 */ @@ -53,6 +57,69 @@ export const ChatMinimize = (props: ChatMinimizeProps) => { y: (window.innerHeight - 86) / window.innerHeight, }); + /** + * 当前消息 + * + */ + const message = useComputed(() => { + return ( + props.controller.messages.value[ + props.controller.messages.value.length - 1 + ] || undefined + ); + }); + + /** + * 是否在输出中 + * + */ + const isOutput = useMemo(() => { + return message.value?.state === 20 && message.value?.completed !== true; + }, [message.value]); + + const parseThinkContent = (text: string) => { + const openThinkIndex = text.indexOf(''); + const closeThinkIndex = text.indexOf(''); + + let thoughtContent = ''; + let answerContent = ''; + let isThoughtCompleted = false; + if (closeThinkIndex === -1) { + isThoughtCompleted = false; + thoughtContent = text.slice(openThinkIndex + 7); + answerContent = ''; + } else { + isThoughtCompleted = true; + thoughtContent = text.slice(openThinkIndex + 7, closeThinkIndex); + answerContent = text.slice(closeThinkIndex + 8); + } + return { isThoughtCompleted, thoughtContent, answerContent }; + }; + + /** + * 消息内容 + * + */ + const msgContent = useMemo(() => { + let content = ''; + if (isOutput) { + if (message.value.content.indexOf('') !== -1) { + const { thoughtContent, answerContent } = parseThinkContent( + message.value.content, + ); + content = thoughtContent + answerContent; + } else { + content = message.value?.content; + } + } else { + // 清空显示内容 + setDisplayedContent(''); + // 重置字符索引 + setCurrentIndex(0); + } + return content; + }, [message.value]); + /** * 设置样式 */ @@ -130,6 +197,18 @@ export const ChatMinimize = (props: ChatMinimizeProps) => { registerDragMinmize(); }, []); + // 逐个字符显示 + useEffect(() => { + if (currentIndex < msgContent.length) { + const timer = setTimeout(() => { + setDisplayedContent(prev => prev + msgContent[currentIndex]); + setCurrentIndex(prev => prev + 1); + }, 100); // 每个字符的显示间隔时间(100ms) + + return () => clearTimeout(timer); // 清理定时器 + } + }, [currentIndex, msgContent]); + return (
{ className={`${ns.b()} ${ns.is('hidden', !props.isMinimize)}`} onClick={handleClick} > - + {isOutput ? ( +
+ 输出中 + {displayedContent && ( +
+
{displayedContent}
+
+ )} +
+ ) : ( + + )}
); }; diff --git a/src/components/chat-container/chat-container.tsx b/src/components/chat-container/chat-container.tsx index cf9d30a..bae8031 100644 --- a/src/components/chat-container/chat-container.tsx +++ b/src/components/chat-container/chat-container.tsx @@ -181,10 +181,18 @@ export class ChatContainer extends Component< * @memberof ChatContainer */ data = { - y: 0, - height: 1, - width: 750 / window.innerWidth, - x: (window.innerWidth - 750) / window.innerWidth, + side: { + y: 0, + height: 1, + width: 750 / window.innerWidth, + x: (window.innerWidth - 750) / window.innerWidth, + }, + window: { + y: 0, + width: 750 / window.innerWidth, + height: 750 / window.innerHeight, + x: (window.innerWidth - 750) / window.innerWidth, + }, minWidth: 500, minHeight: 300, showMode: 'side', @@ -229,11 +237,13 @@ export class ChatContainer extends Component< * @memberof ChatContainer */ calcWindowStyle() { + const data = + this.data.showMode === 'window' ? this.data.window : this.data.side; return { - left: `${this.data.x * 100}%`, - top: `${this.data.y * 100}%`, - width: `${this.data.width * 100}%`, - height: `${this.data.height * 100}%`, + left: `${data.x * 100}%`, + top: `${data.y * 100}%`, + width: `${data.width * 100}%`, + height: `${data.height * 100}%`, minWidth: `${this.data.minWidth}px`, minHeight: `${this.data.minHeight}px`, 'z-index': this.props.containerOptions?.zIndex?.toString() || '10', @@ -248,38 +258,16 @@ export class ChatContainer extends Component< */ calcSideModeStyle(side: 'left' | 'right'): void { Object.assign(this.data, { - y: 0, - x: side === 'left' ? 0 : (window.innerWidth - 750) / window.innerWidth, - height: 1, - width: 750 / window.innerWidth, + side: { + y: 0, + x: side === 'left' ? 0 : (window.innerWidth - 750) / window.innerWidth, + height: 1, + width: 750 / window.innerWidth, + }, showMode: 'side', }); } - /** - * 设置窗口模式样式 - * - * @memberof ChatContainer - */ - calcWindowModeStyle(): void { - const cache = localStorage.getItem(AIChatConst.STYLE_CACHE); - // 默认窗口样式(宽高:750) - const style = { - width: 750 / window.innerWidth, - height: 750 / window.innerHeight, - }; - if (cache) { - const data = JSON.parse(cache); - if (isWithinBounds(data) && data.showMode === 'window') - Object.assign(style, data); - } - Object.assign(this.data, { - width: style.width, - height: style.height, - showMode: 'window', - }); - } - /** * 设置样式 * @@ -287,10 +275,11 @@ export class ChatContainer extends Component< */ setStyle(): void { Object.assign(this.containerRef.current!.style, this.calcWindowStyle()); + localStorage.setItem(AIChatConst.STYLE_CACHE, JSON.stringify(this.data)); } /** - * 吸附边缘 + * 吸附边缘(窗口模式) * - 靠近窗口上下边缘(20px) - 自动吸附 * - 靠近窗口左右边缘(20px) - 靠边模式 * @memberof ChatContainer @@ -298,18 +287,14 @@ export class ChatContainer extends Component< snapToEdge(): void { const snapWidth = 20 / window.innerWidth; const snapHeight = 20 / window.innerHeight; - + const { x, y, width, height } = this.data.window; // 靠边模式 - if ( - this.data.x < snapWidth || - this.data.x + this.data.width > 1 - snapWidth - ) { - this.calcSideModeStyle(this.data.x < snapWidth ? 'left' : 'right'); + if (x < snapWidth || x + width > 1 - snapWidth) { + this.calcSideModeStyle(x < snapWidth ? 'left' : 'right'); } else { // 吸附模式 - if (this.data.y < snapHeight) this.data.y = 0; - if (this.data.y + this.data.height > 1 - snapHeight) - this.data.y = 1 - this.data.height; + if (y < snapHeight) this.data.window.y = 0; + if (y + height > 1 - snapHeight) this.data.window.y = 1 - height; } this.setStyle(); } @@ -329,17 +314,14 @@ export class ChatContainer extends Component< const onMouseMove = (evt: MouseEvent): void => { if (this.disabled) return; // 如果是靠边模式进行拖拽,立即变为窗口模式 - if (this.data.showMode === 'side') { - this.calcWindowModeStyle(); - } else { - const { x, y } = limitDraggable( - evt.clientX - offsetX, - evt.clientY - offsetY, - this.data.width, - this.data.height, - ); - Object.assign(this.data, { x, y }); - } + this.data.showMode = 'window'; + const { x, y } = limitDraggable( + evt.clientX - offsetX, + evt.clientY - offsetY, + this.data.window.width, + this.data.window.height, + ); + Object.assign(this.data.window, { x, y }); this.setStyle(); }; @@ -385,10 +367,12 @@ export class ChatContainer extends Component< listeners: { move: event => { if (this.state.isFullScreen) return; - this.data.x = event.rect.left / window.innerWidth; - this.data.y = event.rect.top / window.innerHeight; - this.data.width = event.rect.width / window.innerWidth; - this.data.height = event.rect.height / window.innerHeight; + const data = + this.data.showMode === 'side' ? this.data.side : this.data.window; + data.x = event.rect.left / window.innerWidth; + data.y = event.rect.top / window.innerHeight; + data.width = event.rect.width / window.innerWidth; + data.height = event.rect.height / window.innerHeight; this.setStyle(); }, start: () => { @@ -419,7 +403,13 @@ export class ChatContainer extends Component< const cache = localStorage.getItem(AIChatConst.STYLE_CACHE); if (cache) { const data = JSON.parse(cache); - if (isWithinBounds(data)) this.data = data; + if ( + data.side && + isWithinBounds(data.side) && + data.window && + isWithinBounds(data.window) + ) + Object.assign(this.data, data); } this.setStyle(); this.registerDragDialog(); @@ -432,7 +422,6 @@ export class ChatContainer extends Component< 'fullscreenchange', this.handleFullScreenChange, ); - localStorage.setItem(AIChatConst.STYLE_CACHE, JSON.stringify(this.data)); } /** diff --git a/src/components/chat-messages/chat-messages.tsx b/src/components/chat-messages/chat-messages.tsx index f115f21..5093671 100644 --- a/src/components/chat-messages/chat-messages.tsx +++ b/src/components/chat-messages/chat-messages.tsx @@ -36,21 +36,28 @@ export const ChatMessages = (props: ChatMessageProps) => { // 用于标记用户是否手动滚动到了上方 const isUserScrolledUp = useRef(false); - useSignalEffect(() => { + const setScrollTo = () => { const container = ref.current; - if (!container || messages.value.length === 0) return; - // 如果最后一个信息是用户提问,则恢复自动滚动 - if (messages.value[messages.value.length - 1].role === 'USER') - isUserScrolledUp.current = false; + if (!container) return; // 如果用户没有手动滚动到上方,则自动滚动到底部 if (!isUserScrolledUp.current) { - // 延迟 100ms 确保 DOM 元素已经渲染完成 + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }); + } + }; + + useSignalEffect(() => { + if (messages.value.length === 0) return; + // 如果最后一个信息是用户提问,则恢复自动滚动 + if (messages.value[messages.value.length - 1].role === 'USER') { + isUserScrolledUp.current = false; setTimeout(() => { - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }); + setScrollTo(); }, 100); + } else { + setScrollTo(); } }); -- Gitee