diff --git a/src/components/chart-minimize/chart-minimize.scss b/src/components/chart-minimize/chart-minimize.scss new file mode 100644 index 0000000000000000000000000000000000000000..6e0560e1a4a153a4d5a84252df21fc165b552def --- /dev/null +++ b/src/components/chart-minimize/chart-minimize.scss @@ -0,0 +1,84 @@ +@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; + } + + @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 new file mode 100644 index 0000000000000000000000000000000000000000..7b44449fc25644f51d2b9aacc52f56a220a29ade --- /dev/null +++ b/src/components/chart-minimize/chart-minimize.tsx @@ -0,0 +1,233 @@ +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'; +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 [displayedContent, setDisplayedContent] = useState(''); + // 当前显示的字符索引 + const [currentIndex, setCurrentIndex] = useState(0); + /** + * 是否在拖拽中 + */ + const isDragging = useRef(false); + + /** + * 最小化样式 + */ + const style = useRef({ + x: (window.innerWidth - 86) / window.innerWidth, + 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]); + + /** + * 设置样式 + */ + 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(); + }, []); + + // 逐个字符显示 + 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 ( +
+ {isOutput ? ( +
+ 输出中 + {displayedContent && ( +
+
{displayedContent}
+
+ )} +
+ ) : ( + + )} +
+ ); +}; diff --git a/src/components/chat-container/chat-container.scss b/src/components/chat-container/chat-container.scss index f5a2d9134b24966723d6738b1540fceafeee52da..f7e664db88a9fa73c97a60e1c09d735c4f7e7f31 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 34416d0719d0c5117c0ff74001fbfed87cdad02c..bae80314e703c904dea50479fa63d92f7137df65 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,27 @@ 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, + 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, - }; - - minimizeData = { - x: (window.innerWidth - 86) / window.innerWidth, - y: (window.innerHeight - 86) / window.innerHeight, + showMode: 'side', }; /** @@ -223,121 +230,73 @@ export class ChatContainer extends Component< : true, }; + /** + * 计算AI窗口样式 + * + * @return {*} + * @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', }; } - 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} - * @memberof ChatContainer - */ - isWithinBounds(data: { x: number; y: number }): boolean { - return data.x >= 0 && data.x <= 1 && data.y >= 0 && data.y <= 1; - } - - /** - * 吸附边缘 - * - 靠近窗口边缘(20px)自动吸附 + * @param {('left' | 'right')} side * @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(); + calcSideModeStyle(side: 'left' | 'right'): void { + Object.assign(this.data, { + side: { + y: 0, + x: side === 'left' ? 0 : (window.innerWidth - 750) / window.innerWidth, + height: 1, + width: 750 / window.innerWidth, + }, + showMode: 'side', + }); } /** - * 拖拽限制(边界问题) + * 设置样式 * - * @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()); + localStorage.setItem(AIChatConst.STYLE_CACHE, JSON.stringify(this.data)); } /** - * 注册最小化拖拽 - * + * 吸附边缘(窗口模式) + * - 靠近窗口上下边缘(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); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; + snapToEdge(): void { + const snapWidth = 20 / window.innerWidth; + const snapHeight = 20 / window.innerHeight; + const { x, y, width, height } = this.data.window; + // 靠边模式 + if (x < snapWidth || x + width > 1 - snapWidth) { + this.calcSideModeStyle(x < snapWidth ? 'left' : 'right'); + } else { + // 吸附模式 + if (y < snapHeight) this.data.window.y = 0; + if (y + height > 1 - snapHeight) this.data.window.y = 1 - height; + } + this.setStyle(); } /** @@ -354,13 +313,15 @@ 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( + // 如果是靠边模式进行拖拽,立即变为窗口模式 + this.data.showMode = 'window'; + const { x, y } = limitDraggable( evt.clientX - offsetX, evt.clientY - offsetY, - this.data.width, - this.data.height, + this.data.window.width, + this.data.window.height, ); - Object.assign(this.data, { x, y }); + Object.assign(this.data.window, { x, y }); this.setStyle(); }; @@ -406,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: () => { @@ -437,22 +400,20 @@ 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 ( + data.side && + isWithinBounds(data.side) && + data.window && + isWithinBounds(data.window) + ) + Object.assign(this.data, data); } this.setStyle(); this.registerDragDialog(); this.registerDragDialogBorder(); - this.registerDragMinmize(); document.addEventListener('fullscreenchange', this.handleFullScreenChange); } @@ -461,11 +422,6 @@ export class ChatContainer extends Component< 'fullscreenchange', this.handleFullScreenChange, ); - localStorage.setItem(AIChatConst.STYLE_CACHE, JSON.stringify(this.data)); - localStorage.setItem( - AIChatConst.MINIMIZE_STYLY_CHCHE, - JSON.stringify(this.minimizeData), - ); } /** @@ -523,7 +479,6 @@ export class ChatContainer extends Component< * @memberof ChatContainer */ exitMinimize(): void { - if (this.isDragging) return; this.setState({ isMinimize: false }); this.props.minimize(false); } @@ -642,8 +597,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 3b19de0561665dc316ea1f3e46d025b663547eca..5093671ff168f205da388669fea9e4215af28a7a 100644 --- a/src/components/chat-messages/chat-messages.tsx +++ b/src/components/chat-messages/chat-messages.tsx @@ -33,17 +33,31 @@ export const ChatMessages = (props: ChatMessageProps) => { const ref = useRef(null); const messages = props.controller.messages; - // 用于标记用户是否手动滚动到了上方 const isUserScrolledUp = useRef(false); - useSignalEffect(() => { + const setScrollTo = () => { const container = ref.current; - if (!container || messages.value.length === 0) return; - + if (!container) return; // 如果用户没有手动滚动到上方,则自动滚动到底部 if (!isUserScrolledUp.current) { - container.scrollTop = container.scrollHeight; + 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(() => { + setScrollTo(); + }, 100); + } else { + setScrollTo(); } }); @@ -51,11 +65,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 ddd070974d8b2e6ef214eba92cff44f59cfc3e5d..d8d065925797f25479b92746965137ded4f22b3e 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 0000000000000000000000000000000000000000..3865b57903f67d6833770e2e697eb4e68fa406a3 --- /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 }; +}