feat(core): add an independent AI panel (#13004)

close AI-246, AI-285
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Introduced an AI chat interface accessible from the sidebar with a
dedicated "/chat" route.
* Added "AFFiNE Intelligent" button with AI icon to the sidebar for
quick chat access.
* Enhanced chat components with an "independent mode" for improved
message display and layout.
* Improved chat input and content styling, including responsive layout
and onboarding offset support.

* **Improvements**
  * Expanded icon support to include an AI icon in the app.
* Updated utility and schema functions for greater flexibility and error
prevention.
* Added a new chat container style for consistent layout and max width.

* **Bug Fixes**
* Prevented potential errors when certain editor hosts are not provided.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-04 10:10:35 +08:00
committed by GitHub
parent e6b456330c
commit 64fb3a7243
12 changed files with 298 additions and 21 deletions

View File

@@ -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}
></chat-panel-chips>
<ai-chat-input
.independentMode=${this.independentMode}
.host=${this.host}
.workspaceId=${this.workspaceId}
.docId=${this.docId}

View File

@@ -6,11 +6,20 @@ import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import { type Signal } from '@preact/signals-core';
import { css, html, type PropertyValues, type TemplateResult } from 'lit';
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { throttle } from 'lodash-es';
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
import { extractSelectedContent } from '../../utils/extract';
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
@@ -44,6 +53,7 @@ export class AIChatContent extends SignalWatcher(
ai-chat-content {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
.ai-chat-title {
@@ -67,10 +77,25 @@ export class AIChatContent extends SignalWatcher(
ai-chat-messages {
flex: 1;
overflow-y: hidden;
transition:
flex-grow 0.32s cubic-bezier(0.07, 0.83, 0.46, 1),
padding-top 0.32s ease,
padding-bottom 0.32s ease;
}
ai-chat-messages.independent-mode.no-message {
flex-grow: 0;
flex-shrink: 0;
overflow-y: visible;
}
}
`;
@property({ attribute: false })
accessor independentMode!: boolean;
@property({ attribute: false })
accessor onboardingOffsetY!: number;
@property({ attribute: false })
accessor chatTitle: TemplateResult<1> | 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` <div class="ai-chat-title">${this.chatTitle}</div>
return html`${this.chatTitle
? html`<div class="ai-chat-title">${this.chatTitle}</div>`
: nothing}
<ai-chat-messages
class=${classMap({
'ai-chat-messages': true,
'independent-mode': this.independentMode,
'no-message': this.messages.length === 0,
})}
${ref(this.chatMessagesRef)}
.host=${this.host}
.workspaceId=${this.workspaceId}
@@ -326,8 +369,15 @@ export class AIChatContent extends SignalWatcher(
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.width=${this.width}
.independentMode=${this.independentMode}
.messages=${this.messages}
></ai-chat-messages>
<ai-chat-composer
style=${styleMap({
[this.onboardingOffsetY > 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}

View File

@@ -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` <div
return html`<div
class="chat-panel-input"
data-independent-mode=${this.independentMode}
data-if-focused=${this.focused}
style=${styleMap({
maxHeight: `${maxHeight}px !important`,

View File

@@ -11,11 +11,11 @@ import { ArrowDownBigIcon as ArrowDownIcon } from '@blocksuite/icons/lit';
import type { Signal } from '@preact/signals-core';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { debounce } from 'lodash-es';
import { AffineIcon } from '../../_common/icons';
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
import { AIPreloadConfig } from '../../chat-panel/preload-config';
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
import { mergeStreamObjects } from '../../utils/stream-objects';
@@ -24,7 +24,12 @@ import type {
AINetworkSearchConfig,
AIReasoningConfig,
} from '../ai-chat-input';
import { isChatAction, isChatMessage, StreamObjectSchema } from './type';
import {
type HistoryMessage,
isChatAction,
isChatMessage,
StreamObjectSchema,
} from './type';
export class AIChatMessages extends WithDisposable(ShadowlessElement) {
static override styles = css`
@@ -69,6 +74,10 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
align-items: center;
gap: 12px;
}
.independent-mode .messages-placeholder {
position: static;
transform: none;
}
.messages-placeholder-title {
font-size: 18px;
@@ -140,6 +149,12 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
@state()
accessor avatarUrl = '';
@property({ attribute: false })
accessor independentMode!: boolean;
@property({ attribute: false })
accessor messages!: HistoryMessage[];
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@@ -243,16 +258,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
};
protected override render() {
const { messages, status, error } = this.chatContextValue;
const { status, error } = this.chatContextValue;
const { isHistoryLoading } = this;
const filteredItems = messages.filter(item => {
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`
<div
class="chat-panel-messages-container"
class=${classMap({
'chat-panel-messages-container': true,
'independent-mode': this.independentMode,
})}
data-testid="chat-panel-messages-container"
@scroll=${() => this._debouncedOnScroll()}
>
@@ -287,7 +298,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
>What can I help you with?</span
>`}
</div>
${this._renderAIOnboarding()}
${this.independentMode ? nothing : this._renderAIOnboarding()}
</div> `
: repeat(
filteredItems,

View File

@@ -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 (
<MenuLinkItem icon={<AiIcon />} active={aiChatActive} to={'/chat'}>
<span data-testid="ai-chat">AFFiNE Intelligent</span>
</MenuLinkItem>
);
};
/**
* 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 => {
<AllDocsButton />
<AppSidebarJournalButton />
{sessionStatus === 'authenticated' && <NotificationButton />}
<AIChatButton />
<MenuItem
data-testid="slider-bar-workspace-setting-button"
icon={<SettingsIcon />}

View File

@@ -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,

View File

@@ -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',
});

View File

@@ -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<Doc | null>(null);
const [host, setHost] = useState<EditorHost | null>(null);
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const widthSignalRef = useRef<Signal<number>>(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 (
<>
<ViewTitle title={t['AFFiNE AI']()} />
<ViewIcon icon="ai" />
<ViewHeader></ViewHeader>
<ViewBody>
<div className={styles.chatRoot} ref={onChatContainerRef} />
</ViewBody>
</>
);
};

View File

@@ -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'),

View File

@@ -1,4 +1,5 @@
import {
AiIcon,
AllDocsIcon,
AttachmentIcon,
DeleteIcon,
@@ -22,6 +23,7 @@ export const iconNameToIcon = {
trash: <DeleteIcon />,
attachment: <AttachmentIcon />,
pdf: <ExportToPdfIcon />,
ai: <AiIcon />,
} satisfies Record<string, ReactNode>;
export type ViewIconName = keyof typeof iconNameToIcon;