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`