diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/chat-actions-handle.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/chat-actions-handle.ts new file mode 100644 index 0000000000..28c1f869a1 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/chat-actions-handle.ts @@ -0,0 +1,506 @@ +import { ChatHistoryOrder } from '@affine/graphql'; +import type { + BlockSelection, + EditorHost, + TextSelection, +} from '@blocksuite/block-std'; +import type { + DocMode, + EdgelessRootService, + ImageSelection, + PageRootService, +} from '@blocksuite/blocks'; +import { + BlocksUtils, + getElementsBound, + NoteDisplayMode, +} from '@blocksuite/blocks'; +import { Bound, type SerializedXYWH } from '@blocksuite/global/utils'; +import { type ChatMessage } from '@blocksuite/presets'; +import type { Doc } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +import { AIProvider, type AIUserInfo } from '../provider'; +import { reportResponse } from '../utils/action-reporter'; +import { insertBelow, replace } from '../utils/editor-actions'; +import { insertFromMarkdown } from '../utils/markdown-utils'; +import { BlockIcon, CreateIcon, InsertBelowIcon, ReplaceIcon } from './icons'; + +const { matchFlavours } = BlocksUtils; + +type Selections = { + text?: TextSelection; + blocks?: BlockSelection[]; + images?: ImageSelection[]; +}; + +export type ChatAction = { + icon: TemplateResult<1>; + title: string; + toast: string; + showWhen: (host: EditorHost) => boolean; + handler: ( + host: EditorHost, + content: string, + currentSelections: Selections, + chatSessionId?: string, + messageId?: string + ) => Promise; +}; + +export async function queryHistoryMessages(doc: Doc, forkSessionId: string) { + // Get fork session messages + const histories = await AIProvider.histories?.chats( + doc.collection.id, + doc.id, + { + sessionId: forkSessionId, + messageOrder: ChatHistoryOrder.asc, + } + ); + + if (!histories || !histories.length) { + return []; + } + + return histories[0].messages; +} + +// Construct user info with messages +export function constructUserInfoWithMessages( + messages: ChatMessage[], + userInfo: AIUserInfo | null +) { + return messages.map(message => { + const { role, id, content, createdAt } = message; + const isUser = role === 'user'; + const userInfoProps = isUser + ? { + userId: userInfo?.id, + userName: userInfo?.name, + avatarUrl: userInfo?.avatarUrl ?? undefined, + } + : {}; + return { + id, + role, + content, + createdAt, + attachments: [], + ...userInfoProps, + }; + }); +} + +export async function constructRootChatBlockMessages( + doc: Doc, + forkSessionId: string +) { + // Convert chat messages to AI chat block messages + const userInfo = await AIProvider.userInfo; + const forkMessages = await queryHistoryMessages(doc, forkSessionId); + return constructUserInfoWithMessages(forkMessages, userInfo); +} + +function getViewportCenter( + mode: DocMode, + rootService: PageRootService | EdgelessRootService +) { + const center = { x: 0, y: 0 }; + if (mode === 'page') { + const viewport = rootService.editPropsStore.getStorage('viewport'); + if (viewport) { + if ('xywh' in viewport) { + const bound = Bound.deserialize(viewport.xywh); + center.x = bound.x + bound.w / 2; + center.y = bound.y + bound.h / 2; + } else { + center.x = viewport.centerX; + center.y = viewport.centerY; + } + } + } else { + // Else we should get latest viewport center from the edgeless root service + const edgelessService = rootService as EdgelessRootService; + center.x = edgelessService.viewport.centerX; + center.y = edgelessService.viewport.centerY; + } + + return center; +} + +// Add AI chat block and focus on it +function addAIChatBlock( + doc: Doc, + messages: ChatMessage[], + sessionId: string, + viewportCenter: { x: number; y: number } +) { + if (!messages.length || !sessionId) { + return; + } + + const surfaceBlock = doc + .getBlocks() + .find(block => block.flavour === 'affine:surface'); + if (!surfaceBlock) { + return; + } + + // Add AI chat block to the center of the viewport + const width = 300; // AI_CHAT_BLOCK_WIDTH = 300 + const height = 320; // AI_CHAT_BLOCK_HEIGHT = 320 + const x = viewportCenter.x - width / 2; + const y = viewportCenter.y - height / 2; + const bound = new Bound(x, y, width, height); + const aiChatBlockId = doc.addBlock( + 'affine:embed-ai-chat' as keyof BlockSuite.BlockModels, + { + xywh: bound.serialize(), + messages: JSON.stringify(messages), + sessionId, + }, + surfaceBlock.id + ); + + return aiChatBlockId; +} + +export function promptDocTitle(host: EditorHost, autofill?: string) { + const notification = + host.std.spec.getService('affine:page').notificationService; + if (!notification) return Promise.resolve(undefined); + + return notification.prompt({ + title: 'Create linked doc', + message: 'Enter a title for the new doc.', + placeholder: 'Untitled', + autofill, + confirmText: 'Confirm', + cancelText: 'Cancel', + }); +} + +const REPLACE_SELECTION = { + icon: ReplaceIcon, + title: 'Replace selection', + showWhen: (host: EditorHost) => { + const textSelection = host.selection.find('text'); + const blockSelections = host.selection.filter('block'); + if ( + (!textSelection || textSelection.from.length === 0) && + blockSelections?.length === 0 + ) { + return false; + } + return true; + }, + toast: 'Successfully replaced', + handler: async ( + host: EditorHost, + content: string, + currentSelections: Selections + ) => { + const currentTextSelection = currentSelections.text; + const currentBlockSelections = currentSelections.blocks; + const [_, data] = host.command + .chain() + .getSelectedBlocks({ + currentTextSelection, + currentBlockSelections, + }) + .run(); + if (!data.selectedBlocks) return false; + + reportResponse('result:replace'); + + if (currentTextSelection) { + const { doc } = host; + const block = doc.getBlock(currentTextSelection.blockId); + if (matchFlavours(block?.model ?? null, ['affine:paragraph'])) { + block?.model.text?.replace( + currentTextSelection.from.index, + currentTextSelection.from.length, + content + ); + return true; + } + } + + await replace( + host, + content, + data.selectedBlocks[0], + data.selectedBlocks.map(block => block.model), + currentTextSelection + ); + return true; + }, +}; + +const INSERT_BELOW = { + icon: InsertBelowIcon, + title: 'Insert below', + showWhen: () => true, + toast: 'Successfully inserted', + handler: async ( + host: EditorHost, + content: string, + currentSelections: Selections + ) => { + const currentTextSelection = currentSelections.text; + const currentBlockSelections = currentSelections.blocks; + const currentImageSelections = currentSelections.images; + const [_, data] = host.command + .chain() + .getSelectedBlocks({ + currentTextSelection, + currentBlockSelections, + currentImageSelections, + }) + .run(); + if (!data.selectedBlocks) return false; + reportResponse('result:insert'); + await insertBelow( + host, + content, + data.selectedBlocks[data.selectedBlocks?.length - 1] + ); + return true; + }, +}; + +const SAVE_CHAT_TO_BLOCK_ACTION: ChatAction = { + icon: BlockIcon, + title: 'Save chat to block', + toast: 'Successfully saved chat to a block', + showWhen: (host: EditorHost) => + !!host.doc.awarenessStore.getFlag('enable_ai_chat_block'), + handler: async ( + host: EditorHost, + _, + __, + chatSessionId?: string, + messageId?: string + ) => { + // The chat session id and the latest message id are required to fork the chat session + const parentSessionId = chatSessionId; + if (!messageId || !parentSessionId) { + return false; + } + + const rootService = host.spec.getService('affine:page'); + if (!rootService) return false; + + const { docModeService, notificationService } = rootService; + const curMode = docModeService.getMode(); + const viewportCenter = getViewportCenter(curMode, rootService); + // If current mode is not edgeless, switch to edgeless mode first + if (curMode !== 'edgeless') { + // Set mode to edgeless + docModeService.setMode('edgeless'); + // Notify user to switch to edgeless mode + notificationService?.notify({ + title: 'Save chat to a block', + accent: 'info', + message: + 'This feature is not available in the page editor. Switch to edgeless mode.', + onClose: function (): void {}, + }); + } + + try { + const newSessionId = await AIProvider.forkChat?.({ + workspaceId: host.doc.collection.id, + docId: host.doc.id, + sessionId: parentSessionId, + latestMessageId: messageId, + }); + + if (!newSessionId) { + return false; + } + + // Get messages before the latest message + const messages = await constructRootChatBlockMessages( + host.doc, + newSessionId + ); + + // After switching to edgeless mode, the user can save the chat to a block + const blockId = addAIChatBlock( + host.doc, + messages, + newSessionId, + viewportCenter + ); + if (!blockId) { + return false; + } + return true; + } catch (err) { + console.error(err); + notificationService?.notify({ + title: 'Failed to save chat to a block', + accent: 'error', + onClose: function (): void {}, + }); + return false; + } + }, +}; + +const ADD_TO_EDGELESS_AS_NOTE = { + icon: CreateIcon, + title: 'Add to edgeless as note', + showWhen: () => true, + toast: 'New note created', + handler: async (host: EditorHost, content: string) => { + reportResponse('result:add-note'); + const { doc } = host; + const service = host.spec.getService('affine:page'); + const elements = service.selection.selectedElements; + + const props: { displayMode: NoteDisplayMode; xywh?: SerializedXYWH } = { + displayMode: NoteDisplayMode.EdgelessOnly, + }; + + if (elements.length > 0) { + const bound = getElementsBound( + elements.map(e => Bound.deserialize(e.xywh)) + ); + const newBound = new Bound(bound.x, bound.maxY + 10, bound.w); + props.xywh = newBound.serialize(); + } + + const id = doc.addBlock('affine:note', props, doc.root?.id); + + await insertFromMarkdown(host, content, doc, id, 0); + + service.selection.set({ + elements: [id], + editing: false, + }); + + return true; + }, +}; + +const CREATE_AS_DOC = { + icon: CreateIcon, + title: 'Create as a doc', + showWhen: () => true, + toast: 'New doc created', + handler: (host: EditorHost, content: string) => { + reportResponse('result:add-page'); + const newDoc = host.doc.collection.createDoc(); + newDoc.load(); + const rootId = newDoc.addBlock('affine:page'); + newDoc.addBlock('affine:surface', {}, rootId); + const noteId = newDoc.addBlock('affine:note', {}, rootId); + + host.spec.getService('affine:page').slots.docLinkClicked.emit({ + docId: newDoc.id, + }); + let complete = false; + (function addContent() { + if (complete) return; + const newHost = document.querySelector('editor-host'); + // FIXME: this is a hack to wait for the host to be ready, now we don't have a way to know if the new host is ready + if (!newHost || newHost === host) { + setTimeout(addContent, 100); + return; + } + complete = true; + const { doc } = newHost; + insertFromMarkdown(newHost, content, doc, noteId, 0).catch(console.error); + })(); + + return true; + }, +}; + +const CREATE_AS_LINKED_DOC = { + icon: CreateIcon, + title: 'Create as a linked doc', + showWhen: () => true, + toast: 'New doc created', + handler: async (host: EditorHost, content: string) => { + reportResponse('result:add-page'); + + const { doc } = host; + const surfaceBlock = doc + .getBlocks() + .find(block => block.flavour === 'affine:surface'); + if (!surfaceBlock) { + return false; + } + + const service = host.spec.getService('affine:page'); + const mode = service.docModeService.getMode(); + if (mode !== 'edgeless') { + return false; + } + + // Create a new doc and add the content to it + const newDoc = host.doc.collection.createDoc(); + newDoc.load(); + const rootId = newDoc.addBlock('affine:page'); + newDoc.addBlock('affine:surface', {}, rootId); + const noteId = newDoc.addBlock('affine:note', {}, rootId); + await insertFromMarkdown(host, content, newDoc, noteId, 0); + + // Add a linked doc card to link to the new doc + const elements = service.selection.selectedElements; + const width = 364; + const height = 390; + let x = 0; + let y = 0; + if (elements.length) { + // Calculate the bound of the selected elements first + const bound = getElementsBound( + elements.map(e => Bound.deserialize(e.xywh)) + ); + x = bound.x; + y = bound.y + bound.h + 100; + } + + // If the selected elements are not in the viewport, center the linked doc card + if (x === Number.POSITIVE_INFINITY || y === Number.POSITIVE_INFINITY) { + const viewportCenter = getViewportCenter(mode, service); + x = viewportCenter.x - width / 2; + y = viewportCenter.y - height / 2; + } + + service.addBlock( + 'affine:embed-linked-doc', + { + xywh: `[${x}, ${y}, ${width}, ${height}]`, + style: 'vertical', + pageId: newDoc.id, + }, + surfaceBlock.id + ); + + return true; + }, +}; + +const CommonActions: ChatAction[] = [REPLACE_SELECTION, INSERT_BELOW]; + +export const PageEditorActions = [ + ...CommonActions, + CREATE_AS_DOC, + SAVE_CHAT_TO_BLOCK_ACTION, +]; + +export const EdgelessEditorActions = [ + ...CommonActions, + ADD_TO_EDGELESS_AS_NOTE, + SAVE_CHAT_TO_BLOCK_ACTION, +]; + +export const ChatBlockPeekViewActions = [ + ADD_TO_EDGELESS_AS_NOTE, + CREATE_AS_LINKED_DOC, +]; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts new file mode 100644 index 0000000000..b7b608d298 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts @@ -0,0 +1,171 @@ +import type { + BlockSelection, + EditorHost, + TextSelection, +} from '@blocksuite/block-std'; +import type { ImageSelection } from '@blocksuite/blocks'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { insertBelow } from '../../utils/editor-actions'; +import type { ChatAction } from '../chat-actions-handle'; + +@customElement('chat-action-list') +export class ChatActionList extends LitElement { + static override styles = css` + .actions-container { + display: flex; + gap: 8px; + } + .actions-container > div { + display: flex; + gap: 8px; + } + .actions-container.horizontal { + flex-wrap: wrap; + justify-content: end; + } + .actions-container.vertical { + flex-direction: column; + align-items: flex-end; + } + .action { + width: fit-content; + height: 32px; + padding: 12px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + background-color: var(--affine-white-10); + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + font-size: 15px; + font-weight: 500; + color: var(--affine-text-primary-color); + cursor: pointer; + user-select: none; + } + .action svg { + color: var(--affine-icon-color); + } + `; + + private get _selectionValue() { + return this.host.selection.value; + } + + private get _rootService() { + return this.host.spec.getService('affine:page'); + } + + private get _currentTextSelection(): TextSelection | undefined { + return this._selectionValue.find(v => v.type === 'text') as TextSelection; + } + + private get _currentBlockSelections(): BlockSelection[] | undefined { + return this._selectionValue.filter(v => v.type === 'block'); + } + + private get _currentImageSelections(): ImageSelection[] | undefined { + return this._selectionValue.filter(v => v.type === 'image'); + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor actions: ChatAction[] = []; + + @property({ attribute: false }) + accessor content: string = ''; + + @property({ attribute: false }) + accessor chatSessionId: string | undefined = undefined; + + @property({ attribute: false }) + accessor messageId: string | undefined = undefined; + + @property({ attribute: false }) + accessor layoutDirection: 'horizontal' | 'vertical' = 'vertical'; // New property for layout direction + + @property({ attribute: false }) + accessor withMargin = false; + + override render() { + const { actions } = this; + if (!actions.length) { + return nothing; + } + + const { host, content, chatSessionId, messageId, layoutDirection } = this; + const classes = classMap({ + 'actions-container': true, + horizontal: layoutDirection === 'horizontal', + vertical: layoutDirection === 'vertical', + }); + + return html` +
+ ${repeat( + actions.filter(action => action.showWhen(host)), + action => action.title, + action => { + return html`
+ ${action.icon} +
{ + if ( + action.title === 'Insert below' && + this._selectionValue.length === 1 && + this._selectionValue[0].type === 'database' + ) { + const element = this.host.view.getBlock( + this._selectionValue[0].blockId + ); + if (!element) return; + await insertBelow(host, content, element); + return; + } + const currentSelections = { + text: this._currentTextSelection, + blocks: this._currentBlockSelections, + images: this._currentImageSelections, + }; + const success = await action.handler( + host, + content, + currentSelections, + chatSessionId, + messageId + ); + if (success) { + this._rootService.notificationService?.notify({ + title: action.toast, + accent: 'success', + onClose: function (): void {}, + }); + } + }} + > + ${action.title} +
+
`; + } + )} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-action-list': ChatActionList; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts similarity index 67% rename from packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts rename to packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts index 25d2e456b0..5d5590ed28 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts @@ -4,17 +4,15 @@ import type { TextSelection, } from '@blocksuite/block-std'; import { WithDisposable } from '@blocksuite/block-std'; -import { type AIError, createButtonPopper, Tooltip } from '@blocksuite/blocks'; +import { createButtonPopper, Tooltip } from '@blocksuite/blocks'; import { noop } from '@blocksuite/global/utils'; import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; +import { type ChatAction } from '../../_common/chat-actions-handle'; import { CopyIcon, MoreIcon, RetryIcon } from '../../_common/icons'; -import { AIProvider } from '../../provider'; import { copyText } from '../../utils/editor-actions'; -import type { ChatContextValue, ChatMessage } from '../chat-context'; -import { PageEditorActions } from './actions-handle'; noop(Tooltip); @@ -25,10 +23,10 @@ export class ChatCopyMore extends WithDisposable(LitElement) { display: flex; gap: 8px; height: 36px; + box-sizing: border-box; justify-content: flex-end; align-items: center; - margin-top: 8px; - margin-bottom: 12px; + padding: 8px 0; div { cursor: pointer; @@ -74,6 +72,22 @@ export class ChatCopyMore extends WithDisposable(LitElement) { } `; + private get _rootService() { + return this.host.spec.getService('affine:page'); + } + + private get _selectionValue() { + return this.host.selection.value; + } + + private get _currentTextSelection(): TextSelection | undefined { + return this._selectionValue.find(v => v.type === 'text') as TextSelection; + } + + private get _currentBlockSelections(): BlockSelection[] | undefined { + return this._selectionValue.filter(v => v.type === 'block'); + } + @state() private accessor _showMoreMenu = false; @@ -88,34 +102,33 @@ export class ChatCopyMore extends WithDisposable(LitElement) { @property({ attribute: false }) accessor host!: EditorHost; + @property({ attribute: false }) + accessor actions: ChatAction[] = []; + @property({ attribute: false }) accessor content!: string; @property({ attribute: false }) - accessor messageId!: string; + accessor chatSessionId: string | undefined = undefined; + + @property({ attribute: false }) + accessor messageId: string | undefined = undefined; @property({ attribute: false }) accessor isLast!: boolean; @property({ attribute: false }) - accessor curTextSelection: TextSelection | undefined = undefined; + accessor withMargin = false; @property({ attribute: false }) - accessor curBlockSelections: BlockSelection[] | undefined = undefined; - - @property({ attribute: false }) - accessor chatContextValue!: ChatContextValue; - - @property({ attribute: false }) - accessor updateContext!: (context: Partial) => void; + accessor retry = () => {}; private _toggle() { this._morePopper?.toggle(); } private readonly _notifySuccess = (title: string) => { - const rootService = this.host.spec.getService('affine:page'); - const { notificationService } = rootService; + const { notificationService } = this._rootService; notificationService?.notify({ title: title, accent: 'success', @@ -123,48 +136,6 @@ export class ChatCopyMore extends WithDisposable(LitElement) { }); }; - private async _retry() { - const { doc } = this.host; - try { - const abortController = new AbortController(); - - const items = [...this.chatContextValue.items]; - const last = items[items.length - 1]; - if ('content' in last) { - last.content = ''; - last.createdAt = new Date().toISOString(); - } - this.updateContext({ items, status: 'loading', error: null }); - - const stream = AIProvider.actions.chat?.({ - retry: true, - docId: doc.id, - workspaceId: doc.collection.id, - host: this.host, - stream: true, - signal: abortController.signal, - where: 'chat-panel', - control: 'chat-send', - }); - - if (stream) { - this.updateContext({ abortController }); - for await (const text of stream) { - const items = [...this.chatContextValue.items]; - const last = items[items.length - 1] as ChatMessage; - last.content += text; - this.updateContext({ items, status: 'transmitting' }); - } - - this.updateContext({ status: 'success' }); - } - } catch (error) { - this.updateContext({ status: 'error', error: error as AIError }); - } finally { - this.updateContext({ abortController: null }); - } - } - protected override updated(changed: PropertyValues): void { if (changed.has('isLast')) { if (this.isLast) { @@ -185,8 +156,12 @@ export class ChatCopyMore extends WithDisposable(LitElement) { } override render() { - const { host, content, isLast, messageId } = this; + const { host, content, isLast, messageId, chatSessionId, actions } = this; return html` this.retry()} > - ${isLast - ? html`
- ${repeat( - actions - .filter(action => action.showWhen(host)) - .filter(action => { - if (!content) return false; - - if ( - action.title === 'Replace selection' && - (!this._currentTextSelection || - this._currentTextSelection.from.length === 0) && - this._currentBlockSelections?.length === 0 - ) { - return false; - } - return true; - }), - action => action.title, - action => { - return html`
- ${action.icon} -
{ - if ( - action.title === 'Insert below' && - this._selectionValue.length === 1 && - this._selectionValue[0].type === 'database' - ) { - const element = this.host.view.getBlock( - this._selectionValue[0].blockId - ); - if (!element) return; - await insertBelow(host, content, element); - return; - } - - const currentSelections = { - text: this._currentTextSelection, - blocks: this._currentBlockSelections, - images: this._currentImageSelections, - }; - - const success = await action.handler( - host, - content, - currentSelections, - this.chatContextValue, - messageId ?? undefined - ); - if (success) { - this._rootService.notificationService?.notify({ - title: action.toast, - accent: 'success', - onClose: function (): void {}, - }); - } - }} - > - ${action.title} -
-
`; - } - )} -
` + ${isLast && !!content + ? html`` : nothing} `; } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts index 993e5ca03b..8f92e279b6 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts @@ -122,6 +122,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { this._chatSessionId = history.sessionId; this.chatContextValue.chatSessionId = history.sessionId; items.push(...history.messages); + AIProvider.LAST_ROOT_SESSION_ID = history.sessionId; } this.chatContextValue = { @@ -188,6 +189,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { protected override updated(_changedProperties: PropertyValues) { if (_changedProperties.has('doc')) { + this.chatContextValue.chatSessionId = null; this._resetItems(); } } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts index 49afb26cda..d4baedc1b3 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts @@ -35,6 +35,7 @@ import { imageOnlyShowWhen, mindmapChildShowWhen, mindmapRootShowWhen, + notAllAIChatBlockShowWhen, noteBlockOrTextShowWhen, noteWithCodeBlockShowWen, } from '../../actions/edgeless-handler'; @@ -271,7 +272,7 @@ const generateGroup: AIItemGroupConfig = { { name: 'Generate an image', icon: AIImageIcon, - showWhen: () => true, + showWhen: notAllAIChatBlockShowWhen, handler: actionToHandler( 'createImage', AIImageIconWithAnimation, @@ -403,7 +404,7 @@ const generateGroup: AIItemGroupConfig = { name: 'Make it real', icon: MakeItRealIcon, beta: true, - showWhen: () => true, + showWhen: notAllAIChatBlockShowWhen, handler: actionToHandler( 'makeItReal', MakeItRealIconWithAnimation, diff --git a/packages/frontend/core/src/blocksuite/presets/ai/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/index.ts index 8391e00153..2d804bc527 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/index.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/index.ts @@ -4,4 +4,5 @@ export { ChatPanel } from './chat-panel/index'; export * from './entries/edgeless/actions-config'; export * from './entries/index'; export * from './messages/index'; +export { AIChatBlockPeekViewTemplate } from './peek-view/chat-block-peek-view'; export * from './provider'; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts index 87720069e3..8c85571a5f 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts @@ -1,6 +1,12 @@ import { type EditorHost, WithDisposable } from '@blocksuite/block-std'; +import { + type AIError, + PaymentRequiredError, + UnauthorizedError, +} from '@blocksuite/blocks'; import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; import { ErrorTipIcon } from '../_common/icons'; import { AIProvider } from '../provider'; @@ -112,3 +118,27 @@ declare global { 'ai-error-wrapper': AIErrorWrapper; } } + +export function AIChatErrorRenderer(host: EditorHost, error: AIError) { + if (error instanceof PaymentRequiredError) { + return PaymentRequiredErrorRenderer(host); + } else if (error instanceof UnauthorizedError) { + return GeneralErrorRenderer( + html`You need to login to AFFiNE Cloud to continue using AFFiNE AI.`, + html`
AIProvider.slots.requestLogin.emit({ host })} + > + Login +
` + ); + } else { + return GeneralErrorRenderer(); + } +} 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 new file mode 100644 index 0000000000..f77efc9069 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-input.ts @@ -0,0 +1,456 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { type AIError, openFileOrFiles } from '@blocksuite/blocks'; +import { type ChatMessage } from '@blocksuite/presets'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + ChatAbortIcon, + ChatClearIcon, + ChatSendIcon, + CloseIcon, + ImageIcon, +} from '../_common/icons'; +import { AIProvider } from '../provider'; +import { reportResponse } from '../utils/action-reporter'; +import { readBlobAsURL } from '../utils/image'; +import type { ChatContext } from './types'; + +const MaximumImageCount = 8; + +@customElement('chat-block-input') +export class ChatBlockInput extends LitElement { + static override styles = css` + :host { + width: 100%; + } + .ai-chat-input { + display: flex; + width: 100%; + min-height: 100px; + max-height: 206px; + padding: 8px 12px; + box-sizing: border-box; + border: 1px solid var(--affine-border-color); + border-radius: 4px; + flex-direction: column; + justify-content: space-between; + gap: 12px; + position: relative; + background-color: var(--affine-white-10); + } + .ai-chat-input { + textarea { + width: 100%; + padding: 0; + margin: 0; + border: none; + line-height: 22px; + font-size: var(--affine-font-sm); + font-weight: 400; + font-family: var(--affine-font-family); + color: var(--affine-text-primary-color); + box-sizing: border-box; + resize: none; + overflow-y: hidden; + background-color: transparent; + } + textarea::placeholder { + font-size: 14px; + font-weight: 400; + font-family: var(--affine-font-family); + color: var(--affine-placeholder-color); + } + textarea:focus { + outline: none; + } + } + .chat-input-images { + display: flex; + gap: 4px; + flex-wrap: wrap; + position: relative; + .image-container { + width: 58px; + height: 58px; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + cursor: pointer; + overflow: hidden; + position: relative; + display: flex; + justify-content: center; + align-items: center; + img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + } + } + } + .close-wrapper { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + justify-content: center; + align-items: center; + display: none; + position: absolute; + background-color: var(--affine-white); + z-index: 1; + cursor: pointer; + } + .close-wrapper:hover { + background-color: var(--affine-background-error-color); + border: 1px solid var(--affine-error-color); + } + .close-wrapper:hover svg path { + fill: var(--affine-error-color); + } + .chat-panel-input-actions { + display: flex; + gap: 8px; + align-items: center; + div { + width: 24px; + height: 24px; + cursor: pointer; + } + div:nth-child(2) { + margin-left: auto; + } + } + + .chat-history-clear.disabled { + cursor: not-allowed; + opacity: 0.5; + } + `; + + override render() { + const { images, status, messages } = this.chatContext; + const hasImages = images.length > 0; + const maxHeight = hasImages ? 272 + 2 : 200 + 2; + const disableCleanUp = + status === 'loading' || status === 'transmitting' || !messages.length; + const cleanButtonClasses = classMap({ + 'chat-history-clear': true, + disabled: disableCleanUp, + }); + + return html` +
+ ${hasImages ? this._renderImages(images) : nothing} + +
+
{ + if (disableCleanUp) { + return; + } + await this.cleanupHistories(); + }} + > + ${ChatClearIcon} +
+ ${images.length < MaximumImageCount + ? html`
{ + const images = await openFileOrFiles({ + acceptType: 'Images', + multiple: true, + }); + if (!images) return; + this._addImages(images); + }} + > + ${ImageIcon} +
` + : nothing} + ${status === 'transmitting' + ? html`
{ + this.chatContext.abortController?.abort(); + this.updateContext({ status: 'success' }); + reportResponse('aborted:stop'); + }} + > + ${ChatAbortIcon} +
` + : html`
+ ${ChatSendIcon} +
`} +
+
`; + } + + @property({ attribute: false }) + accessor parentSessionId!: string; + + @property({ attribute: false }) + accessor latestMessageId!: string; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor updateChatBlock!: () => Promise; + + @property({ attribute: false }) + accessor createChatBlock!: () => Promise; + + @property({ attribute: false }) + accessor cleanupHistories!: () => Promise; + + @property({ attribute: false }) + accessor updateContext!: (context: Partial) => void; + + @property({ attribute: false }) + accessor chatContext!: ChatContext; + + @query('textarea') + accessor textarea!: HTMLTextAreaElement; + + @state() + accessor _isInputEmpty = true; + + @state() + accessor _focused = false; + + @query('.close-wrapper') + accessor closeWrapper: HTMLDivElement | null = null; + + @state() + accessor _curIndex = -1; + + private readonly _addImages = (images: File[]) => { + const oldImages = this.chatContext.images; + this.updateContext({ + images: [...oldImages, ...images].slice(0, MaximumImageCount), + }); + }; + + private _renderImages(images: File[]) { + return html` +
{ + if (!this.closeWrapper) return; + this.closeWrapper.style.display = 'none'; + this._curIndex = -1; + }} + > + ${repeat( + images, + image => image.name, + (image, index) => + html`
{ + const ele = evt.target as HTMLImageElement; + const rect = ele.getBoundingClientRect(); + if (!ele.parentElement) return; + const parentRect = ele.parentElement.getBoundingClientRect(); + const left = Math.abs(rect.right - parentRect.left) - 8; + const top = Math.abs(parentRect.top - rect.top) - 8; + this._curIndex = index; + if (!this.closeWrapper) return; + this.closeWrapper.style.display = 'flex'; + this.closeWrapper.style.left = left + 'px'; + this.closeWrapper.style.top = top + 'px'; + }} + > + ${image.name} +
` + )} +
{ + if (this._curIndex >= 0 && this._curIndex < images.length) { + const newImages = [...images]; + newImages.splice(this._curIndex, 1); + this.updateContext({ images: newImages }); + this._curIndex = -1; + if (!this.closeWrapper) return; + this.closeWrapper.style.display = 'none'; + } + }} + > + ${CloseIcon} +
+
+ `; + } + + private readonly _send = async () => { + const { images, status } = this.chatContext; + if (status === 'loading' || status === 'transmitting') return; + + const text = this.textarea.value; + if (!text && !images.length) { + return; + } + + const { doc } = this.host; + this.textarea.value = ''; + this._isInputEmpty = true; + this.updateContext({ + images: [], + status: 'loading', + error: null, + }); + + const attachments = await Promise.all( + images?.map(image => readBlobAsURL(image)) + ); + + const userInfo = await AIProvider.userInfo; + this.updateContext({ + messages: [ + ...this.chatContext.messages, + { + id: '', + content: text, + role: 'user', + createdAt: new Date().toISOString(), + attachments, + userId: userInfo?.id, + userName: userInfo?.name, + avatarUrl: userInfo?.avatarUrl ?? undefined, + }, + { + id: '', + content: '', + role: 'assistant', + createdAt: new Date().toISOString(), + }, + ], + }); + + const { currentChatBlockId, currentSessionId } = this.chatContext; + let content = ''; + const chatBlockExists = !!currentChatBlockId; + try { + // If has not forked a chat session, fork a new one + let chatSessionId = currentSessionId; + if (!chatSessionId) { + const forkSessionId = await AIProvider.forkChat?.({ + workspaceId: doc.collection.id, + docId: doc.id, + sessionId: this.parentSessionId, + latestMessageId: this.latestMessageId, + }); + if (!forkSessionId) return; + this.updateContext({ + currentSessionId: forkSessionId, + }); + chatSessionId = forkSessionId; + } + + const abortController = new AbortController(); + const stream = AIProvider.actions.chat?.({ + input: text, + sessionId: chatSessionId, + docId: doc.id, + attachments: images, + workspaceId: doc.collection.id, + host: this.host, + stream: true, + signal: abortController.signal, + where: 'ai-chat-block', + control: 'chat-send', + }); + + if (stream) { + this.updateContext({ + abortController, + }); + + for await (const text of stream) { + const messages = [...this.chatContext.messages]; + const last = messages[messages.length - 1] as ChatMessage; + last.content += text; + this.updateContext({ messages, status: 'transmitting' }); + content += text; + } + + this.updateContext({ status: 'success' }); + } + } catch (error) { + console.error(error); + this.updateContext({ status: 'error', error: error as AIError }); + } finally { + if (content) { + if (!chatBlockExists) { + await this.createChatBlock(); + } + // Update new chat block messages if there are contents returned from AI + await this.updateChatBlock(); + } + + this.updateContext({ abortController: null }); + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-block-input': ChatBlockInput; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-peek-view.ts b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-peek-view.ts new file mode 100644 index 0000000000..4fc06e14f9 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/chat-block-peek-view.ts @@ -0,0 +1,527 @@ +import './chat-block-input'; +import './date-time'; +import '../_common/components/chat-action-list'; +import '../_common/components/copy-more'; + +import { type EditorHost } from '@blocksuite/block-std'; +import { + type AIError, + CanvasElementType, + ConnectorMode, + type EdgelessRootService, +} from '@blocksuite/blocks'; +import { Bound } from '@blocksuite/global/utils'; +import { + type AIChatBlockModel, + type ChatMessage, + ChatMessagesSchema, +} from '@blocksuite/presets'; +import type { Doc } from '@blocksuite/store'; +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + ChatBlockPeekViewActions, + constructUserInfoWithMessages, + queryHistoryMessages, +} from '../_common/chat-actions-handle'; +import { SmallHintIcon } from '../_common/icons'; +import { AIChatErrorRenderer } from '../messages/error'; +import { AIProvider } from '../provider'; +import { PeekViewStyles } from './styles'; +import type { ChatContext } from './types'; + +@customElement('ai-chat-block-peek-view') +export class AIChatBlockPeekView extends LitElement { + static override styles = PeekViewStyles; + + private get _rootService() { + return this.host.spec.getService('affine:page'); + } + + private get _modeService() { + return this._rootService.docModeService; + } + + private get parentSessionId() { + return this.parentModel.sessionId; + } + + private get historyMessagesString() { + return this.parentModel.messages; + } + + private get parentXYWH() { + return this.parentModel.xywh; + } + + private get parentChatBlockId() { + return this.parentModel.id; + } + + private readonly _deserializeHistoryChatMessages = ( + historyMessagesString: string + ) => { + try { + const result = ChatMessagesSchema.safeParse( + JSON.parse(historyMessagesString) + ); + if (result.success) { + return result.data; + } else { + return []; + } + } catch { + return []; + } + }; + + private readonly _constructBranchChatBlockMessages = async ( + doc: Doc, + forkSessionId: string + ) => { + const currentUserInfo = await AIProvider.userInfo; + const forkMessages = await queryHistoryMessages(doc, forkSessionId); + const forkLength = forkMessages.length; + const historyLength = this._historyMessages.length; + + if (!forkLength || forkLength <= historyLength) { + return constructUserInfoWithMessages(forkMessages, currentUserInfo); + } + + // Update history messages with the fork messages, keep user info + const historyMessages = this._historyMessages.map((message, idx) => { + return { + ...message, + id: forkMessages[idx]?.id ?? message.id, + attachments: [], + }; + }); + + const currentChatMessages = constructUserInfoWithMessages( + forkMessages.slice(historyLength), + currentUserInfo + ); + return [...historyMessages, ...currentChatMessages]; + }; + + private readonly _resetContext = () => { + const { abortController } = this.chatContext; + if (abortController) { + abortController.abort(); + } + + this.updateContext({ + status: 'idle', + error: null, + images: [], + abortController: null, + messages: [], + currentSessionId: null, + currentChatBlockId: null, + }); + }; + + /** + * Create a new AI chat block based on the current session and history messages + */ + createAIChatBlock = async () => { + // Only create AI chat block in edgeless mode + const mode = this._modeService.getMode(); + if (mode !== 'edgeless') { + return; + } + + // If there is already a chat block, do not create a new one + if (this.chatContext.currentChatBlockId) { + return; + } + + // If there is no session id or chat messages, do not create a new chat block + if ( + !this.chatContext.currentSessionId || + !this.chatContext.messages.length + ) { + return; + } + + const { doc } = this.host; + // create a new AI chat block + const surfaceBlock = doc + .getBlocks() + .find(block => block.flavour === 'affine:surface'); + if (!surfaceBlock) { + return; + } + + const parentXYWH = Bound.deserialize(this.parentXYWH); + const { + x: parentX, + y: parentY, + w: parentWidth, + h: parentHeight, + } = parentXYWH; + + // Add AI chat block to the center of the viewport + // TODO: optimize the position of the AI chat block + const gap = parentWidth; + const x = parentX + parentWidth + gap; + const y = parentY; + const bound = new Bound(x, y, parentWidth, parentHeight); + + // Get fork session messages + const messages = await this._constructBranchChatBlockMessages( + doc, + this.chatContext.currentSessionId + ); + if (!messages.length) { + return; + } + + const aiChatBlockId = doc.addBlock( + 'affine:embed-ai-chat' as keyof BlockSuite.BlockModels, + { + xywh: bound.serialize(), + messages: JSON.stringify(messages), + sessionId: this.chatContext.currentSessionId, + }, + surfaceBlock.id + ); + + if (!aiChatBlockId) { + return; + } + + this.updateContext({ currentChatBlockId: aiChatBlockId }); + + // Connect the parent chat block to the AI chat block + const edgelessService = this._rootService as EdgelessRootService; + edgelessService.addElement(CanvasElementType.CONNECTOR, { + mode: ConnectorMode.Curve, + controllers: [], + source: { id: this.parentChatBlockId }, + target: { id: aiChatBlockId }, + }); + }; + + /** + * Update the current chat messages with the new message + */ + updateChatBlockMessages = async () => { + if ( + !this.chatContext.currentChatBlockId || + !this.chatContext.currentSessionId + ) { + return; + } + + const { doc } = this.host; + const chatBlock = doc.getBlock(this.chatContext.currentChatBlockId); + + // Get fork session messages + const messages = await this._constructBranchChatBlockMessages( + doc, + this.chatContext.currentSessionId + ); + if (!messages.length) { + return; + } + doc.updateBlock(chatBlock.model, { + messages: JSON.stringify(messages), + }); + }; + + updateContext = (context: Partial) => { + this.chatContext = { ...this.chatContext, ...context }; + }; + + /** + * Clean current chat messages and delete the newly created AI chat block + */ + cleanCurrentChatHistories = async () => { + const { notificationService } = this._rootService; + if (!notificationService) { + return; + } + + const { currentChatBlockId, currentSessionId } = this.chatContext; + if (!currentChatBlockId && !currentSessionId) { + return; + } + + if ( + await notificationService.confirm({ + title: 'Clear History', + message: + 'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.', + confirmText: 'Confirm', + cancelText: 'Cancel', + }) + ) { + const { doc } = this.host; + if (currentSessionId) { + await AIProvider.histories?.cleanup(doc.collection.id, doc.id, [ + currentSessionId, + ]); + } + + if (currentChatBlockId) { + const edgelessService = this._rootService as EdgelessRootService; + const chatBlock = doc.getBlock(currentChatBlockId).model; + if (chatBlock) { + const connectors = edgelessService.getConnectors( + chatBlock as AIChatBlockModel + ); + doc.transact(() => { + // Delete the AI chat block + edgelessService.removeElement(currentChatBlockId); + // Delete the connectors + connectors.forEach(connector => { + edgelessService.removeElement(connector.id); + }); + }); + } + } + + notificationService.toast('History cleared'); + this._resetContext(); + } + }; + + /** + * Retry the last chat message + */ + retry = async () => { + const { doc } = this.host; + const { currentChatBlockId, currentSessionId } = this.chatContext; + if (!currentChatBlockId || !currentSessionId) { + return; + } + + let content = ''; + try { + const abortController = new AbortController(); + + const messages = [...this.chatContext.messages]; + const last = messages[messages.length - 1]; + if ('content' in last) { + last.content = ''; + last.createdAt = new Date().toISOString(); + } + this.updateContext({ messages, status: 'loading', error: null }); + + const stream = AIProvider.actions.chat?.({ + sessionId: currentSessionId, + retry: true, + docId: doc.id, + workspaceId: doc.collection.id, + host: this.host, + stream: true, + signal: abortController.signal, + where: 'ai-chat-block', + control: 'chat-send', + }); + + if (stream) { + this.updateContext({ abortController }); + for await (const text of stream) { + const messages = [...this.chatContext.messages]; + const last = messages[messages.length - 1] as ChatMessage; + last.content += text; + this.updateContext({ messages, status: 'transmitting' }); + content += text; + } + + this.updateContext({ status: 'success' }); + } + } catch (error) { + this.updateContext({ status: 'error', error: error as AIError }); + } finally { + this.updateContext({ abortController: null }); + if (content) { + // Update new chat block messages if there are contents returned from AI + await this.updateChatBlockMessages(); + } + } + }; + + CurrentMessages = (currentMessages: ChatMessage[]) => { + if (!currentMessages.length) { + return nothing; + } + + const { host } = this; + const actions = ChatBlockPeekViewActions; + + return html`${repeat( + currentMessages, + message => message.createdAt + message.content, + (message, idx) => { + const { status, error } = this.chatContext; + const isAssistantMessage = message.role === 'assistant'; + const isLastReply = + idx === currentMessages.length - 1 && isAssistantMessage; + const messageState = + isLastReply && status === 'transmitting' ? '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, + }); + + return html`
+ + ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} + ${shouldRenderCopyMore + ? html` this.retry()} + >` + : nothing} + ${shouldRenderActions + ? html`` + : nothing} +
`; + } + )}`; + }; + + override connectedCallback() { + super.connectedCallback(); + this._historyMessages = this._deserializeHistoryChatMessages( + this.historyMessagesString + ); + queryHistoryMessages(this.host.doc, this.parentSessionId) + .then(messages => { + this._historyMessages = this._historyMessages.map((message, idx) => { + return { + ...message, + attachments: messages[idx]?.attachments ?? [], + }; + }); + }) + .catch((err: Error) => { + console.error('Query history messages failed', err); + }); + } + + override firstUpdated() { + // first time render, scroll ai-chat-messages-container to bottom + requestAnimationFrame(() => { + if (this._chatMessagesContainer) { + this._chatMessagesContainer.scrollTop = + this._chatMessagesContainer.scrollHeight; + } + }); + } + + override render() { + const { host, _historyMessages } = this; + if (!_historyMessages.length) { + return nothing; + } + + const latestHistoryMessage = _historyMessages[_historyMessages.length - 1]; + const latestMessageCreatedAt = latestHistoryMessage.createdAt; + const latestHistoryMessageId = latestHistoryMessage.id; + const { + parentSessionId, + updateChatBlockMessages, + createAIChatBlock, + cleanCurrentChatHistories, + chatContext, + updateContext, + } = this; + + const { messages: currentChatMessages } = chatContext; + + return html`
+
+ + +
+ ${this.CurrentMessages(currentChatMessages)} +
+
+ + +
`; + } + + @query('.ai-chat-messages-container') + accessor _chatMessagesContainer!: HTMLDivElement; + + @property({ attribute: false }) + accessor parentModel!: AIChatBlockModel; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @state() + accessor _historyMessages: ChatMessage[] = []; + + @state() + accessor chatContext: ChatContext = { + status: 'idle', + error: null, + images: [], + abortController: null, + messages: [], + currentSessionId: null, + currentChatBlockId: null, + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-chat-block-peek-view': AIChatBlockPeekView; + } +} + +export const AIChatBlockPeekViewTemplate = ( + parentModel: AIChatBlockModel, + host: EditorHost +) => { + return html``; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/peek-view/date-time.ts b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/date-time.ts new file mode 100644 index 0000000000..914e471335 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/date-time.ts @@ -0,0 +1,59 @@ +import { i18nTime } from '@affine/i18n'; +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('date-time') +export class DateTime extends LitElement { + static override styles = css` + :host { + width: 100%; + } + .date-time-container { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + } + .line { + flex-grow: 1; + height: 0.5px; + background-color: var(--affine-border-color); + } + .date-time { + padding: 0 8px; + font-size: var(--affine-font-xs); + font-weight: 400; + line-height: 22px; + text-align: center; + color: var(--affine-text-secondary-color); + } + `; + + override render() { + const date = i18nTime(this.date, { + relative: { + max: [1, 'day'], + accuracy: 'minute', + weekday: true, + }, + absolute: { + accuracy: 'second', + }, + }); + + return html`
+
+
${date}
+
+
`; + } + + @property({ attribute: false }) + accessor date!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'date-time': DateTime; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/peek-view/styles.ts b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/styles.ts new file mode 100644 index 0000000000..7de306f0bd --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/styles.ts @@ -0,0 +1,73 @@ +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +export const PeekViewStyles = css` + :host { + width: 100%; + height: 100%; + } + + .ai-chat-block-peek-view-container { + gap: 8px; + width: 100%; + height: 100%; + display: flex; + align-items: center; + box-sizing: border-box; + justify-content: start; + flex-direction: column; + box-sizing: border-box; + padding: 24px 120px 16px 120px; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + + .ai-chat-messages-container { + display: flex; + align-items: center; + justify-content: start; + flex-direction: column; + box-sizing: border-box; + width: 100%; + color: var(--affine-text-primary-color); + line-height: 22px; + font-size: var(--affine-font-sm); + overflow-y: auto; + overflow-x: hidden; + flex: 1; + gap: 24px; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .new-chat-messages-container { + width: 100%; + box-sizing: border-box; + min-height: 450px; + display: flex; + flex-direction: column; + gap: 24px; + } + + .ai-chat-messages-container::-webkit-scrollbar { + display: none; + } + + .assistant-message-container { + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + } + + .peek-view-footer { + padding: 0 12px; + width: 100%; + height: 20px; + display: flex; + gap: 4px; + align-items: center; + color: var(--affine-text-secondary-color); + font-size: var(--affine-font-xs); + } +`; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/peek-view/types.ts b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/types.ts new file mode 100644 index 0000000000..cd8b9d7fca --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/peek-view/types.ts @@ -0,0 +1,19 @@ +import type { AIError } from '@blocksuite/blocks'; +import { type ChatMessage } from '@blocksuite/presets'; + +export type ChatStatus = + | 'success' + | 'error' + | 'idle' + | 'transmitting' + | 'loading'; + +export type ChatContext = { + messages: ChatMessage[]; + status: ChatStatus; + error: AIError | null; + images: File[]; + abortController: AbortController | null; + currentSessionId: string | null; + currentChatBlockId: string | null; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/provider.ts b/packages/frontend/core/src/blocksuite/presets/ai/provider.ts index b3846455d9..6135dce200 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/provider.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/provider.ts @@ -78,6 +78,8 @@ export class AIProvider { static LAST_ACTION_SESSIONID = ''; + static LAST_ROOT_SESSION_ID = ''; + static MAX_LOCAL_HISTORY = 10; private readonly actions: Partial = {}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts index 13912cccaa..d57fa226ca 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts @@ -59,9 +59,11 @@ export const insert = async ( ); const insertIndex = below ? index + 1 : index; + const { doc } = host; const models = await insertFromMarkdown( host, content, + doc, blockParent.model.id, insertIndex ); @@ -110,9 +112,11 @@ export const replace = async ( host.doc.deleteBlock(model); }); + const { doc } = host; const models = await insertFromMarkdown( host, content, + doc, firstBlockParent.model.id, firstIndex ); diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts index bf519c20cc..5189f5cea4 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts @@ -148,6 +148,7 @@ export const markdownToSnapshot = async ( export async function insertFromMarkdown( host: EditorHost, markdown: string, + doc: Doc, parent?: string, index?: number ) { @@ -160,7 +161,7 @@ export async function insertFromMarkdown( const blockSnapshot = snapshots[i]; const model = await job.snapshotToBlock( blockSnapshot, - host.std.doc, + doc, parent, (index ?? 0) + i ); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts index 01b12520e9..85a39059e4 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts @@ -24,6 +24,7 @@ export type TextToTextOptions = { signal?: AbortSignal; retry?: boolean; workflow?: boolean; + isRootSession?: boolean; postfix?: (text: string) => string; }; @@ -151,6 +152,7 @@ export function textToText({ timeout = TIMEOUT, retry = false, workflow = false, + isRootSession = false, postfix, }: TextToTextOptions) { let _sessionId: string; @@ -188,6 +190,9 @@ export function textToText({ workflow ? 'workflow' : undefined ); AIProvider.LAST_ACTION_SESSIONID = _sessionId; + if (isRootSession) { + AIProvider.LAST_ROOT_SESSION_ID = _sessionId; + } if (signal) { if (signal.aborted) { @@ -250,6 +255,10 @@ export function textToText({ } AIProvider.LAST_ACTION_SESSIONID = _sessionId; + if (isRootSession) { + AIProvider.LAST_ROOT_SESSION_ID = _sessionId; + } + return client.chatText({ sessionId: _sessionId, messageId: _messageId, diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx index 02d0512f75..c551c19ab3 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx @@ -77,7 +77,8 @@ function setupAIProvider() { //#region actions AIProvider.provide('chat', options => { - const sessionId = getChatSessionId(options.workspaceId, options.docId); + const sessionId = + options.sessionId ?? getChatSessionId(options.workspaceId, options.docId); return textToText({ ...options, content: options.input, diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts index 7de1157511..cf4857b681 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts @@ -18,13 +18,15 @@ type AIActionEventProperties = { | 'AI action panel' | 'right side bar' | 'inline chat panel' - | 'AI result panel'; + | 'AI result panel' + | 'AI chat block'; module: | 'exit confirmation' | 'AI action panel' | 'AI chat panel' | 'inline chat panel' - | 'AI result panel'; + | 'AI result panel' + | 'AI chat block'; control: | 'stop button' | 'format toolbar' @@ -139,6 +141,8 @@ function inferSegment( return 'AI result panel'; } else if (event.options.where === 'chat-panel') { return 'right side bar'; + } else if (event.options.where === 'ai-chat-block') { + return 'AI chat block'; } else { return 'AI action panel'; } @@ -155,6 +159,8 @@ function inferModule( return 'AI result panel'; } else if (event.options.where === 'inline-chat-panel') { return 'inline chat panel'; + } else if (event.options.where === 'ai-chat-block') { + return 'AI chat block'; } else { return 'AI action panel'; } diff --git a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts index f69aa83118..a8c6d9cbae 100644 --- a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts +++ b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts @@ -1,4 +1,4 @@ -import type { BlockComponent } from '@blocksuite/block-std'; +import type { BlockComponent, EditorHost } from '@blocksuite/block-std'; import { AffineReference, type EmbedLinkedDocModel, @@ -7,6 +7,7 @@ import { type SurfaceRefBlockComponent, type SurfaceRefBlockModel, } from '@blocksuite/blocks'; +import type { AIChatBlockModel } from '@blocksuite/presets'; import type { BlockModel } from '@blocksuite/store'; import { type DocMode, Entity, LiveData } from '@toeverything/infra'; import type { TemplateResult } from 'lit'; @@ -36,6 +37,13 @@ export type ImagePeekViewInfo = { blockId: string; }; +export type AIChatBlockPeekViewInfo = { + type: 'ai-chat-block'; + docId: string; + host: EditorHost; + model: AIChatBlockModel; +}; + export type CustomTemplatePeekViewInfo = { type: 'template'; template: TemplateResult; @@ -43,7 +51,11 @@ export type CustomTemplatePeekViewInfo = { export type ActivePeekView = { target: PeekViewTarget; - info: DocPeekViewInfo | ImagePeekViewInfo | CustomTemplatePeekViewInfo; + info: + | DocPeekViewInfo + | ImagePeekViewInfo + | CustomTemplatePeekViewInfo + | AIChatBlockPeekViewInfo; }; const EMBED_DOC_FLAVOURS = [ @@ -69,6 +81,12 @@ const isSurfaceRefModel = ( return blockModel.flavour === 'affine:surface-ref'; }; +const isAIChatBlockModel = ( + blockModel: BlockModel +): blockModel is AIChatBlockModel => { + return blockModel.flavour === 'affine:embed-ai-chat'; +}; + function resolvePeekInfoFromPeekTarget( peekTarget: PeekViewTarget, template?: TemplateResult @@ -113,6 +131,13 @@ function resolvePeekInfoFromPeekTarget( docId: blockModel.doc.id, blockId: blockModel.id, }; + } else if (isAIChatBlockModel(blockModel)) { + return { + type: 'ai-chat-block', + docId: blockModel.doc.id, + model: blockModel, + host: peekTarget.host, + }; } } else if (peekTarget instanceof HTMLAnchorElement) { const maybeDoc = resolveLinkToDoc(peekTarget.href); diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index 64e96cfa40..d9c3690890 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -1,4 +1,5 @@ import { toReactNode } from '@affine/component'; +import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/presets/ai'; import { BlockComponent } from '@blocksuite/block-std'; import { useLiveData, useService } from '@toeverything/infra'; import { useEffect, useMemo } from 'react'; @@ -35,6 +36,11 @@ function renderPeekView({ info }: ActivePeekView) { return ; } + if (info.type === 'ai-chat-block') { + const template = AIChatBlockPeekViewTemplate(info.model, info.host); + return toReactNode(template); + } + return null; // unreachable }