diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index 47eaaee45f..d21d6738fc 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -16,6 +16,7 @@ "@affine/templates": "workspace:*", "@blocksuite/blocks": "0.16.0-canary-202407230727-128fc57", "@blocksuite/global": "0.16.0-canary-202407230727-128fc57", + "@blocksuite/presets": "0.16.0-canary-202407230727-128fc57", "@blocksuite/store": "0.16.0-canary-202407230727-128fc57", "@datastructures-js/binary-search-tree": "^5.3.2", "foxact": "^0.2.33", diff --git a/packages/common/infra/src/modules/workspace/global-schema.ts b/packages/common/infra/src/modules/workspace/global-schema.ts index 151acafcbd..49efef45c5 100644 --- a/packages/common/infra/src/modules/workspace/global-schema.ts +++ b/packages/common/infra/src/modules/workspace/global-schema.ts @@ -1,6 +1,8 @@ import { AffineSchemas } from '@blocksuite/blocks/schemas'; +import { AIChatBlockSchema } from '@blocksuite/presets'; import { Schema } from '@blocksuite/store'; export const globalBlockSuiteSchema = new Schema(); -globalBlockSuiteSchema.register(AffineSchemas); +const schemas = [...AffineSchemas, AIChatBlockSchema]; +globalBlockSuiteSchema.register(schemas); diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts index 9ce9666965..7d8af84865 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts @@ -1178,3 +1178,17 @@ export const CommentIcon = html` `; + +export const BlockIcon = html` + +`; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts index 39aa16920e..854aec6cc2 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts @@ -15,7 +15,6 @@ import { EDGELESS_TEXT_BLOCK_MIN_HEIGHT, EDGELESS_TEXT_BLOCK_MIN_WIDTH, EdgelessTextBlockModel, - EmbedHtmlBlockSpec, fitContent, ImageBlockModel, InsertBelowIcon, @@ -463,7 +462,7 @@ export const responses: { host.doc.transact(() => { edgelessRoot.doc.addBlock( - EmbedHtmlBlockSpec.schema.model.flavour as 'affine:embed-html', + 'affine:embed-html', { html, design: 'ai:makeItReal', // as tag diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts index 500e260833..7d7d9f5eb6 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts @@ -1,3 +1,4 @@ +import type { getCopilotHistoriesQuery, RequestOptions } from '@affine/graphql'; import type { EditorHost } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; @@ -74,6 +75,13 @@ declare global { where: TrackerWhere; } + interface AIForkChatSessionOptions { + docId: string; + workspaceId: string; + sessionId: string; + latestMessageId: string; + } + interface AIImageActionOptions extends AITextActionOptions { content?: string; seed?: string; @@ -222,12 +230,22 @@ declare global { action: string; createdAt: string; messages: { + id: string; // message id content: string; createdAt: string; - role: 'user' | 'assistant'; + role: MessageRole; }[]; } + type MessageRole = 'user' | 'assistant'; + + type AIHistoryIds = Pick & { + messages: Pick< + AIHistory['messages'][number], + 'id' | 'createdAt' | 'role' + >[]; + }; + interface AIHistoryService { // non chat histories actions: ( @@ -236,13 +254,23 @@ declare global { ) => Promise; chats: ( workspaceId: string, - docId?: string + docId?: string, + options?: RequestOptions< + typeof getCopilotHistoriesQuery + >['variables']['options'] ) => Promise; cleanup: ( workspaceId: string, docId: string, sessionIds: string[] ) => Promise; + ids: ( + workspaceId: string, + docId?: string, + options?: RequestOptions< + typeof getCopilotHistoriesQuery + >['variables']['options'] + ) => Promise; } interface AIPhotoEngineService { diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts index b03b5c9602..871fceeb2b 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts @@ -1,9 +1,15 @@ +import { ChatHistoryOrder } from '@affine/graphql'; import type { BlockSelection, EditorHost, TextSelection, } from '@blocksuite/block-std'; -import type { EdgelessRootService, ImageSelection } from '@blocksuite/blocks'; +import type { + DocMode, + EdgelessRootService, + ImageSelection, + PageRootService, +} from '@blocksuite/blocks'; import { BlocksUtils, getElementsBound, @@ -11,25 +17,158 @@ import { } from '@blocksuite/blocks'; import type { SerializedXYWH } from '@blocksuite/global/utils'; import { Bound } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; -import { CreateIcon, InsertBelowIcon, ReplaceIcon } from '../../_common/icons'; +import { + BlockIcon, + CreateIcon, + InsertBelowIcon, + ReplaceIcon, +} from '../../_common/icons'; +import { AIProvider } from '../../provider'; import { reportResponse } from '../../utils/action-reporter'; import { insertBelow, replace } from '../../utils/editor-actions'; import { insertFromMarkdown } from '../../utils/markdown-utils'; +import type { ChatBlockMessage, ChatContextValue } from '../chat-context'; const { matchFlavours } = BlocksUtils; -const CommonActions = [ +type Selections = { + text?: TextSelection; + blocks?: BlockSelection[]; + images?: ImageSelection[]; +}; + +type ChatAction = { + icon: TemplateResult<1>; + title: string; + toast: string; + showWhen: (host: EditorHost) => boolean; + handler: ( + host: EditorHost, + content: string, + currentSelections: Selections, + chatContext?: ChatContextValue, + messageId?: string + ) => Promise; +}; + +async function constructChatBlockMessages(doc: Doc, forkSessionId: string) { + const userInfo = await AIProvider.userInfo; + // 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 []; + } + + const messages = histories[0].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, + }; + }); + return messages; +} + +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: ChatBlockMessage[], + 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; +} + +const CommonActions: ChatAction[] = [ { icon: ReplaceIcon, title: 'Replace selection', + showWhen: () => true, toast: 'Successfully replaced', handler: async ( host: EditorHost, content: string, - currentTextSelection?: TextSelection, - currentBlockSelections?: BlockSelection[] + currentSelections: Selections ) => { + const currentTextSelection = currentSelections.text; + const currentBlockSelections = currentSelections.blocks; const [_, data] = host.command .chain() .getSelectedBlocks({ @@ -67,14 +206,16 @@ const CommonActions = [ { icon: InsertBelowIcon, title: 'Insert below', + showWhen: () => true, toast: 'Successfully inserted', handler: async ( host: EditorHost, content: string, - currentTextSelection?: TextSelection, - currentBlockSelections?: BlockSelection[], - currentImageSelections?: ImageSelection[] + currentSelections: Selections ) => { + const currentTextSelection = currentSelections.text; + const currentBlockSelections = currentSelections.blocks; + const currentImageSelections = currentSelections.images; const [_, data] = host.command .chain() .getSelectedBlocks({ @@ -95,11 +236,90 @@ const CommonActions = [ }, ]; +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, + _, + __, + chatContext?: ChatContextValue, + messageId?: string + ) => { + // The chat session id and the latest message id are required to fork the chat session + const parentSessionId = chatContext?.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; + } + + // Construct chat block messages from the forked chat session + const messages = await constructChatBlockMessages(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; + } + }, +}; + export const PageEditorActions = [ ...CommonActions, { icon: CreateIcon, title: 'Create as a doc', + showWhen: () => true, toast: 'New doc created', handler: (host: EditorHost, content: string) => { reportResponse('result:add-page'); @@ -128,6 +348,7 @@ export const PageEditorActions = [ return true; }, }, + SAVE_CHAT_TO_BLOCK_ACTION, ]; export const EdgelessEditorActions = [ @@ -135,6 +356,7 @@ export const EdgelessEditorActions = [ { icon: CreateIcon, title: 'Add to edgeless as note', + showWhen: () => true, toast: 'New note created', handler: async (host: EditorHost, content: string) => { reportResponse('result:add-note'); @@ -166,4 +388,5 @@ export const EdgelessEditorActions = [ return true; }, }, + SAVE_CHAT_TO_BLOCK_ACTION, ]; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts index e874b9de7c..25d2e456b0 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts @@ -91,6 +91,9 @@ export class ChatCopyMore extends WithDisposable(LitElement) { @property({ attribute: false }) accessor content!: string; + @property({ attribute: false }) + accessor messageId!: string; + @property({ attribute: false }) accessor isLast!: boolean; @@ -182,7 +185,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) { } override render() { - const { host, content, isLast } = this; + const { host, content, isLast, messageId } = this; return html`