diff --git a/blocksuite/affine/shared/src/utils/dom/checker.ts b/blocksuite/affine/shared/src/utils/dom/checker.ts index ec86e77b67..0e34c73a16 100644 --- a/blocksuite/affine/shared/src/utils/dom/checker.ts +++ b/blocksuite/affine/shared/src/utils/dom/checker.ts @@ -1,12 +1,13 @@ import type { EditorHost } from '@blocksuite/std'; -export function isInsidePageEditor(host: EditorHost) { +export function isInsidePageEditor(host?: EditorHost) { + if (!host) return false; return Array.from(host.children).some( v => v.tagName.toLowerCase() === 'affine-page-root' ); } -export function isInsideEdgelessEditor(host: EditorHost) { +export function isInsideEdgelessEditor(host?: EditorHost) { if (!host) return false; return Array.from(host.children).some( diff --git a/packages/frontend/apps/electron/src/main/shared-state-schema.ts b/packages/frontend/apps/electron/src/main/shared-state-schema.ts index 9d2d3b4c66..922e909d71 100644 --- a/packages/frontend/apps/electron/src/main/shared-state-schema.ts +++ b/packages/frontend/apps/electron/src/main/shared-state-schema.ts @@ -11,6 +11,7 @@ export const workbenchViewIconNameSchema = z.enum([ 'journal', 'attachment', 'pdf', + 'ai', ]); export const workbenchViewMetaSchema = z.object({ diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts index d145f909fd..e8ecd2013b 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts @@ -50,6 +50,9 @@ export class AIChatComposer extends SignalWatcher( } `; + @property({ attribute: false }) + accessor independentMode!: boolean; + @property({ attribute: false }) accessor host: EditorHost | null | undefined; @@ -126,6 +129,7 @@ export class AIChatComposer extends SignalWatcher( .addImages=${this.addImages} > | undefined; @@ -134,6 +159,17 @@ export class AIChatContent extends SignalWatcher( private lastScrollTop: number | undefined; + get messages() { + return this.chatContextValue.messages.filter(item => { + return ( + isChatMessage(item) || + item.messages?.length === 3 || + (HISTORY_IMAGE_ACTIONS.includes(item.action) && + item.messages?.length === 2) + ); + }); + } + private readonly updateHistory = async () => { const currentRequest = ++this.updateHistoryCounter; if (!AIProvider.histories) { @@ -310,8 +346,15 @@ export class AIChatContent extends SignalWatcher( } override render() { - return html`
${this.chatTitle}
+ return html`${this.chatTitle + ? html`
${this.chatTitle}
` + : nothing} 0 ? 'paddingTop' : 'paddingBottom']: + `${this.messages.length === 0 ? Math.abs(this.onboardingOffsetY) * 2 : 0}px`, + })} + .independentMode=${this.independentMode} .host=${this.host} .workspaceId=${this.workspaceId} .docId=${this.docId} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts index d9205ab19e..f89f08f7f5 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts @@ -80,6 +80,11 @@ export class AIChatInput extends SignalWatcher( transition: box-shadow 0.23s ease; background-color: var(--affine-v2-input-background); + &[data-independent-mode='true'] { + padding: 12px; + border-radius: 16px; + } + .chat-selection-quote { padding: 4px 0px 8px 0px; padding-left: 15px; @@ -280,6 +285,9 @@ export class AIChatInput extends SignalWatcher( } `; + @property({ attribute: false }) + accessor independentMode!: boolean; + @property({ attribute: false }) accessor host: EditorHost | null | undefined; @@ -385,8 +393,9 @@ export class AIChatInput extends SignalWatcher( const hasImages = images.length > 0; const maxHeight = hasImages ? 272 + 2 : 200 + 2; - return html`
{ - return ( - isChatMessage(item) || - item.messages?.length === 3 || - (HISTORY_IMAGE_ACTIONS.includes(item.action) && - item.messages?.length === 2) - ); - }); + const filteredItems = this.messages; const showDownIndicator = this.canScrollDown && @@ -261,7 +269,10 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) { return html`
this._debouncedOnScroll()} > @@ -287,7 +298,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) { >What can I help you with?`}
- ${this._renderAIOnboarding()} + ${this.independentMode ? nothing : this._renderAIOnboarding()}
` : repeat( filteredItems, diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 4217715107..12331390d9 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -18,6 +18,7 @@ import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import type { Store } from '@blocksuite/affine/store'; import { + AiIcon, AllDocsIcon, ImportIcon, JournalIcon, @@ -86,6 +87,22 @@ const AllDocsButton = () => { ); }; +const AIChatButton = () => { + const { workbenchService } = useServices({ + WorkbenchService, + }); + const workbench = workbenchService.workbench; + const aiChatActive = useLiveData( + workbench.location$.selector(location => location.pathname === '/chat') + ); + + return ( + } active={aiChatActive} to={'/chat'}> + AFFiNE Intelligent + + ); +}; + /** * This is for the whole affine app sidebar. * This component wraps the app sidebar in `@affine/component` with logic and data. @@ -184,6 +201,7 @@ export const RootAppSidebar = memo((): ReactElement => { {sessionStatus === 'authenticated' && } + } diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/docs/index.ts b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/docs/index.ts index 916b4f5268..e8e6ed9ad9 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/docs/index.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/docs/index.ts @@ -3,9 +3,9 @@ import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace'; import type { DocSnapshot, Store } from '@blocksuite/affine/store'; import { Transformer } from '@blocksuite/affine/store'; import { Doc as YDoc } from 'yjs'; -const getCollection = (() => { +export const getCollection = (() => { let collection: WorkspaceImpl | null = null; - return async function () { + return function () { if (collection) { return collection; } @@ -85,7 +85,7 @@ export async function getDocByName(name: DocName) { async function initDoc(name: DocName) { const snapshot = (await loaders[name]()) as DocSnapshot; - const collection = await getCollection(); + const collection = getCollection(); const transformer = new Transformer({ schema: getAFFiNEWorkspaceSchema(), blobCRUD: collection.blobSync, diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts b/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts new file mode 100644 index 0000000000..86ef17d144 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts @@ -0,0 +1,9 @@ +import { style } from '@vanilla-extract/css'; + +export const chatRoot = style({ + width: '100%', + height: '100%', + maxWidth: 800, + padding: '0px 16px', + margin: '0 auto', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx new file mode 100644 index 0000000000..d1d3dbafbe --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx @@ -0,0 +1,168 @@ +import { observeResize } from '@affine/component'; +import { CopilotClient } from '@affine/core/blocksuite/ai'; +import { AIChatContent } from '@affine/core/blocksuite/ai/components/ai-chat-content'; +import { getCustomPageEditorBlockSpecs } from '@affine/core/blocksuite/ai/components/text-renderer'; +import type { PromptKey } from '@affine/core/blocksuite/ai/provider/prompt'; +import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config'; +import { getCollection } from '@affine/core/desktop/dialogs/setting/general-setting/editor/edgeless/docs'; +import { + EventSourceService, + FetchService, + GraphQLService, +} from '@affine/core/modules/cloud'; +import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; +import { FeatureFlagService } from '@affine/core/modules/feature-flag'; +import { + ViewBody, + ViewHeader, + ViewIcon, + ViewTitle, +} from '@affine/core/modules/workbench'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { useI18n } from '@affine/i18n'; +import type { Doc, Store } from '@blocksuite/affine/store'; +import { BlockStdScope, type EditorHost } from '@blocksuite/std'; +import { type Signal, signal } from '@preact/signals-core'; +import { useFramework, useService } from '@toeverything/infra'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import * as styles from './index.css'; + +function useCopilotClient() { + const graphqlService = useService(GraphQLService); + const eventSourceService = useService(EventSourceService); + const fetchService = useService(FetchService); + + return useMemo( + () => + new CopilotClient( + graphqlService.gql, + fetchService.fetch, + eventSourceService.eventSource + ), + [graphqlService, eventSourceService, fetchService] + ); +} + +export const Component = () => { + const t = useI18n(); + const framework = useFramework(); + const [isBodyProvided, setIsBodyProvided] = useState(false); + const [doc, setDoc] = useState(null); + const [host, setHost] = useState(null); + const [chatContent, setChatContent] = useState(null); + const chatContainerRef = useRef(null); + const widthSignalRef = useRef>(signal(0)); + const client = useCopilotClient(); + + const workspaceId = useService(WorkspaceService).workspace.id; + + const { + docDisplayConfig, + searchMenuConfig, + networkSearchConfig, + reasoningConfig, + } = useAIChatConfig(); + + // create a temp doc/host for ai-chat-content + useEffect(() => { + let tempDoc: Doc | null = null; + const collection = getCollection(); + const doc = collection.createDoc(); + tempDoc = doc; + doc.load(() => { + const host = new BlockStdScope({ + store: tempDoc?.getStore() as Store, + extensions: getCustomPageEditorBlockSpecs(), + }).render(); + setDoc(doc); + setHost(host); + }); + + return () => { + tempDoc?.dispose(); + }; + }, []); + + // init or update ai-chat-content + useEffect(() => { + if (!isBodyProvided || !host || !doc) { + return; + } + + let content = chatContent; + + if (!content) { + content = new AIChatContent(); + } + content.host = host; + content.workspaceId = workspaceId; + content.docDisplayConfig = docDisplayConfig; + content.searchMenuConfig = searchMenuConfig; + content.networkSearchConfig = networkSearchConfig; + content.reasoningConfig = reasoningConfig; + content.affineFeatureFlagService = framework.get(FeatureFlagService); + content.affineWorkspaceDialogService = framework.get( + WorkspaceDialogService + ); + + if (!chatContent) { + // initial values that won't change + const createSession = async () => { + const sessionId = await client.createSession({ + workspaceId, + docId: doc.id, + promptName: 'Chat With AFFiNE AI' satisfies PromptKey, + }); + + const session = await client.getSession(workspaceId, sessionId); + return session; + }; + + content.createSession = createSession; + content.independentMode = true; + content.onboardingOffsetY = -100; + chatContainerRef.current?.append(content); + setChatContent(content); + } + }, [ + chatContent, + client, + doc, + docDisplayConfig, + framework, + host, + isBodyProvided, + networkSearchConfig, + reasoningConfig, + searchMenuConfig, + workspaceId, + ]); + + const onChatContainerRef = useCallback((node: HTMLDivElement) => { + if (node) { + setIsBodyProvided(true); + chatContainerRef.current = node; + widthSignalRef.current.value = node.clientWidth; + } + }, []); + + // observe chat container width and provide to ai-chat-content + useEffect(() => { + if (!isBodyProvided || !chatContainerRef.current) return; + return observeResize(chatContainerRef.current, entry => { + widthSignalRef.current.value = entry.contentRect.width; + }); + }, [isBodyProvided]); + + return ( + <> + + + + +
+ + + ); +}; diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index f4f962374f..04d6a6adf6 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -1,6 +1,10 @@ import type { RouteObject } from 'react-router-dom'; export const workbenchRoutes = [ + { + path: '/chat', + lazy: () => import('./pages/workspace/chat/index'), + }, { path: '/all', lazy: () => import('./pages/workspace/all-page/all-page'), diff --git a/packages/frontend/core/src/modules/workbench/constants.tsx b/packages/frontend/core/src/modules/workbench/constants.tsx index 7439354f5e..99b3e53a6f 100644 --- a/packages/frontend/core/src/modules/workbench/constants.tsx +++ b/packages/frontend/core/src/modules/workbench/constants.tsx @@ -1,4 +1,5 @@ import { + AiIcon, AllDocsIcon, AttachmentIcon, DeleteIcon, @@ -22,6 +23,7 @@ export const iconNameToIcon = { trash: , attachment: , pdf: , + ai: , } satisfies Record; export type ViewIconName = keyof typeof iconNameToIcon;