From e6b570e613f5610fe45f5cc97575eb81588ee2da Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Fri, 14 Feb 2025 12:43:31 +0000 Subject: [PATCH] feat(core): support network search in chat block center peek (#10186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [BS-2582](https://linear.app/affine-design/issue/BS-2582/chat-block-center-peek-支持-network-search) --- .../presets/ai/chat-panel/chat-panel-input.ts | 7 +- .../blocksuite/presets/ai/chat-panel/const.ts | 3 + .../presets/ai/peek-view/chat-block-input.ts | 226 +++++++++++++----- .../ai/peek-view/chat-block-peek-view.ts | 142 ++++++----- .../components/ai-chat-messages.ts | 34 +-- .../view/ai-chat-block-peek-view/index.tsx | 12 +- 6 files changed, 276 insertions(+), 148 deletions(-) diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts index 615603dded..9866b0cfb0 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts @@ -27,6 +27,7 @@ import { readBlobAsURL } from '../utils/image'; import type { AINetworkSearchConfig } from './chat-config'; import type { ChatContextValue, ChatMessage, DocContext } from './chat-context'; import { isDocChip } from './components/utils'; +import { PROMPT_NAME_AFFINE_AI, PROMPT_NAME_NETWORK_SEARCH } from './const'; const MaximumImageCount = 32; @@ -289,11 +290,11 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) { private get _promptName() { if (this._isNetworkDisabled) { - return 'Chat With AFFiNE AI'; + return PROMPT_NAME_AFFINE_AI; } return this._isNetworkActive - ? 'Search With AFFiNE AI' - : 'Chat With AFFiNE AI'; + ? PROMPT_NAME_NETWORK_SEARCH + : PROMPT_NAME_AFFINE_AI; } private async _updatePromptName() { diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts index bd5c350b37..2009ce9837 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts @@ -8,3 +8,6 @@ export const HISTORY_IMAGE_ACTIONS = [ 'Remove background', 'Convert to sticker', ]; + +export const PROMPT_NAME_AFFINE_AI = 'Chat With AFFiNE AI'; +export const PROMPT_NAME_NETWORK_SEARCH = 'Search With AFFiNE AI'; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-input.ts b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-input.ts index f1b1055c52..bf70214fa5 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-input.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-input.ts @@ -1,5 +1,11 @@ import type { EditorHost } from '@blocksuite/affine/block-std'; -import { type AIError, openFileOrFiles } from '@blocksuite/affine/blocks'; +import { + type AIError, + openFileOrFiles, + unsafeCSSVarV2, +} from '@blocksuite/affine/blocks'; +import { SignalWatcher } from '@blocksuite/affine/global/utils'; +import { ImageIcon, PublishIcon } from '@blocksuite/icons/lit'; import { css, html, LitElement, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -11,16 +17,21 @@ import { ChatClearIcon, ChatSendIcon, CloseIcon, - ImageIcon, } from '../_common/icons'; +import type { AINetworkSearchConfig } from '../chat-panel/chat-config'; +import { + PROMPT_NAME_AFFINE_AI, + PROMPT_NAME_NETWORK_SEARCH, +} from '../chat-panel/const'; import { AIProvider } from '../provider'; import { reportResponse } from '../utils/action-reporter'; import { readBlobAsURL } from '../utils/image'; +import { stopPropagation } from '../utils/selection-utils'; import type { ChatContext } from './types'; const MaximumImageCount = 8; -export class ChatBlockInput extends LitElement { +export class ChatBlockInput extends SignalWatcher(LitElement) { static override styles = css` :host { width: 100%; @@ -126,6 +137,7 @@ export class ChatBlockInput extends LitElement { display: flex; gap: 8px; align-items: center; + div { width: 24px; height: 24px; @@ -134,6 +146,28 @@ export class ChatBlockInput extends LitElement { div:nth-child(2) { margin-left: auto; } + + .image-upload, + .chat-network-search { + display: flex; + justify-content: center; + align-items: center; + svg { + width: 20px; + height: 20px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + } + .chat-network-search[data-active='true'] svg { + color: ${unsafeCSSVarV2('icon/activated')}; + } + + .chat-network-search[aria-disabled='true'] { + cursor: not-allowed; + } + .chat-network-search[aria-disabled='true'] svg { + color: var(--affine-text-disable-color) !important; + } } .chat-history-clear.disabled { @@ -168,79 +202,44 @@ export class ChatBlockInput extends LitElement {
-
{ - if (disableCleanUp) { - return; - } - await this.cleanupHistories(); - }} - > +
${ChatClearIcon}
+ ${this.networkSearchConfig.visible.value + ? html` + + ` + : nothing} ${images.length < MaximumImageCount - ? html`
{ - const images = await openFileOrFiles({ - acceptType: 'Images', - multiple: true, - }); - if (!images) return; - this._addImages(images); - }} - > - ${ImageIcon} + ? html`
+ ${ImageIcon()}
` : nothing} ${status === 'transmitting' - ? html`
{ - this.chatContext.abortController?.abort(); - this.updateContext({ status: 'success' }); - reportResponse('aborted:stop'); - }} - > - ${ChatAbortIcon} -
` + ? html`
${ChatAbortIcon}
` : html`
Promise; @@ -291,6 +293,41 @@ export class ChatBlockInput extends LitElement { @state() accessor _curIndex = -1; + private _lastPromptName: string | null = null; + + private get _isNetworkActive() { + return ( + !!this.networkSearchConfig.visible.value && + !!this.networkSearchConfig.enabled.value + ); + } + + private get _isNetworkDisabled() { + return !!this.chatContext.images.length; + } + + private get _promptName() { + if (this._isNetworkDisabled) { + return PROMPT_NAME_AFFINE_AI; + } + return this._isNetworkActive + ? PROMPT_NAME_NETWORK_SEARCH + : PROMPT_NAME_AFFINE_AI; + } + + private async _updatePromptName() { + if (this._lastPromptName !== this._promptName) { + this._lastPromptName = this._promptName; + const { currentSessionId } = this.chatContext; + if (currentSessionId) { + await AIProvider.session?.updateSession( + currentSessionId, + this._promptName + ); + } + } + } + private readonly _addImages = (images: File[]) => { const oldImages = this.chatContext.images; this.updateContext({ @@ -298,6 +335,71 @@ export class ChatBlockInput extends LitElement { }); }; + private readonly _toggleNetworkSearch = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const enable = this.networkSearchConfig.enabled.value; + this.networkSearchConfig.setEnabled(!enable); + }; + + private readonly _handleKeyDown = async (evt: KeyboardEvent) => { + if (evt.key === 'Enter' && !evt.shiftKey && !evt.isComposing) { + evt.preventDefault(); + await this._send(); + } + }; + + private readonly _handleInput = () => { + const { textarea } = this; + this._isInputEmpty = !textarea.value.trim(); + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + if (this.scrollHeight >= 202) { + textarea.style.height = '168px'; + textarea.style.overflowY = 'scroll'; + } + }; + + private readonly _handlePaste = (event: ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + for (const index in items) { + const item = items[index]; + if (item.kind === 'file' && item.type.indexOf('image') >= 0) { + const blob = item.getAsFile(); + if (!blob) continue; + this._addImages([blob]); + } + } + }; + + private readonly _handleCleanup = async () => { + if ( + this.chatContext.status === 'loading' || + this.chatContext.status === 'transmitting' || + !this.chatContext.messages.length + ) { + return; + } + await this.cleanupHistories(); + }; + + private readonly _handleImageUpload = async () => { + const images = await openFileOrFiles({ + acceptType: 'Images', + multiple: true, + }); + if (!images) return; + this._addImages(images); + }; + + private readonly _handleAbort = () => { + this.chatContext.abortController?.abort(); + this.updateContext({ status: 'success' }); + reportResponse('aborted:stop'); + }; + private _renderImages(images: File[]) { return html`
{ @@ -360,74 +363,76 @@ export class AIChatBlockPeekView extends LitElement { const { host } = this; const actions = ChatBlockPeekViewActions; - return html`${repeat(currentMessages, (message, idx) => { - const { status, error } = this.chatContext; - const isAssistantMessage = message.role === 'assistant'; - const isLastReply = - idx === currentMessages.length - 1 && isAssistantMessage; - const messageState = - isLastReply && (status === 'transmitting' || status === 'loading') - ? 'generating' - : 'finished'; - const shouldRenderError = isLastReply && status === 'error' && !!error; - const isNotReady = status === 'transmitting' || status === 'loading'; - const shouldRenderCopyMore = - isAssistantMessage && !(isLastReply && isNotReady); - const shouldRenderActions = - isLastReply && !!message.content && !isNotReady; + return html`${repeat( + currentMessages, + message => message.id || message.createdAt, + (message, idx) => { + const { status, error } = this.chatContext; + const isAssistantMessage = message.role === 'assistant'; + const isLastReply = + idx === currentMessages.length - 1 && isAssistantMessage; + const messageState = + isLastReply && (status === 'transmitting' || status === 'loading') + ? 'generating' + : 'finished'; + const shouldRenderError = isLastReply && status === 'error' && !!error; + const isNotReady = status === 'transmitting' || status === 'loading'; + const shouldRenderCopyMore = + isAssistantMessage && !(isLastReply && isNotReady); + const shouldRenderActions = + isLastReply && !!message.content && !isNotReady; - const messageClasses = classMap({ - 'assistant-message-container': isAssistantMessage, - }); + const messageClasses = classMap({ + 'assistant-message-container': isAssistantMessage, + }); - const { attachments, role, content } = message; - const userInfo = { - userId: message.userId, - userName: message.userName, - avatarUrl: message.avatarUrl, - }; - const textRendererOptions: TextRendererOptions = { - extensions: this.previewSpecBuilder.value, - }; + const { attachments, role, content, userId, userName, avatarUrl } = + message; - return html`
- - ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} - ${shouldRenderCopyMore - ? html` this.retry()} - >` - : nothing} - ${shouldRenderActions - ? html`` - : nothing} -
`; - })}`; + return html`
+ + ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} + ${shouldRenderCopyMore + ? html` this.retry()} + >` + : nothing} + ${shouldRenderActions + ? html`` + : nothing} +
`; + } + )}`; }; override connectedCallback() { super.connectedCallback(); + this._textRendererOptions = { + extensions: this.previewSpecBuilder.value, + }; this._historyMessages = this._deserializeHistoryChatMessages( this.historyMessagesString ); @@ -476,19 +481,18 @@ export class AIChatBlockPeekView extends LitElement { cleanCurrentChatHistories, chatContext, updateContext, + networkSearchConfig, + _textRendererOptions, } = this; const { messages: currentChatMessages } = chatContext; - const textRendererOptions: TextRendererOptions = { - extensions: this.previewSpecBuilder.value, - }; return html`
@@ -504,6 +508,7 @@ export class AIChatBlockPeekView extends LitElement { .cleanupHistories=${cleanCurrentChatHistories} .chatContext=${chatContext} .updateContext=${updateContext} + .networkSearchConfig=${networkSearchConfig} >