mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 07:17:00 +08:00
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user