diff --git a/src/components/chat-back-bottom/chat-back-bottom.scss b/src/components/chat-back-bottom/chat-back-bottom.scss new file mode 100644 index 0000000000000000000000000000000000000000..b8435bbafe6502751f2bb318bf4594fe91338621 --- /dev/null +++ b/src/components/chat-back-bottom/chat-back-bottom.scss @@ -0,0 +1,26 @@ +@include b(chat-back-bottom) { + position: absolute; + z-index: 5; + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: getCssVar(font-size, header, 4); + cursor: pointer; + background-color: getCssVar(ai-chat, background, color, 2); + border-radius: getCssVar(border-radius, circle); + box-shadow: getCssVar(shadow, elevated); + + &:hover { + background-color: getCssVar(ai-chat, hover, background, color, 2); + } + + @include e(icon) { + font-size: 20px; + } + + @include when(visible) { + display: flex; + } +} \ No newline at end of file diff --git a/src/components/chat-back-bottom/chat-back-bottom.tsx b/src/components/chat-back-bottom/chat-back-bottom.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3aeaa7a5488ecad7b8d67ae558bbfcd2b5277572 --- /dev/null +++ b/src/components/chat-back-bottom/chat-back-bottom.tsx @@ -0,0 +1,112 @@ +import { useSignal } from '@preact/signals'; +import { useEffect, useMemo } from 'preact/hooks'; +import { Namespace } from '../../utils'; +import { ChevronDownSvg } from '../../icons'; +import './chat-back-bottom.scss'; + +export interface ChatBackBottomProps { + /** + * @description 右侧距离 + * @type {number} + * @memberof ChatBackBottomProps + */ + right: number; + + /** + * @description 底部距离 + * @type {number} + * @memberof ChatBackBottomProps + */ + bottom: number; + + /** + * @description 滚动目标 + * @type {string} + * @memberof ChatBackBottomProps + */ + target: string; + + /** + * @description 滚动高度达到此参数值才出现 + * @type {number} + * @memberof ChatBackBottomProps + */ + visibilityHeight?: number; +} + +export function throttle( + fn: (...args: unknown[]) => void | Promise, + wait: number, +): (...args: unknown[]) => void { + let timer: unknown = null; + return function (this: unknown, ...args: unknown[]): void { + if (!timer) { + timer = setTimeout(() => { + fn.apply(this, args); + timer = null; + }, wait); + } + }; +} + +export const ChatBackBottom = (props: ChatBackBottomProps) => { + // 是否显示 + const visible = useSignal(false); + + // 按钮样式 + const buttonStyle = useSignal({}); + + const ns = new Namespace('chat-back-bottom'); + + const container = useSignal(null); + + const handleScroll = () => { + if (container.value) { + const visibilityHeight = props.visibilityHeight || 200; + const scrollBottom = + container.value.scrollHeight - + container.value.scrollTop - + container.value.offsetHeight; + visible.value = scrollBottom >= visibilityHeight; + } + }; + + const handleClick = () => { + if (container.value) { + container.value.scrollTo({ + top: container.value.scrollHeight, + behavior: 'smooth', + }); + } + }; + + const handleScrollThrottled = throttle(handleScroll, 300); + + useMemo(() => { + buttonStyle.value = { + right: `${props.right}px`, + bottom: `${props.bottom}px`, + }; + }, [props.right, props.bottom]); + + useEffect(() => { + if (props.target) { + const el = document.querySelector(props.target) ?? undefined; + if (el) { + container.value = el; + el.addEventListener('scroll', handleScrollThrottled); + handleScroll(); + } + } + }, []); + + return ( +
+ +
+ ); +}; diff --git a/src/components/chat-container/chat-container.scss b/src/components/chat-container/chat-container.scss index 427fdd021be7264148c21979f7b257f37338f135..1b2fe437d0a85af976bbffe7ed7646fead6c84ef 100644 --- a/src/components/chat-container/chat-container.scss +++ b/src/components/chat-container/chat-container.scss @@ -92,8 +92,8 @@ $ai-chat: ( align-items: center; justify-content: space-between; height: 60px; - cursor: move; padding: 4px; + cursor: move; border-bottom: 1px solid #{getCssVar('ai-chat', 'border-color')}; } @@ -152,6 +152,7 @@ $ai-chat: ( } @include b(chat-container-content) { + position: relative; flex-grow: 1; overflow: hidden; } diff --git a/src/components/chat-input/chat-input.scss b/src/components/chat-input/chat-input.scss index 4b5e704e0cce12d5f0bdad14588c9a2d5164ad0a..1e7ceb29ce5599705a78513c5f05b0f8e5210835 100644 --- a/src/components/chat-input/chat-input.scss +++ b/src/components/chat-input/chat-input.scss @@ -14,28 +14,6 @@ border-radius: #{getCssVar('ai-chat', 'border-radius')}; } -@keyframes loading-change { - 0% { - opacity: 1; - } - - 25% { - opacity: 0.5; - } - - 50% { - opacity: 0.1; - } - - 75% { - opacity: 0.5; - } - - 100% { - opacity: 1; - } -} - @include b(chat-input__textarea) { flex-grow: 1; max-height: 6em; @@ -83,11 +61,6 @@ color: #{getCssVar('ai-chat', 'disabled-color')}; pointer-events: none; } - - @include when(loading) { - pointer-events: none; - animation: loading-change .8s infinite; - } } } diff --git a/src/components/chat-input/chat-input.tsx b/src/components/chat-input/chat-input.tsx index fa4fd489e1e9647c56144daa5874803bd5ebc92f..5fbd4869d2ff608b21e522a8d84376c5e544482a 100644 --- a/src/components/chat-input/chat-input.tsx +++ b/src/components/chat-input/chat-input.tsx @@ -18,6 +18,7 @@ import { RecordingSvg, SendSvg, FileSvg, + StopCircleSvg, } from '../../icons'; import { AiChatController, AIMaterialFactory } from '../../controller'; import './chat-input.scss'; @@ -128,7 +129,8 @@ export const ChatInput = (props: ChatInputProps) => { }, [input, isLoading]); const onKeyDown = (e: KeyboardEvent) => { - if (e.code === 'Enter') { + // 存在候选词时不请求 + if (e.code === 'Enter' && !e.isComposing) { e.stopPropagation(); if (e.shiftKey === false) { question(); @@ -167,6 +169,16 @@ export const ChatInput = (props: ChatInputProps) => { setIsPopupOpen(false); }; + const onSendClick = () => { + // 停止生成 + if (isLoading.value) { + props.controller.abortQuestion(); + isLoading.value = false; + } else { + question(); + } + }; + return (
@@ -243,15 +255,15 @@ export const ChatInput = (props: ChatInputProps) => { {recording.value ? : }
- + {isLoading.value ? : }
diff --git a/src/components/chat-message-item/markdown-message/markdown-message.scss b/src/components/chat-message-item/markdown-message/markdown-message.scss index ee2ebce5fca66b3f19abfe7262a8391cec35b001..eeb840cb7b8f4d1d3884d9b820770e8286c1d9c4 100644 --- a/src/components/chat-message-item/markdown-message/markdown-message.scss +++ b/src/components/chat-message-item/markdown-message/markdown-message.scss @@ -10,9 +10,24 @@ padding: 8px; border: 0; - figure > svg { - width: 100%; - min-height: 100px; + figure { + max-width: 400px; + + > svg { + width: 100%; + min-height: 100px; + } + } + + div[data-type="codeBlock"] { + position: relative !important; + display: flex; + padding-top: 24px; + + .cherry-edit-code-block,.cherry-copy-code-block { + position: absolute; + top: 0; + } } } diff --git a/src/components/chat-message-item/markdown-message/markdown-message.tsx b/src/components/chat-message-item/markdown-message/markdown-message.tsx index e5324aa1e9c95c574f1f2b46748f9de17f88f5a6..0cd63eb861ff3ad950e62856b592b5b11c62b0a3 100644 --- a/src/components/chat-message-item/markdown-message/markdown-message.tsx +++ b/src/components/chat-message-item/markdown-message/markdown-message.tsx @@ -135,6 +135,7 @@ export const MarkdownMessage = (props: MarkdownMessageProps) => { ) : ( ); + thoughtChain.value.title = '思考完成'; } if (thoughtContent) { thoughtChain.value.description = thoughtContent; @@ -154,7 +155,7 @@ export const MarkdownMessage = (props: MarkdownMessageProps) => { const { isThoughtCompleted, thoughtContent, answerContent } = parseThinkContent(message.content); thoughtChain.value = { - title: '思考过程', + title: isThoughtCompleted ? '思考完成' : '思考中...', description: thoughtContent || '', icon: isThoughtCompleted ? ( diff --git a/src/components/chat-messages/chat-messages.tsx b/src/components/chat-messages/chat-messages.tsx index 5a344a42ec438c4fc93b19b39fe34ec0792053fe..367c59c03f1d73ecff36922859d48061370390ce 100644 --- a/src/components/chat-messages/chat-messages.tsx +++ b/src/components/chat-messages/chat-messages.tsx @@ -5,6 +5,7 @@ import { AiChatController } from '../../controller'; import { IChatToolbarItem } from '../../interface'; import { ChatMessageItem } from '../chat-message-item/chat-message-item'; import { ChatToolbar } from '../chat-toolbar/chat-toolbar'; +import { ChatBackBottom } from '../chat-back-bottom/chat-back-bottom'; import './chat-messages.scss'; export interface ChatMessageProps { @@ -59,6 +60,7 @@ export const ChatMessages = (props: ChatMessageProps) => { ); })} + ); }; diff --git a/src/components/chat-topic-item/chat-topic-item.scss b/src/components/chat-topic-item/chat-topic-item.scss index 2e1a98990797d602478e59bc600d019a3e1d051e..ef4c2452f52ea0fa8edbd7927d5a10c55b04fd11 100644 --- a/src/components/chat-topic-item/chat-topic-item.scss +++ b/src/components/chat-topic-item/chat-topic-item.scss @@ -37,10 +37,11 @@ top: 50%; right: 10px; display: flex; + gap: 8px; align-items: center; - justify-content: space-around; - width: 54px; + justify-content: flex-end; height: 24px; + padding: 0 8px; color: #{getCssVar('ai-chat', 'hover-color')}; background-color: #{getCssVar('ai-chat', 'hover-background-color')}; border-radius: 8px; diff --git a/src/controller/ai-chat/ai-chat.controller.ts b/src/controller/ai-chat/ai-chat.controller.ts index 97e8afd568a6d001de7dd3655e814571a941c820..9212c9fd604b96231f0eed79c033c15341f98a68 100644 --- a/src/controller/ai-chat/ai-chat.controller.ts +++ b/src/controller/ai-chat/ai-chat.controller.ts @@ -78,6 +78,13 @@ export class AiChatController { */ readonly topicId: string | undefined = undefined; + /** + * @description 当前发送消息标识 + * @type {(string)} + * @memberof AiChatController + */ + public messageId: string = ''; + /** * Creates an instance of AiChatController. * @@ -269,10 +276,11 @@ export class AiChatController { type: 'DEFAULT', content: inputText, }); + this.messageId = createUUID(); await this.opts.question( this.context, this.params, - { appDataEntityId: this.appDataEntityId }, + { appDataEntityId: this.appDataEntityId, messageId: this.messageId }, this.messages.value .filter(item => item.type !== 'ERROR') .map(item => item._origin), @@ -282,6 +290,23 @@ export class AiChatController { } } + /** + * @description 中断请求 + * @memberof AiChatController + */ + abortQuestion(): void { + this.opts.abortQuestion(); + this.addMessage({ + state: 30, + messageid: this.messageId, + role: 'ASSISTANT', + type: 'DEFAULT', + completed: true, + content: '', + }); + this.completeMessage(this.messageId, true); + } + /** * 回填选中的消息 * @@ -329,10 +354,11 @@ export class AiChatController { if (isuser) { this.messages.value.splice(i + 1, this.messages.value.length - i - 1); this.messages.value = [...this.messages.value]; + this.messageId = createUUID(); await this.opts.question( this.context, this.params, - { appDataEntityId: this.appDataEntityId }, + { appDataEntityId: this.appDataEntityId, messageId: this.messageId }, this.messages.value .filter(item => item.type !== 'ERROR') .map(item => item._origin), @@ -340,10 +366,11 @@ export class AiChatController { } else if (i === this.messages.value.length - 1) { this.messages.value.pop(); this.messages.value = [...this.messages.value]; + this.messageId = createUUID(); await this.opts.question( this.context, this.params, - { appDataEntityId: this.appDataEntityId }, + { appDataEntityId: this.appDataEntityId, messageId: this.messageId }, this.messages.value .filter(item => item.type !== 'ERROR') .map(item => item._origin), diff --git a/src/entity/chat-message/chat-message.ts b/src/entity/chat-message/chat-message.ts index 5540c2688816bbacf06ed83395cb2862e60e8804..ba5c63e51ce6295869d14bba8ed284a5e46e24c5 100644 --- a/src/entity/chat-message/chat-message.ts +++ b/src/entity/chat-message/chat-message.ts @@ -52,6 +52,7 @@ export class ChatMessage implements IChatMessage { msg.content = ''; } this.msg.content += msg.content; + this.msg.state = msg.state; } /** diff --git a/src/icons/index.ts b/src/icons/index.ts index 968da6369f766325c5e9062b76d7a63076737538..fea60dd3ca6d9db9e4dd8c07af75152a1e999291 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -21,3 +21,4 @@ export { PaperclipSvg } from './paperclip-svg'; export { FileSvg } from './upload-svg'; export { MaterialRemoveSvg } from './material-remove-svg'; export { DefaultMaterialSvg } from './default-material-svg'; +export { StopCircleSvg } from './stop-circle-svg'; diff --git a/src/icons/stop-circle-svg.tsx b/src/icons/stop-circle-svg.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4262bffeedaa3097738537394e1bc73dfa87bf87 --- /dev/null +++ b/src/icons/stop-circle-svg.tsx @@ -0,0 +1,13 @@ +// 停止图标 +export const StopCircleSvg = (props: { className?: string }) => ( + + + +); diff --git a/src/interface/i-chat-options/i-chat-options.ts b/src/interface/i-chat-options/i-chat-options.ts index b6d5d098dbfa9ca4c756310d198ab20c05b92e58..b6169b1516dda3f8dbcc52b7225e9a20cd1c0859 100644 --- a/src/interface/i-chat-options/i-chat-options.ts +++ b/src/interface/i-chat-options/i-chat-options.ts @@ -117,6 +117,12 @@ export interface IChatOptions extends IChat { question: IChatMessage[], ): Promise; + /** + * @description 中断消息 + * @memberof IChatOptions + */ + abortQuestion(): void; + /** * 聊天窗历史记录获取 *