feat(core): support network search in chat block center peek (#10186)

[BS-2582](https://linear.app/affine-design/issue/BS-2582/chat-block-center-peek-支持-network-search)
This commit is contained in:
donteatfriedrice
2025-02-14 12:43:31 +00:00
parent b6f8027e1b
commit e6b570e613
6 changed files with 276 additions and 148 deletions
@@ -27,6 +27,7 @@ import { readBlobAsURL } from '../utils/image';
import type { AINetworkSearchConfig } from './chat-config'; import type { AINetworkSearchConfig } from './chat-config';
import type { ChatContextValue, ChatMessage, DocContext } from './chat-context'; import type { ChatContextValue, ChatMessage, DocContext } from './chat-context';
import { isDocChip } from './components/utils'; import { isDocChip } from './components/utils';
import { PROMPT_NAME_AFFINE_AI, PROMPT_NAME_NETWORK_SEARCH } from './const';
const MaximumImageCount = 32; const MaximumImageCount = 32;
@@ -289,11 +290,11 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
private get _promptName() { private get _promptName() {
if (this._isNetworkDisabled) { if (this._isNetworkDisabled) {
return 'Chat With AFFiNE AI'; return PROMPT_NAME_AFFINE_AI;
} }
return this._isNetworkActive return this._isNetworkActive
? 'Search With AFFiNE AI' ? PROMPT_NAME_NETWORK_SEARCH
: 'Chat With AFFiNE AI'; : PROMPT_NAME_AFFINE_AI;
} }
private async _updatePromptName() { private async _updatePromptName() {
@@ -8,3 +8,6 @@ export const HISTORY_IMAGE_ACTIONS = [
'Remove background', 'Remove background',
'Convert to sticker', 'Convert to sticker',
]; ];
export const PROMPT_NAME_AFFINE_AI = 'Chat With AFFiNE AI';
export const PROMPT_NAME_NETWORK_SEARCH = 'Search With AFFiNE AI';
@@ -1,5 +1,11 @@
import type { EditorHost } from '@blocksuite/affine/block-std'; 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 { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
@@ -11,16 +17,21 @@ import {
ChatClearIcon, ChatClearIcon,
ChatSendIcon, ChatSendIcon,
CloseIcon, CloseIcon,
ImageIcon,
} from '../_common/icons'; } 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 { AIProvider } from '../provider';
import { reportResponse } from '../utils/action-reporter'; import { reportResponse } from '../utils/action-reporter';
import { readBlobAsURL } from '../utils/image'; import { readBlobAsURL } from '../utils/image';
import { stopPropagation } from '../utils/selection-utils';
import type { ChatContext } from './types'; import type { ChatContext } from './types';
const MaximumImageCount = 8; const MaximumImageCount = 8;
export class ChatBlockInput extends LitElement { export class ChatBlockInput extends SignalWatcher(LitElement) {
static override styles = css` static override styles = css`
:host { :host {
width: 100%; width: 100%;
@@ -126,6 +137,7 @@ export class ChatBlockInput extends LitElement {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
div { div {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -134,6 +146,28 @@ export class ChatBlockInput extends LitElement {
div:nth-child(2) { div:nth-child(2) {
margin-left: auto; 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 { .chat-history-clear.disabled {
@@ -168,79 +202,44 @@ export class ChatBlockInput extends LitElement {
<textarea <textarea
rows="1" rows="1"
placeholder="What are your thoughts?" placeholder="What are your thoughts?"
@keydown=${async (evt: KeyboardEvent) => { @keydown=${this._handleKeyDown}
if (evt.key === 'Enter' && !evt.shiftKey && !evt.isComposing) { @input=${this._handleInput}
evt.preventDefault();
await this._send();
}
}}
@input=${() => {
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';
}
}}
@focus=${() => { @focus=${() => {
this._focused = true; this._focused = true;
}} }}
@blur=${() => { @blur=${() => {
this._focused = false; this._focused = false;
}} }}
@paste=${(event: ClipboardEvent) => { @paste=${this._handlePaste}
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]);
}
}
}}
data-testid="chat-block-input" data-testid="chat-block-input"
></textarea> ></textarea>
<div class="chat-panel-input-actions"> <div class="chat-panel-input-actions">
<div <div class=${cleanButtonClasses} @click=${this._handleCleanup}>
class=${cleanButtonClasses}
@click=${async () => {
if (disableCleanUp) {
return;
}
await this.cleanupHistories();
}}
>
${ChatClearIcon} ${ChatClearIcon}
</div> </div>
${this.networkSearchConfig.visible.value
? html`
<div
class="chat-network-search"
data-testid="chat-network-search"
aria-disabled=${this._isNetworkDisabled}
data-active=${this._isNetworkActive}
@click=${this._isNetworkDisabled
? undefined
: this._toggleNetworkSearch}
@pointerdown=${stopPropagation}
>
${PublishIcon()}
</div>
`
: nothing}
${images.length < MaximumImageCount ${images.length < MaximumImageCount
? html`<div ? html`<div class="image-upload" @click=${this._handleImageUpload}>
class="image-upload" ${ImageIcon()}
@click=${async () => {
const images = await openFileOrFiles({
acceptType: 'Images',
multiple: true,
});
if (!images) return;
this._addImages(images);
}}
>
${ImageIcon}
</div>` </div>`
: nothing} : nothing}
${status === 'transmitting' ${status === 'transmitting'
? html`<div ? html`<div @click=${this._handleAbort}>${ChatAbortIcon}</div>`
@click=${() => {
this.chatContext.abortController?.abort();
this.updateContext({ status: 'success' });
reportResponse('aborted:stop');
}}
>
${ChatAbortIcon}
</div>`
: html`<div : html`<div
@click="${this._send}" @click="${this._send}"
class="chat-panel-send" class="chat-panel-send"
@@ -261,6 +260,9 @@ export class ChatBlockInput extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
accessor host!: EditorHost; accessor host!: EditorHost;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false }) @property({ attribute: false })
accessor updateChatBlock!: () => Promise<void>; accessor updateChatBlock!: () => Promise<void>;
@@ -291,6 +293,41 @@ export class ChatBlockInput extends LitElement {
@state() @state()
accessor _curIndex = -1; 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[]) => { private readonly _addImages = (images: File[]) => {
const oldImages = this.chatContext.images; const oldImages = this.chatContext.images;
this.updateContext({ 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[]) { private _renderImages(images: File[]) {
return html` return html`
<div <div
@@ -416,6 +518,8 @@ export class ChatBlockInput extends LitElement {
chatSessionId = forkSessionId; chatSessionId = forkSessionId;
} }
await this._updatePromptName();
const abortController = new AbortController(); const abortController = new AbortController();
const stream = AIProvider.actions.chat?.({ const stream = AIProvider.actions.chat?.({
input: text, input: text,
@@ -27,6 +27,7 @@ import {
queryHistoryMessages, queryHistoryMessages,
} from '../_common/chat-actions-handle'; } from '../_common/chat-actions-handle';
import { SmallHintIcon } from '../_common/icons'; import { SmallHintIcon } from '../_common/icons';
import type { AINetworkSearchConfig } from '../chat-panel/chat-config';
import { AIChatErrorRenderer } from '../messages/error'; import { AIChatErrorRenderer } from '../messages/error';
import { AIProvider } from '../provider'; import { AIProvider } from '../provider';
import { PeekViewStyles } from './styles'; import { PeekViewStyles } from './styles';
@@ -60,6 +61,8 @@ export class AIChatBlockPeekView extends LitElement {
return this.parentModel.rootWorkspaceId; return this.parentModel.rootWorkspaceId;
} }
private _textRendererOptions: TextRendererOptions = {};
private readonly _deserializeHistoryChatMessages = ( private readonly _deserializeHistoryChatMessages = (
historyMessagesString: string historyMessagesString: string
) => { ) => {
@@ -360,74 +363,76 @@ export class AIChatBlockPeekView extends LitElement {
const { host } = this; const { host } = this;
const actions = ChatBlockPeekViewActions; const actions = ChatBlockPeekViewActions;
return html`${repeat(currentMessages, (message, idx) => { return html`${repeat(
const { status, error } = this.chatContext; currentMessages,
const isAssistantMessage = message.role === 'assistant'; message => message.id || message.createdAt,
const isLastReply = (message, idx) => {
idx === currentMessages.length - 1 && isAssistantMessage; const { status, error } = this.chatContext;
const messageState = const isAssistantMessage = message.role === 'assistant';
isLastReply && (status === 'transmitting' || status === 'loading') const isLastReply =
? 'generating' idx === currentMessages.length - 1 && isAssistantMessage;
: 'finished'; const messageState =
const shouldRenderError = isLastReply && status === 'error' && !!error; isLastReply && (status === 'transmitting' || status === 'loading')
const isNotReady = status === 'transmitting' || status === 'loading'; ? 'generating'
const shouldRenderCopyMore = : 'finished';
isAssistantMessage && !(isLastReply && isNotReady); const shouldRenderError = isLastReply && status === 'error' && !!error;
const shouldRenderActions = const isNotReady = status === 'transmitting' || status === 'loading';
isLastReply && !!message.content && !isNotReady; const shouldRenderCopyMore =
isAssistantMessage && !(isLastReply && isNotReady);
const shouldRenderActions =
isLastReply && !!message.content && !isNotReady;
const messageClasses = classMap({ const messageClasses = classMap({
'assistant-message-container': isAssistantMessage, 'assistant-message-container': isAssistantMessage,
}); });
const { attachments, role, content } = message; const { attachments, role, content, userId, userName, avatarUrl } =
const userInfo = { message;
userId: message.userId,
userName: message.userName,
avatarUrl: message.avatarUrl,
};
const textRendererOptions: TextRendererOptions = {
extensions: this.previewSpecBuilder.value,
};
return html`<div class=${messageClasses}> return html`<div class=${messageClasses}>
<ai-chat-message <ai-chat-message
.host=${host} .host=${host}
.state=${messageState} .state=${messageState}
.content=${content} .content=${content}
.attachments=${attachments} .attachments=${attachments}
.messageRole=${role} .messageRole=${role}
.userInfo=${userInfo} .userId=${userId}
.textRendererOptions=${textRendererOptions} .userName=${userName}
></ai-chat-message> .avatarUrl=${avatarUrl}
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} .textRendererOptions=${this._textRendererOptions}
${shouldRenderCopyMore ></ai-chat-message>
? html` <chat-copy-more ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
.host=${host} ${shouldRenderCopyMore
.actions=${actions} ? html` <chat-copy-more
.content=${message.content} .host=${host}
.isLast=${isLastReply} .actions=${actions}
.chatSessionId=${this.chatContext.currentSessionId ?? undefined} .content=${message.content}
.messageId=${message.id ?? undefined} .isLast=${isLastReply}
.retry=${() => this.retry()} .chatSessionId=${this.chatContext.currentSessionId ?? undefined}
></chat-copy-more>` .messageId=${message.id ?? undefined}
: nothing} .retry=${() => this.retry()}
${shouldRenderActions ></chat-copy-more>`
? html`<chat-action-list : nothing}
.host=${host} ${shouldRenderActions
.actions=${actions} ? html`<chat-action-list
.content=${message.content} .host=${host}
.chatSessionId=${this.chatContext.currentSessionId ?? undefined} .actions=${actions}
.messageId=${message.id ?? undefined} .content=${message.content}
.layoutDirection=${'horizontal'} .chatSessionId=${this.chatContext.currentSessionId ?? undefined}
></chat-action-list>` .messageId=${message.id ?? undefined}
: nothing} .layoutDirection=${'horizontal'}
</div>`; ></chat-action-list>`
})}`; : nothing}
</div>`;
}
)}`;
}; };
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._textRendererOptions = {
extensions: this.previewSpecBuilder.value,
};
this._historyMessages = this._deserializeHistoryChatMessages( this._historyMessages = this._deserializeHistoryChatMessages(
this.historyMessagesString this.historyMessagesString
); );
@@ -476,19 +481,18 @@ export class AIChatBlockPeekView extends LitElement {
cleanCurrentChatHistories, cleanCurrentChatHistories,
chatContext, chatContext,
updateContext, updateContext,
networkSearchConfig,
_textRendererOptions,
} = this; } = this;
const { messages: currentChatMessages } = chatContext; const { messages: currentChatMessages } = chatContext;
const textRendererOptions: TextRendererOptions = {
extensions: this.previewSpecBuilder.value,
};
return html`<div class="ai-chat-block-peek-view-container"> return html`<div class="ai-chat-block-peek-view-container">
<div class="ai-chat-messages-container"> <div class="ai-chat-messages-container">
<ai-chat-messages <ai-chat-messages
.host=${host} .host=${host}
.messages=${_historyMessages} .messages=${_historyMessages}
.textRendererOptions=${textRendererOptions} .textRendererOptions=${_textRendererOptions}
></ai-chat-messages> ></ai-chat-messages>
<date-time .date=${latestMessageCreatedAt}></date-time> <date-time .date=${latestMessageCreatedAt}></date-time>
<div class="new-chat-messages-container"> <div class="new-chat-messages-container">
@@ -504,6 +508,7 @@ export class AIChatBlockPeekView extends LitElement {
.cleanupHistories=${cleanCurrentChatHistories} .cleanupHistories=${cleanCurrentChatHistories}
.chatContext=${chatContext} .chatContext=${chatContext}
.updateContext=${updateContext} .updateContext=${updateContext}
.networkSearchConfig=${networkSearchConfig}
></chat-block-input> ></chat-block-input>
<div class="peek-view-footer"> <div class="peek-view-footer">
${SmallHintIcon} ${SmallHintIcon}
@@ -524,6 +529,9 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
accessor previewSpecBuilder!: SpecBuilder; accessor previewSpecBuilder!: SpecBuilder;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@state() @state()
accessor _historyMessages: ChatMessage[] = []; accessor _historyMessages: ChatMessage[] = [];
@@ -548,11 +556,13 @@ declare global {
export const AIChatBlockPeekViewTemplate = ( export const AIChatBlockPeekViewTemplate = (
parentModel: AIChatBlockModel, parentModel: AIChatBlockModel,
host: EditorHost, host: EditorHost,
previewSpecBuilder: SpecBuilder previewSpecBuilder: SpecBuilder,
networkSearchConfig: AINetworkSearchConfig
) => { ) => {
return html`<ai-chat-block-peek-view return html`<ai-chat-block-peek-view
.parentModel=${parentModel} .parentModel=${parentModel}
.host=${host} .host=${host}
.previewSpecBuilder=${previewSpecBuilder} .previewSpecBuilder=${previewSpecBuilder}
.networkSearchConfig=${networkSearchConfig}
></ai-chat-block-peek-view>`; ></ai-chat-block-peek-view>`;
}; };
@@ -5,11 +5,7 @@ import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import type { import type { ChatMessage, MessageRole } from '../../../../blocks';
ChatMessage,
MessageRole,
MessageUserInfo,
} from '../../../../blocks';
import type { TextRendererOptions } from '../../../_common/components/text-renderer'; import type { TextRendererOptions } from '../../../_common/components/text-renderer';
import { UserInfoTemplate } from './user-info'; import { UserInfoTemplate } from './user-info';
@@ -43,7 +39,9 @@ export class AIChatMessage extends LitElement {
content, content,
attachments, attachments,
messageRole, messageRole,
userInfo, userId,
userName,
avatarUrl,
} = this; } = this;
const withAttachments = !!attachments && attachments.length > 0; const withAttachments = !!attachments && attachments.length > 0;
@@ -53,7 +51,7 @@ export class AIChatMessage extends LitElement {
return html` return html`
<div class="ai-chat-message"> <div class="ai-chat-message">
${UserInfoTemplate(userInfo, messageRole)} ${UserInfoTemplate({ userId, userName, avatarUrl }, messageRole)}
<div class="ai-chat-content"> <div class="ai-chat-content">
<chat-images .attachments=${attachments}></chat-images> <chat-images .attachments=${attachments}></chat-images>
<div class=${messageClasses}> <div class=${messageClasses}>
@@ -88,7 +86,13 @@ export class AIChatMessage extends LitElement {
accessor textRendererOptions: TextRendererOptions = {}; accessor textRendererOptions: TextRendererOptions = {};
@property({ attribute: false }) @property({ attribute: false })
accessor userInfo: MessageUserInfo = {}; accessor userId: string | undefined = undefined;
@property({ attribute: false })
accessor userName: string | undefined = undefined;
@property({ attribute: false })
accessor avatarUrl: string | undefined = undefined;
} }
export class AIChatMessages extends LitElement { export class AIChatMessages extends LitElement {
@@ -112,14 +116,10 @@ export class AIChatMessages extends LitElement {
return html`<div class="ai-chat-messages"> return html`<div class="ai-chat-messages">
${repeat( ${repeat(
this.messages, this.messages,
message => message.id, message => message.id || message.createdAt,
message => { message => {
const { attachments, role, content } = message; const { attachments, role, content, userId, userName, avatarUrl } =
const userInfo = { message;
userId: message.userId,
userName: message.userName,
avatarUrl: message.avatarUrl,
};
return html` return html`
<ai-chat-message <ai-chat-message
.host=${this.host} .host=${this.host}
@@ -127,7 +127,9 @@ export class AIChatMessages extends LitElement {
.content=${content} .content=${content}
.attachments=${attachments} .attachments=${attachments}
.messageRole=${role} .messageRole=${role}
.userInfo=${userInfo} .userId=${userId}
.userName=${userName}
.avatarUrl=${avatarUrl}
></ai-chat-message> ></ai-chat-message>
`; `;
} }
@@ -1,5 +1,6 @@
import { toReactNode } from '@affine/component'; import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/presets/ai'; import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/presets/ai';
import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search';
import type { EditorHost } from '@blocksuite/affine/block-std'; import type { EditorHost } from '@blocksuite/affine/block-std';
import { useFramework } from '@toeverything/infra'; import { useFramework } from '@toeverything/infra';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -17,13 +18,20 @@ export const AIChatBlockPeekView = ({
host, host,
}: AIChatBlockPeekViewProps) => { }: AIChatBlockPeekViewProps) => {
const framework = useFramework(); const framework = useFramework();
const searchService = framework.get(AINetworkSearchService);
return useMemo(() => { return useMemo(() => {
const previewSpecBuilder = createPageModePreviewSpecs(framework); const previewSpecBuilder = createPageModePreviewSpecs(framework);
const networkSearchConfig = {
visible: searchService.visible,
enabled: searchService.enabled,
setEnabled: searchService.setEnabled,
};
const template = AIChatBlockPeekViewTemplate( const template = AIChatBlockPeekViewTemplate(
model, model,
host, host,
previewSpecBuilder previewSpecBuilder,
networkSearchConfig
); );
return toReactNode(template); return toReactNode(template);
}, [framework, model, host]); }, [framework, model, host, searchService]);
}; };