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:
@@ -1,12 +1,13 @@
|
|||||||
import type { EditorHost } from '@blocksuite/std';
|
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(
|
return Array.from(host.children).some(
|
||||||
v => v.tagName.toLowerCase() === 'affine-page-root'
|
v => v.tagName.toLowerCase() === 'affine-page-root'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInsideEdgelessEditor(host: EditorHost) {
|
export function isInsideEdgelessEditor(host?: EditorHost) {
|
||||||
if (!host) return false;
|
if (!host) return false;
|
||||||
|
|
||||||
return Array.from(host.children).some(
|
return Array.from(host.children).some(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const workbenchViewIconNameSchema = z.enum([
|
|||||||
'journal',
|
'journal',
|
||||||
'attachment',
|
'attachment',
|
||||||
'pdf',
|
'pdf',
|
||||||
|
'ai',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const workbenchViewMetaSchema = z.object({
|
export const workbenchViewMetaSchema = z.object({
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor independentMode!: boolean;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor host: EditorHost | null | undefined;
|
accessor host: EditorHost | null | undefined;
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
.addImages=${this.addImages}
|
.addImages=${this.addImages}
|
||||||
></chat-panel-chips>
|
></chat-panel-chips>
|
||||||
<ai-chat-input
|
<ai-chat-input
|
||||||
|
.independentMode=${this.independentMode}
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
.docId=${this.docId}
|
.docId=${this.docId}
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import type { EditorHost } from '@blocksuite/affine/std';
|
|||||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||||
import { type Signal } from '@preact/signals-core';
|
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 { 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 { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
|
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
|
||||||
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
|
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
|
||||||
import { extractSelectedContent } from '../../utils/extract';
|
import { extractSelectedContent } from '../../utils/extract';
|
||||||
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
|
||||||
@@ -44,6 +53,7 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
ai-chat-content {
|
ai-chat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.ai-chat-title {
|
.ai-chat-title {
|
||||||
@@ -67,10 +77,25 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
ai-chat-messages {
|
ai-chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: hidden;
|
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 })
|
@property({ attribute: false })
|
||||||
accessor chatTitle: TemplateResult<1> | undefined;
|
accessor chatTitle: TemplateResult<1> | undefined;
|
||||||
|
|
||||||
@@ -134,6 +159,17 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
|
|
||||||
private lastScrollTop: number | undefined;
|
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 () => {
|
private readonly updateHistory = async () => {
|
||||||
const currentRequest = ++this.updateHistoryCounter;
|
const currentRequest = ++this.updateHistoryCounter;
|
||||||
if (!AIProvider.histories) {
|
if (!AIProvider.histories) {
|
||||||
@@ -310,8 +346,15 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
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
|
<ai-chat-messages
|
||||||
|
class=${classMap({
|
||||||
|
'ai-chat-messages': true,
|
||||||
|
'independent-mode': this.independentMode,
|
||||||
|
'no-message': this.messages.length === 0,
|
||||||
|
})}
|
||||||
${ref(this.chatMessagesRef)}
|
${ref(this.chatMessagesRef)}
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
@@ -326,8 +369,15 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
.networkSearchConfig=${this.networkSearchConfig}
|
.networkSearchConfig=${this.networkSearchConfig}
|
||||||
.reasoningConfig=${this.reasoningConfig}
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
.width=${this.width}
|
.width=${this.width}
|
||||||
|
.independentMode=${this.independentMode}
|
||||||
|
.messages=${this.messages}
|
||||||
></ai-chat-messages>
|
></ai-chat-messages>
|
||||||
<ai-chat-composer
|
<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}
|
.host=${this.host}
|
||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
.docId=${this.docId}
|
.docId=${this.docId}
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
transition: box-shadow 0.23s ease;
|
transition: box-shadow 0.23s ease;
|
||||||
background-color: var(--affine-v2-input-background);
|
background-color: var(--affine-v2-input-background);
|
||||||
|
|
||||||
|
&[data-independent-mode='true'] {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-selection-quote {
|
.chat-selection-quote {
|
||||||
padding: 4px 0px 8px 0px;
|
padding: 4px 0px 8px 0px;
|
||||||
padding-left: 15px;
|
padding-left: 15px;
|
||||||
@@ -280,6 +285,9 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor independentMode!: boolean;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor host: EditorHost | null | undefined;
|
accessor host: EditorHost | null | undefined;
|
||||||
|
|
||||||
@@ -385,8 +393,9 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
|
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
|
||||||
|
|
||||||
return html` <div
|
return html`<div
|
||||||
class="chat-panel-input"
|
class="chat-panel-input"
|
||||||
|
data-independent-mode=${this.independentMode}
|
||||||
data-if-focused=${this.focused}
|
data-if-focused=${this.focused}
|
||||||
style=${styleMap({
|
style=${styleMap({
|
||||||
maxHeight: `${maxHeight}px !important`,
|
maxHeight: `${maxHeight}px !important`,
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import { ArrowDownBigIcon as ArrowDownIcon } from '@blocksuite/icons/lit';
|
|||||||
import type { Signal } from '@preact/signals-core';
|
import type { Signal } from '@preact/signals-core';
|
||||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||||
import { property, query, state } from 'lit/decorators.js';
|
import { property, query, state } from 'lit/decorators.js';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
import { AffineIcon } from '../../_common/icons';
|
import { AffineIcon } from '../../_common/icons';
|
||||||
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
|
|
||||||
import { AIPreloadConfig } from '../../chat-panel/preload-config';
|
import { AIPreloadConfig } from '../../chat-panel/preload-config';
|
||||||
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
|
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
|
||||||
import { mergeStreamObjects } from '../../utils/stream-objects';
|
import { mergeStreamObjects } from '../../utils/stream-objects';
|
||||||
@@ -24,7 +24,12 @@ import type {
|
|||||||
AINetworkSearchConfig,
|
AINetworkSearchConfig,
|
||||||
AIReasoningConfig,
|
AIReasoningConfig,
|
||||||
} from '../ai-chat-input';
|
} from '../ai-chat-input';
|
||||||
import { isChatAction, isChatMessage, StreamObjectSchema } from './type';
|
import {
|
||||||
|
type HistoryMessage,
|
||||||
|
isChatAction,
|
||||||
|
isChatMessage,
|
||||||
|
StreamObjectSchema,
|
||||||
|
} from './type';
|
||||||
|
|
||||||
export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -69,6 +74,10 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.independent-mode .messages-placeholder {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.messages-placeholder-title {
|
.messages-placeholder-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -140,6 +149,12 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
@state()
|
@state()
|
||||||
accessor avatarUrl = '';
|
accessor avatarUrl = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor independentMode!: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor messages!: HistoryMessage[];
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor host: EditorHost | null | undefined;
|
accessor host: EditorHost | null | undefined;
|
||||||
|
|
||||||
@@ -243,16 +258,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
protected override render() {
|
protected override render() {
|
||||||
const { messages, status, error } = this.chatContextValue;
|
const { status, error } = this.chatContextValue;
|
||||||
const { isHistoryLoading } = this;
|
const { isHistoryLoading } = this;
|
||||||
const filteredItems = messages.filter(item => {
|
const filteredItems = this.messages;
|
||||||
return (
|
|
||||||
isChatMessage(item) ||
|
|
||||||
item.messages?.length === 3 ||
|
|
||||||
(HISTORY_IMAGE_ACTIONS.includes(item.action) &&
|
|
||||||
item.messages?.length === 2)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const showDownIndicator =
|
const showDownIndicator =
|
||||||
this.canScrollDown &&
|
this.canScrollDown &&
|
||||||
@@ -261,7 +269,10 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="chat-panel-messages-container"
|
class=${classMap({
|
||||||
|
'chat-panel-messages-container': true,
|
||||||
|
'independent-mode': this.independentMode,
|
||||||
|
})}
|
||||||
data-testid="chat-panel-messages-container"
|
data-testid="chat-panel-messages-container"
|
||||||
@scroll=${() => this._debouncedOnScroll()}
|
@scroll=${() => this._debouncedOnScroll()}
|
||||||
>
|
>
|
||||||
@@ -287,7 +298,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
>What can I help you with?</span
|
>What can I help you with?</span
|
||||||
>`}
|
>`}
|
||||||
</div>
|
</div>
|
||||||
${this._renderAIOnboarding()}
|
${this.independentMode ? nothing : this._renderAIOnboarding()}
|
||||||
</div> `
|
</div> `
|
||||||
: repeat(
|
: repeat(
|
||||||
filteredItems,
|
filteredItems,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useI18n } from '@affine/i18n';
|
|||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import type { Store } from '@blocksuite/affine/store';
|
import type { Store } from '@blocksuite/affine/store';
|
||||||
import {
|
import {
|
||||||
|
AiIcon,
|
||||||
AllDocsIcon,
|
AllDocsIcon,
|
||||||
ImportIcon,
|
ImportIcon,
|
||||||
JournalIcon,
|
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 is for the whole affine app sidebar.
|
||||||
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
||||||
@@ -184,6 +201,7 @@ export const RootAppSidebar = memo((): ReactElement => {
|
|||||||
<AllDocsButton />
|
<AllDocsButton />
|
||||||
<AppSidebarJournalButton />
|
<AppSidebarJournalButton />
|
||||||
{sessionStatus === 'authenticated' && <NotificationButton />}
|
{sessionStatus === 'authenticated' && <NotificationButton />}
|
||||||
|
<AIChatButton />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
data-testid="slider-bar-workspace-setting-button"
|
data-testid="slider-bar-workspace-setting-button"
|
||||||
icon={<SettingsIcon />}
|
icon={<SettingsIcon />}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
|
|||||||
import type { DocSnapshot, Store } from '@blocksuite/affine/store';
|
import type { DocSnapshot, Store } from '@blocksuite/affine/store';
|
||||||
import { Transformer } from '@blocksuite/affine/store';
|
import { Transformer } from '@blocksuite/affine/store';
|
||||||
import { Doc as YDoc } from 'yjs';
|
import { Doc as YDoc } from 'yjs';
|
||||||
const getCollection = (() => {
|
export const getCollection = (() => {
|
||||||
let collection: WorkspaceImpl | null = null;
|
let collection: WorkspaceImpl | null = null;
|
||||||
return async function () {
|
return function () {
|
||||||
if (collection) {
|
if (collection) {
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ export async function getDocByName(name: DocName) {
|
|||||||
|
|
||||||
async function initDoc(name: DocName) {
|
async function initDoc(name: DocName) {
|
||||||
const snapshot = (await loaders[name]()) as DocSnapshot;
|
const snapshot = (await loaders[name]()) as DocSnapshot;
|
||||||
const collection = await getCollection();
|
const collection = getCollection();
|
||||||
const transformer = new Transformer({
|
const transformer = new Transformer({
|
||||||
schema: getAFFiNEWorkspaceSchema(),
|
schema: getAFFiNEWorkspaceSchema(),
|
||||||
blobCRUD: collection.blobSync,
|
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';
|
import type { RouteObject } from 'react-router-dom';
|
||||||
|
|
||||||
export const workbenchRoutes = [
|
export const workbenchRoutes = [
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
lazy: () => import('./pages/workspace/chat/index'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/all',
|
path: '/all',
|
||||||
lazy: () => import('./pages/workspace/all-page/all-page'),
|
lazy: () => import('./pages/workspace/all-page/all-page'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AiIcon,
|
||||||
AllDocsIcon,
|
AllDocsIcon,
|
||||||
AttachmentIcon,
|
AttachmentIcon,
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
@@ -22,6 +23,7 @@ export const iconNameToIcon = {
|
|||||||
trash: <DeleteIcon />,
|
trash: <DeleteIcon />,
|
||||||
attachment: <AttachmentIcon />,
|
attachment: <AttachmentIcon />,
|
||||||
pdf: <ExportToPdfIcon />,
|
pdf: <ExportToPdfIcon />,
|
||||||
|
ai: <AiIcon />,
|
||||||
} satisfies Record<string, ReactNode>;
|
} satisfies Record<string, ReactNode>;
|
||||||
|
|
||||||
export type ViewIconName = keyof typeof iconNameToIcon;
|
export type ViewIconName = keyof typeof iconNameToIcon;
|
||||||
|
|||||||
Reference in New Issue
Block a user