feat: cleanup chat panel (#14259)

#### PR Dependency Tree


* **PR #14258**
  * **PR #14259** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **Refactor**
* Split AI initialization into separate editor, app, and shared
registries; removed legacy chat-panel and replaced it with a
component-based editor chat, updating wiring and public exports.
* Propagated server/subscription/model services into chat/playground
components and improved session lifecycle and UI composition.

* **Tests**
* Added tests for AI effect registration and chat session resolution;
extended DOM/test utilities and assertions.

* **Chores**
  * Added happy-dom for runtime and test environments.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-01-26 04:02:07 +08:00
committed by GitHub
parent 02449026b9
commit 09cc2dceda
35 changed files with 1408 additions and 1059 deletions

View File

@@ -109,6 +109,7 @@
"@types/semver": "^7",
"@vanilla-extract/css": "^1.17.0",
"fake-indexeddb": "^6.0.0",
"happy-dom": "^20.3.0",
"lodash-es": "^4.17.21",
"vitest": "^3.2.4"
}

View File

@@ -0,0 +1,30 @@
import {
appEffectElementTags,
editorEffectElementTags,
sharedEffectElementTags,
} from '@affine/core/blocksuite/ai/effects/registry';
import { describe, expect, test } from 'vitest';
describe('ai effects registration split', () => {
const editorTags = new Set<string>([
...editorEffectElementTags,
...sharedEffectElementTags,
]);
const appTags = new Set<string>([
...appEffectElementTags,
...sharedEffectElementTags,
]);
test('registerAIEditorEffects skips app-only elements', () => {
expect(editorTags.has('affine-ai-chat')).toBe(true);
expect(editorTags.has('chat-panel')).toBe(false);
expect(editorTags.has('text-renderer')).toBe(true);
});
test('registerAIAppEffects skips editor-only elements', () => {
expect(appTags.has('ai-chat-content')).toBe(true);
expect(appTags.has('chat-panel')).toBe(false);
expect(appTags.has('affine-ai-chat')).toBe(false);
expect(appTags.has('text-renderer')).toBe(true);
});
});

View File

@@ -1,14 +1,23 @@
/**
* @vitest-environment happy-dom
*/
import '@blocksuite/affine-shared/test-utils';
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
import { createAffineTemplate } from '@blocksuite/affine-shared/test-utils';
import type { Store } from '@blocksuite/store';
import { describe, expect, it } from 'vitest';
import { applyPatchToDoc } from '../../../../blocksuite/ai/utils/apply-model/apply-patch-to-doc';
import type { PatchOp } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
declare module 'vitest' {
interface Assertion<T = any> {
toEqualDoc(expected: Store, options?: { compareId?: boolean }): T;
}
}
const manager = new StoreExtensionManager(getInternalStoreExtensions());
const { affine } = createAffineTemplate(manager.get('store'));

View File

@@ -26,7 +26,7 @@ import { property, state } from 'lit/decorators.js';
import { type ChatAction } from '../../components/ai-chat-messages';
import { createTextRenderer } from '../../components/text-renderer';
import { HISTORY_IMAGE_ACTIONS } from '../const';
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
const icons: Record<string, TemplateResult<1>> = {
'Fix spelling for it': DoneIcon(),

View File

@@ -1,195 +0,0 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { ServerService } from '@affine/core/modules/cloud';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { type NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType, Store } from '@blocksuite/affine/store';
import { CenterPeekIcon } from '@blocksuite/icons/lit';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { SearchMenuConfig } from '../components/ai-chat-add-context';
import type { DocDisplayConfig } from '../components/ai-chat-chips';
import type {
AIPlaygroundConfig,
AIReasoningConfig,
} from '../components/ai-chat-input';
import type { ChatStatus } from '../components/ai-chat-messages';
import { createPlaygroundModal } from '../components/playground/modal';
import type { AppSidebarConfig } from './chat-config';
export class AIChatPanelTitle extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.ai-chat-panel-title {
background: var(--affine-background-primary-color);
position: relative;
padding: 8px var(--h-padding, 16px);
width: 100%;
height: 36px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1;
svg {
width: 18px;
height: 18px;
color: var(--affine-text-secondary-color);
}
.chat-panel-title-text {
font-size: 14px;
font-weight: 500;
color: var(--affine-text-secondary-color);
}
.chat-panel-playground {
cursor: pointer;
padding: 2px;
margin-left: 8px;
margin-right: auto;
display: flex;
justify-content: center;
align-items: center;
}
.chat-panel-playground:hover svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
}
`;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor playgroundConfig!: AIPlaygroundConfig;
@property({ attribute: false })
accessor appSidebarConfig!: AppSidebarConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor serverService!: ServerService;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor affineThemeService!: AppThemeService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor session!: CopilotChatHistoryFragment | null | undefined;
@property({ attribute: false })
accessor status!: ChatStatus;
@property({ attribute: false })
accessor embeddingProgress: [number, number] = [0, 0];
@property({ attribute: false })
accessor newSession!: () => void;
@property({ attribute: false })
accessor togglePin!: () => void;
@property({ attribute: false })
accessor openSession!: (sessionId: string) => void;
@property({ attribute: false })
accessor openDoc!: (docId: string, sessionId: string) => void;
@property({ attribute: false })
accessor deleteSession!: (session: BlockSuitePresets.AIRecentSession) => void;
private readonly openPlayground = () => {
const playgroundContent = html`
<playground-content
.host=${this.host}
.doc=${this.doc}
.reasoningConfig=${this.reasoningConfig}
.playgroundConfig=${this.playgroundConfig}
.appSidebarConfig=${this.appSidebarConfig}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.extensions=${this.extensions}
.serverService=${this.serverService}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.aiToolsConfigService=${this.aiToolsConfigService}
></playground-content>
`;
createPlaygroundModal(playgroundContent, 'AI Playground');
};
override render() {
const [done, total] = this.embeddingProgress;
const isEmbedding = total > 0 && done < total;
return html`
<div class="ai-chat-panel-title">
<div class="chat-panel-title-text">
${isEmbedding
? html`<span data-testid="chat-panel-embedding-progress"
>Embedding ${done}/${total}</span
>`
: 'AFFiNE AI'}
</div>
${this.playgroundConfig.visible.value
? html`
<div class="chat-panel-playground" @click=${this.openPlayground}>
${CenterPeekIcon()}
</div>
`
: nothing}
<ai-chat-toolbar
.session=${this.session}
.workspaceId=${this.doc.workspace.id}
.docId=${this.doc.id}
.status=${this.status}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
.onOpenDoc=${this.openDoc}
.onSessionDelete=${this.deleteSession}
.docDisplayConfig=${this.docDisplayConfig}
.notificationService=${this.notificationService}
></ai-chat-toolbar>
</div>
`;
}
}

View File

@@ -1,485 +0,0 @@
import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type {
ServerService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { PeekViewService } from '@affine/core/modules/peek-view';
import type { AppThemeService } from '@affine/core/modules/theme';
import type { WorkbenchService } from '@affine/core/modules/workbench';
import type {
ContextEmbedStatus,
CopilotChatHistoryFragment,
UpdateChatSessionInput,
} from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { type NotificationService } from '@blocksuite/affine/shared/services';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType, Store } from '@blocksuite/affine/store';
import { type Signal, signal } from '@preact/signals-core';
import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { AffineIcon } from '../_common/icons';
import type { SearchMenuConfig } from '../components/ai-chat-add-context';
import type { DocDisplayConfig } from '../components/ai-chat-chips';
import type { ChatContextValue } from '../components/ai-chat-content';
import type {
AIPlaygroundConfig,
AIReasoningConfig,
} from '../components/ai-chat-input';
import type { ChatStatus } from '../components/ai-chat-messages';
import { AIProvider } from '../provider';
import type { AppSidebarConfig } from './chat-config';
export class ChatPanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
chat-panel {
width: 100%;
user-select: text;
.chat-panel-container {
height: 100%;
display: flex;
flex-direction: column;
}
ai-chat-content {
height: 0;
flex-grow: 1;
}
.chat-loading-container {
position: relative;
padding: 44px 0 166px 0;
height: 100%;
display: flex;
align-items: center;
}
.chat-loading {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.chat-loading-title {
font-weight: 600;
font-size: var(--affine-font-sm);
color: var(--affine-text-secondary-color);
}
}
`;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor playgroundConfig!: AIPlaygroundConfig;
@property({ attribute: false })
accessor appSidebarConfig!: AppSidebarConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor serverService!: ServerService;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor affineWorkbenchService!: WorkbenchService;
@property({ attribute: false })
accessor affineThemeService!: AppThemeService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor peekViewService!: PeekViewService;
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor session: CopilotChatHistoryFragment | null | undefined;
@state()
accessor embeddingProgress: [number, number] = [0, 0];
@state()
accessor status: ChatStatus = 'idle';
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = signal(undefined);
private hasPinned = false;
private get isInitialized() {
return this.session !== undefined;
}
private readonly getSessionIdFromUrl = () => {
if (this.affineWorkbenchService) {
const { workbench } = this.affineWorkbenchService;
const location = workbench.location$.value;
const searchParams = new URLSearchParams(location.search);
const sessionId = searchParams.get('sessionId');
if (sessionId) {
workbench.activeView$.value.updateQueryString(
{ sessionId: undefined },
{ replace: true }
);
}
return sessionId;
}
return undefined;
};
private readonly setSession = (
session: CopilotChatHistoryFragment | null | undefined
) => {
this.session = session ?? null;
};
private readonly initSession = async () => {
if (!AIProvider.session) {
return;
}
const sessionId = this.getSessionIdFromUrl();
const pinSessions = await AIProvider.session.getSessions(
this.doc.workspace.id,
undefined,
{ pinned: true, limit: 1 }
);
if (Array.isArray(pinSessions) && pinSessions[0]) {
// pinned session
this.session = pinSessions[0];
} else if (sessionId) {
// sessionId from url
const session = await AIProvider.session.getSession(
this.doc.workspace.id,
sessionId
);
this.setSession(session);
} else {
// latest doc session
const docSessions = await AIProvider.session.getSessions(
this.doc.workspace.id,
this.doc.id,
{ action: false, fork: false, limit: 1 }
);
// sessions is descending ordered by updatedAt
// the first item is the latest session
const session = docSessions?.[0];
this.setSession(session);
}
};
private readonly createSession = async (
options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}
) => {
if (this.session) {
return this.session;
}
const sessionId = await AIProvider.session?.createSession({
docId: this.doc.id,
workspaceId: this.doc.workspace.id,
promptName: 'Chat With AFFiNE AI',
reuseLatestChat: false,
...options,
});
if (sessionId) {
const session = await AIProvider.session?.getSession(
this.doc.workspace.id,
sessionId
);
this.setSession(session);
}
return this.session;
};
private readonly deleteSession = async (
session: BlockSuitePresets.AIRecentSession
) => {
if (!AIProvider.histories) {
return;
}
const confirm = await this.notificationService.confirm({
title: 'Delete this history?',
message:
'Do you want to delete this AI conversation history? Once deleted, it cannot be recovered.',
confirmText: 'Delete',
cancelText: 'Cancel',
});
if (confirm) {
await AIProvider.histories.cleanup(
session.workspaceId,
session.docId || undefined,
[session.sessionId]
);
if (session.sessionId === this.session?.sessionId) {
this.newSession();
}
}
};
private readonly updateSession = async (options: UpdateChatSessionInput) => {
await AIProvider.session?.updateSession(options);
const session = await AIProvider.session?.getSession(
this.doc.workspace.id,
options.sessionId
);
this.setSession(session);
};
private readonly newSession = () => {
this.resetPanel();
requestAnimationFrame(() => {
this.session = null;
});
};
private readonly openSession = async (sessionId: string) => {
if (this.session?.sessionId === sessionId) {
return;
}
this.resetPanel();
const session = await AIProvider.session?.getSession(
this.doc.workspace.id,
sessionId
);
this.setSession(session);
};
private readonly openDoc = async (docId: string, sessionId: string) => {
if (this.doc.id === docId) {
if (this.session?.sessionId === sessionId || this.session?.pinned) {
return;
}
await this.openSession(sessionId);
} else if (this.affineWorkbenchService) {
const { workbench } = this.affineWorkbenchService;
if (this.session?.pinned) {
workbench.open(`/${docId}`, { at: 'active' });
} else {
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
}
}
};
private readonly togglePin = async () => {
const pinned = !this.session?.pinned;
this.hasPinned = true;
if (!this.session) {
await this.createSession({ pinned });
} else {
await this.updateSession({
sessionId: this.session.sessionId,
pinned,
});
}
};
private readonly rebindSession = async () => {
if (!this.session) {
return;
}
if (this.session.docId !== this.doc.id) {
await this.updateSession({
sessionId: this.session.sessionId,
docId: this.doc.id,
});
}
};
private readonly initPanel = async () => {
try {
if (!this.isSidebarOpen.value) {
return;
}
await this.initSession();
this.hasPinned = !!this.session?.pinned;
} catch (error) {
console.error(error);
}
};
private readonly resetPanel = () => {
this.session = undefined;
this.embeddingProgress = [0, 0];
this.hasPinned = false;
};
private readonly onEmbeddingProgressChange = (
count: Record<ContextEmbedStatus, number>
) => {
const total = count.finished + count.processing + count.failed;
this.embeddingProgress = [count.finished, total];
};
private readonly onContextChange = async (
context: Partial<ChatContextValue>
) => {
this.status = context.status ?? 'idle';
if (context.status === 'success') {
await this.rebindSession();
}
};
protected override updated(changedProperties: PropertyValues) {
if (changedProperties.has('doc')) {
if (this.session?.pinned) {
return;
}
this.resetPanel();
this.initPanel().catch(console.error);
}
}
override connectedCallback() {
super.connectedCallback();
if (!this.doc) throw new Error('doc is required');
this._disposables.add(
AIProvider.slots.userInfo.subscribe(() => {
this.resetPanel();
this.initPanel().catch(console.error);
})
);
const isOpen = this.appSidebarConfig.isOpen();
this.isSidebarOpen = isOpen.signal;
this._disposables.add(isOpen.cleanup);
const width = this.appSidebarConfig.getWidth();
this.sidebarWidth = width.signal;
this._disposables.add(width.cleanup);
this._disposables.add(
this.isSidebarOpen.subscribe(isOpen => {
if (isOpen && !this.isInitialized) {
this.initPanel().catch(console.error);
}
})
);
}
override render() {
if (!this.isInitialized) {
return html`<div class="chat-loading-container">
<div class="chat-loading">
${AffineIcon('var(--affine-icon-secondary)')}
<div class="chat-loading-title">
<span> AFFiNE AI is loading history... </span>
</div>
</div>
</div>`;
}
return html`<div class="chat-panel-container">
<ai-chat-panel-title
.host=${this.host}
.doc=${this.doc}
.playgroundConfig=${this.playgroundConfig}
.appSidebarConfig=${this.appSidebarConfig}
.reasoningConfig=${this.reasoningConfig}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.extensions=${this.extensions}
.serverService=${this.serverService}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.session=${this.session}
.status=${this.status}
.embeddingProgress=${this.embeddingProgress}
.newSession=${this.newSession}
.togglePin=${this.togglePin}
.openSession=${this.openSession}
.openDoc=${this.openDoc}
.deleteSession=${this.deleteSession}
></ai-chat-panel-title>
${keyed(
this.hasPinned ? this.session?.sessionId : this.doc.id,
html`<ai-chat-content
.host=${this.host}
.session=${this.session}
.createSession=${this.createSession}
.workspaceId=${this.doc.workspace.id}
.docId=${this.doc.id}
.reasoningConfig=${this.reasoningConfig}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.extensions=${this.extensions}
.serverService=${this.serverService}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService}
.peekViewService=${this.peekViewService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth}
.onOpenDoc=${this.openDoc}
></ai-chat-content>`
)}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'chat-panel': ChatPanel;
}
}

View File

@@ -5,7 +5,7 @@ import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { type ChatAction } from '../../components/ai-chat-messages';
import { HISTORY_IMAGE_ACTIONS } from '../const';
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
export class ChatMessageAction extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })

View File

@@ -28,9 +28,9 @@ import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { pick } 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 { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type { AIReasoningConfig } from '../ai-chat-input';

View File

@@ -19,12 +19,12 @@ import { repeat } from 'lit/directives/repeat.js';
import { debounce } from 'lodash-es';
import { AffineIcon } from '../../_common/icons';
import { AIPreloadConfig } from '../../chat-panel/preload-config';
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
import { mergeStreamObjects } from '../../utils/stream-objects';
import type { DocDisplayConfig } from '../ai-chat-chips';
import { type ChatContextValue } from '../ai-chat-content/type';
import type { AIReasoningConfig } from '../ai-chat-input';
import { AIPreloadConfig } from './preload-config';
import {
type HistoryMessage,
isChatAction,

View File

@@ -6,7 +6,7 @@ import {
SendIcon,
} from '@blocksuite/icons/lit';
import { AIProvider } from '../provider/ai-provider.js';
import { AIProvider } from '../../provider/ai-provider.js';
import completeWritingWithAI from './templates/completeWritingWithAI.zip';
import freelyCommunicateWithAI from './templates/freelyCommunicateWithAI.zip';
import readAforeign from './templates/readAforeign.zip';

View File

@@ -1,5 +1,9 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { ServerService } from '@affine/core/modules/cloud';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type {
ServerService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
@@ -20,8 +24,8 @@ import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { throttle } from 'lodash-es';
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
import { AIProvider } from '../../provider';
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type { ChatContextValue } from '../ai-chat-content';
@@ -179,6 +183,12 @@ export class PlaygroundChat extends SignalWatcher(
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe: (() => Promise<void>) | undefined;
@@ -373,6 +383,8 @@ export class PlaygroundChat extends SignalWatcher(
.aiToolsConfigService=${this.aiToolsConfigService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
></ai-chat-composer>
</div>`;

View File

@@ -1,5 +1,10 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { ServerService } from '@affine/core/modules/cloud';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type {
ServerService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
@@ -93,6 +98,15 @@ export class PlaygroundContent extends SignalWatcher(
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@state()
accessor sessions: CopilotChatHistoryFragment[] = [];
@@ -351,6 +365,10 @@ export class PlaygroundContent extends SignalWatcher(
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.affineWorkspaceDialogService=${this
.affineWorkspaceDialogService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.addChat=${this.addChat}
></playground-chat>
</div>

View File

@@ -1,256 +0,0 @@
import { effects as tooltipEffects } from '@blocksuite/affine-components/tooltip';
import { AIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-block';
import { EdgelessAIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-edgeless-block';
import { LitTranscriptionBlock } from './blocks/ai-chat-block/ai-transcription-block';
import {
AIChatBlockMessage,
AIChatBlockMessages,
} from './blocks/ai-chat-block/components/ai-chat-messages';
import {
ChatImage,
ChatImages,
} from './blocks/ai-chat-block/components/chat-images';
import { ImagePlaceholder } from './blocks/ai-chat-block/components/image-placeholder';
import { UserInfo } from './blocks/ai-chat-block/components/user-info';
import { ChatPanel } from './chat-panel';
import { ActionWrapper } from './chat-panel/actions/action-wrapper';
import { ActionImage } from './chat-panel/actions/image';
import { ActionImageToText } from './chat-panel/actions/image-to-text';
import { ActionMakeReal } from './chat-panel/actions/make-real';
import { ActionMindmap } from './chat-panel/actions/mindmap';
import { ActionSlides } from './chat-panel/actions/slides';
import { ActionText } from './chat-panel/actions/text';
import { AILoading } from './chat-panel/ai-loading';
import { AIChatPanelTitle } from './chat-panel/ai-title';
import { ChatMessageAction } from './chat-panel/message/action';
import { ChatMessageAssistant } from './chat-panel/message/assistant';
import { ChatMessageUser } from './chat-panel/message/user';
import { ChatPanelSplitView } from './chat-panel/split-view';
import { ArtifactSkeleton } from './components/ai-artifact-skeleton';
import { AIChatAddContext } from './components/ai-chat-add-context';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
import { ChatPanelAttachmentChip } from './components/ai-chat-chips/attachment-chip';
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
import { ChatPanelChip } from './components/ai-chat-chips/chip';
import { ChatPanelCollectionChip } from './components/ai-chat-chips/collection-chip';
import { ChatPanelDocChip } from './components/ai-chat-chips/doc-chip';
import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip';
import { ChatPanelSelectedChip } from './components/ai-chat-chips/selected-chip';
import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip';
import { AIChatComposer } from './components/ai-chat-composer';
import { AIChatContent } from './components/ai-chat-content';
import { AIChatInput } from './components/ai-chat-input';
import { AIChatEmbeddingStatusTooltip } from './components/ai-chat-input/embedding-status-tooltip';
import { ChatInputPreference } from './components/ai-chat-input/preference-popup';
import { AIChatMessages } from './components/ai-chat-messages/ai-chat-messages';
import { AIChatToolbar, AISessionHistory } from './components/ai-chat-toolbar';
import { AIHistoryClear } from './components/ai-history-clear';
import { effects as componentAiItemEffects } from './components/ai-item';
import { AssistantAvatar } from './components/ai-message-content/assistant-avatar';
import { ChatContentImages } from './components/ai-message-content/images';
import { ChatContentPureText } from './components/ai-message-content/pure-text';
import { ChatContentRichText } from './components/ai-message-content/rich-text';
import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects';
import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
import { ArtifactPreviewPanel } from './components/ai-tools/artifacts-preview-panel';
import {
CodeArtifactTool,
CodeHighlighter,
} from './components/ai-tools/code-artifact';
import { DocComposeTool } from './components/ai-tools/doc-compose';
import { DocEditTool } from './components/ai-tools/doc-edit';
import { DocKeywordSearchResult } from './components/ai-tools/doc-keyword-search-result';
import { DocReadResult } from './components/ai-tools/doc-read-result';
import { DocSemanticSearchResult } from './components/ai-tools/doc-semantic-search-result';
import { DocWriteTool } from './components/ai-tools/doc-write';
import { SectionEditTool } from './components/ai-tools/section-edit';
import { ToolCallCard } from './components/ai-tools/tool-call-card';
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
import { ToolResultCard } from './components/ai-tools/tool-result-card';
import { WebCrawlTool } from './components/ai-tools/web-crawl';
import { WebSearchTool } from './components/ai-tools/web-search';
import { AskAIButton } from './components/ask-ai-button';
import { AskAIIcon } from './components/ask-ai-icon';
import { AskAIPanel } from './components/ask-ai-panel';
import { AskAIToolbarButton } from './components/ask-ai-toolbar';
import { ChatActionList } from './components/chat-action-list';
import { ChatCopyMore } from './components/copy-more';
import { ImagePreviewGrid } from './components/image-preview-grid';
import { effects as componentPlaygroundEffects } from './components/playground';
import { TextRenderer } from './components/text-renderer';
import { AIErrorWrapper } from './messages/error';
import { AISlidesRenderer } from './messages/slides-renderer';
import { AIAnswerWrapper } from './messages/wrapper';
import { registerMiniMindmapBlocks } from './mini-mindmap';
import { AIChatBlockPeekView } from './peek-view/chat-block-peek-view';
import { DateTime } from './peek-view/date-time';
import {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from './widgets/ai-panel/ai-panel';
import {
AIPanelAnswer,
AIPanelDivider,
AIPanelError,
AIPanelGenerating,
AIPanelInput,
} from './widgets/ai-panel/components';
import { AIFinishTip } from './widgets/ai-panel/components/finish-tip';
import { GeneratingPlaceholder } from './widgets/ai-panel/components/generating-placeholder';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock,
} from './widgets/block-diff/block';
import { BlockDiffOptions } from './widgets/block-diff/options';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage,
} from './widgets/block-diff/page';
import {
AFFINE_BLOCK_DIFF_PLAYGROUND,
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlayground,
BlockDiffPlaygroundModal,
} from './widgets/block-diff/playground';
import {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
} from './widgets/edgeless-copilot';
import { EdgelessCopilotPanel } from './widgets/edgeless-copilot-panel';
import { EdgelessCopilotToolbarEntry } from './widgets/edgeless-copilot-panel/toolbar-entry';
export function registerAIEffects() {
registerMiniMindmapBlocks();
componentAiItemEffects();
componentPlaygroundEffects();
tooltipEffects();
customElements.define('ask-ai-icon', AskAIIcon);
customElements.define('ask-ai-button', AskAIButton);
customElements.define('ask-ai-toolbar-button', AskAIToolbarButton);
customElements.define('ask-ai-panel', AskAIPanel);
customElements.define('chat-action-list', ChatActionList);
customElements.define('chat-copy-more', ChatCopyMore);
customElements.define('image-preview-grid', ImagePreviewGrid);
customElements.define('action-wrapper', ActionWrapper);
customElements.define('action-image-to-text', ActionImageToText);
customElements.define('action-image', ActionImage);
customElements.define('action-make-real', ActionMakeReal);
customElements.define('action-mindmap', ActionMindmap);
customElements.define('action-slides', ActionSlides);
customElements.define('action-text', ActionText);
customElements.define('ai-loading', AILoading);
customElements.define('ai-chat-content', AIChatContent);
customElements.define('ai-chat-toolbar', AIChatToolbar);
customElements.define('ai-session-history', AISessionHistory);
customElements.define('ai-chat-messages', AIChatMessages);
customElements.define('chat-panel', ChatPanel);
customElements.define('ai-chat-panel-title', AIChatPanelTitle);
customElements.define('ai-chat-input', AIChatInput);
customElements.define('ai-chat-add-context', AIChatAddContext);
customElements.define(
'ai-chat-embedding-status-tooltip',
AIChatEmbeddingStatusTooltip
);
customElements.define('ai-chat-composer', AIChatComposer);
customElements.define('chat-panel-chips', ChatPanelChips);
customElements.define('ai-history-clear', AIHistoryClear);
customElements.define('chat-panel-add-popover', ChatPanelAddPopover);
customElements.define('chat-input-preference', ChatInputPreference);
customElements.define(
'chat-panel-candidates-popover',
ChatPanelCandidatesPopover
);
customElements.define('chat-panel-doc-chip', ChatPanelDocChip);
customElements.define('chat-panel-file-chip', ChatPanelFileChip);
customElements.define('chat-panel-tag-chip', ChatPanelTagChip);
customElements.define('chat-panel-collection-chip', ChatPanelCollectionChip);
customElements.define('chat-panel-selected-chip', ChatPanelSelectedChip);
customElements.define('chat-panel-attachment-chip', ChatPanelAttachmentChip);
customElements.define('chat-panel-chip', ChatPanelChip);
customElements.define('ai-error-wrapper', AIErrorWrapper);
customElements.define('ai-slides-renderer', AISlidesRenderer);
customElements.define('ai-answer-wrapper', AIAnswerWrapper);
customElements.define('ai-chat-block-peek-view', AIChatBlockPeekView);
customElements.define('date-time', DateTime);
customElements.define(
'affine-edgeless-ai-chat',
EdgelessAIChatBlockComponent
);
customElements.define('affine-ai-chat', AIChatBlockComponent);
customElements.define('ai-chat-block-message', AIChatBlockMessage);
customElements.define('ai-chat-block-messages', AIChatBlockMessages);
customElements.define(
'ai-scrollable-text-renderer',
AIScrollableTextRenderer
);
customElements.define('image-placeholder', ImagePlaceholder);
customElements.define('chat-image', ChatImage);
customElements.define('chat-images', ChatImages);
customElements.define('user-info', UserInfo);
customElements.define('text-renderer', TextRenderer);
customElements.define('generating-placeholder', GeneratingPlaceholder);
customElements.define('ai-finish-tip', AIFinishTip);
customElements.define('ai-panel-divider', AIPanelDivider);
customElements.define('ai-panel-answer', AIPanelAnswer);
customElements.define('ai-panel-input', AIPanelInput);
customElements.define('ai-panel-generating', AIPanelGenerating);
customElements.define('ai-panel-error', AIPanelError);
customElements.define('chat-assistant-avatar', AssistantAvatar);
customElements.define('chat-content-images', ChatContentImages);
customElements.define('chat-content-pure-text', ChatContentPureText);
customElements.define('chat-content-rich-text', ChatContentRichText);
customElements.define(
'chat-content-stream-objects',
ChatContentStreamObjects
);
customElements.define('chat-message-action', ChatMessageAction);
customElements.define('chat-message-assistant', ChatMessageAssistant);
customElements.define('chat-message-user', ChatMessageUser);
customElements.define('ai-block-diff-options', BlockDiffOptions);
customElements.define(AFFINE_BLOCK_DIFF_PLAYGROUND, BlockDiffPlayground);
customElements.define(
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlaygroundModal
);
customElements.define('tool-call-card', ToolCallCard);
customElements.define('tool-result-card', ToolResultCard);
customElements.define('tool-call-failed', ToolFailedCard);
customElements.define('doc-semantic-search-result', DocSemanticSearchResult);
customElements.define('doc-keyword-search-result', DocKeywordSearchResult);
customElements.define('doc-read-result', DocReadResult);
customElements.define('doc-write-tool', DocWriteTool);
customElements.define('web-crawl-tool', WebCrawlTool);
customElements.define('web-search-tool', WebSearchTool);
customElements.define('section-edit-tool', SectionEditTool);
customElements.define('doc-compose-tool', DocComposeTool);
customElements.define('code-artifact-tool', CodeArtifactTool);
customElements.define('code-highlighter', CodeHighlighter);
customElements.define('artifact-preview-panel', ArtifactPreviewPanel);
customElements.define('doc-edit-tool', DocEditTool);
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
customElements.define(
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock
);
customElements.define(
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage
);
customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel);
customElements.define(
'edgeless-copilot-toolbar-entry',
EdgelessCopilotToolbarEntry
);
customElements.define('transcription-block', LitTranscriptionBlock);
customElements.define('chat-panel-split-view', ChatPanelSplitView);
customElements.define('artifact-skeleton', ArtifactSkeleton);
}

View File

@@ -0,0 +1,95 @@
import { ActionWrapper } from '../chat-panel/actions/action-wrapper';
import { ActionImage } from '../chat-panel/actions/image';
import { ActionImageToText } from '../chat-panel/actions/image-to-text';
import { ActionMakeReal } from '../chat-panel/actions/make-real';
import { ActionMindmap } from '../chat-panel/actions/mindmap';
import { ActionSlides } from '../chat-panel/actions/slides';
import { ActionText } from '../chat-panel/actions/text';
import { AILoading } from '../chat-panel/ai-loading';
import { ChatMessageAction } from '../chat-panel/message/action';
import { ChatMessageAssistant } from '../chat-panel/message/assistant';
import { ChatMessageUser } from '../chat-panel/message/user';
import { AIChatAddContext } from '../components/ai-chat-add-context';
import { ChatPanelAddPopover } from '../components/ai-chat-chips/add-popover';
import { ChatPanelAttachmentChip } from '../components/ai-chat-chips/attachment-chip';
import { ChatPanelCandidatesPopover } from '../components/ai-chat-chips/candidates-popover';
import { ChatPanelChips } from '../components/ai-chat-chips/chat-panel-chips';
import { ChatPanelChip } from '../components/ai-chat-chips/chip';
import { ChatPanelCollectionChip } from '../components/ai-chat-chips/collection-chip';
import { ChatPanelDocChip } from '../components/ai-chat-chips/doc-chip';
import { ChatPanelFileChip } from '../components/ai-chat-chips/file-chip';
import { ChatPanelSelectedChip } from '../components/ai-chat-chips/selected-chip';
import { ChatPanelTagChip } from '../components/ai-chat-chips/tag-chip';
import { AIChatComposer } from '../components/ai-chat-composer';
import { AIChatContent } from '../components/ai-chat-content';
import { ChatPanelSplitView } from '../components/ai-chat-content/split-view';
import { AIChatInput } from '../components/ai-chat-input';
import { AIChatEmbeddingStatusTooltip } from '../components/ai-chat-input/embedding-status-tooltip';
import { ChatInputPreference } from '../components/ai-chat-input/preference-popup';
import { AIChatMessages } from '../components/ai-chat-messages/ai-chat-messages';
import { AIChatToolbar, AISessionHistory } from '../components/ai-chat-toolbar';
import { AIHistoryClear } from '../components/ai-history-clear';
import { AssistantAvatar } from '../components/ai-message-content/assistant-avatar';
import { ChatActionList } from '../components/chat-action-list';
import { ChatCopyMore } from '../components/copy-more';
import { ImagePreviewGrid } from '../components/image-preview-grid';
import { effects as componentPlaygroundEffects } from '../components/playground';
import { AIChatBlockPeekView } from '../peek-view/chat-block-peek-view';
import { DateTime } from '../peek-view/date-time';
import { type AppEffectElementTag, appEffectElementTags } from './registry';
import { registerAISharedEffects } from './shared';
const appRegistries = new WeakSet<CustomElementRegistry>();
const appElements = {
'chat-action-list': ChatActionList,
'chat-copy-more': ChatCopyMore,
'image-preview-grid': ImagePreviewGrid,
'action-wrapper': ActionWrapper,
'action-image-to-text': ActionImageToText,
'action-image': ActionImage,
'action-make-real': ActionMakeReal,
'action-mindmap': ActionMindmap,
'action-slides': ActionSlides,
'action-text': ActionText,
'ai-loading': AILoading,
'ai-chat-content': AIChatContent,
'ai-chat-toolbar': AIChatToolbar,
'ai-session-history': AISessionHistory,
'ai-chat-messages': AIChatMessages,
'ai-chat-input': AIChatInput,
'ai-chat-add-context': AIChatAddContext,
'ai-chat-embedding-status-tooltip': AIChatEmbeddingStatusTooltip,
'ai-chat-composer': AIChatComposer,
'chat-panel-chips': ChatPanelChips,
'ai-history-clear': AIHistoryClear,
'chat-panel-add-popover': ChatPanelAddPopover,
'chat-input-preference': ChatInputPreference,
'chat-panel-candidates-popover': ChatPanelCandidatesPopover,
'chat-panel-doc-chip': ChatPanelDocChip,
'chat-panel-file-chip': ChatPanelFileChip,
'chat-panel-tag-chip': ChatPanelTagChip,
'chat-panel-collection-chip': ChatPanelCollectionChip,
'chat-panel-selected-chip': ChatPanelSelectedChip,
'chat-panel-attachment-chip': ChatPanelAttachmentChip,
'chat-panel-chip': ChatPanelChip,
'chat-assistant-avatar': AssistantAvatar,
'chat-message-action': ChatMessageAction,
'chat-message-assistant': ChatMessageAssistant,
'chat-message-user': ChatMessageUser,
'ai-chat-block-peek-view': AIChatBlockPeekView,
'date-time': DateTime,
'chat-panel-split-view': ChatPanelSplitView,
} satisfies Record<AppEffectElementTag, CustomElementConstructor>;
export function registerAIAppEffects() {
const registry = customElements;
if (appRegistries.has(registry)) return;
appRegistries.add(registry);
registerAISharedEffects();
componentPlaygroundEffects();
for (const tag of appEffectElementTags) {
customElements.define(tag, appElements[tag]);
}
}

View File

@@ -0,0 +1,105 @@
import { AIChatBlockComponent } from '../blocks/ai-chat-block/ai-chat-block';
import { EdgelessAIChatBlockComponent } from '../blocks/ai-chat-block/ai-chat-edgeless-block';
import { LitTranscriptionBlock } from '../blocks/ai-chat-block/ai-transcription-block';
import {
AIChatBlockMessage,
AIChatBlockMessages,
} from '../blocks/ai-chat-block/components/ai-chat-messages';
import {
ChatImage,
ChatImages,
} from '../blocks/ai-chat-block/components/chat-images';
import { ImagePlaceholder } from '../blocks/ai-chat-block/components/image-placeholder';
import { UserInfo } from '../blocks/ai-chat-block/components/user-info';
import { effects as componentAiItemEffects } from '../components/ai-item';
import { AIScrollableTextRenderer } from '../components/ai-scrollable-text-renderer';
import { AskAIButton } from '../components/ask-ai-button';
import { AskAIIcon } from '../components/ask-ai-icon';
import { AskAIPanel } from '../components/ask-ai-panel';
import { AskAIToolbarButton } from '../components/ask-ai-toolbar';
import {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from '../widgets/ai-panel/ai-panel';
import {
AIPanelAnswer,
AIPanelDivider,
AIPanelError,
AIPanelGenerating,
AIPanelInput,
} from '../widgets/ai-panel/components';
import { AIFinishTip } from '../widgets/ai-panel/components/finish-tip';
import { GeneratingPlaceholder } from '../widgets/ai-panel/components/generating-placeholder';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock,
} from '../widgets/block-diff/block';
import { BlockDiffOptions } from '../widgets/block-diff/options';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage,
} from '../widgets/block-diff/page';
import {
AFFINE_BLOCK_DIFF_PLAYGROUND,
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlayground,
BlockDiffPlaygroundModal,
} from '../widgets/block-diff/playground';
import {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
} from '../widgets/edgeless-copilot';
import { EdgelessCopilotPanel } from '../widgets/edgeless-copilot-panel';
import { EdgelessCopilotToolbarEntry } from '../widgets/edgeless-copilot-panel/toolbar-entry';
import {
type EditorEffectElementTag,
editorEffectElementTags,
} from './registry';
import { registerAISharedEffects } from './shared';
const editorRegistries = new WeakSet<CustomElementRegistry>();
const editorElements = {
'ask-ai-icon': AskAIIcon,
'ask-ai-button': AskAIButton,
'ask-ai-toolbar-button': AskAIToolbarButton,
'ask-ai-panel': AskAIPanel,
'affine-edgeless-ai-chat': EdgelessAIChatBlockComponent,
'affine-ai-chat': AIChatBlockComponent,
'ai-chat-block-message': AIChatBlockMessage,
'ai-chat-block-messages': AIChatBlockMessages,
'ai-scrollable-text-renderer': AIScrollableTextRenderer,
'image-placeholder': ImagePlaceholder,
'chat-image': ChatImage,
'chat-images': ChatImages,
'user-info': UserInfo,
'generating-placeholder': GeneratingPlaceholder,
'ai-finish-tip': AIFinishTip,
'ai-panel-divider': AIPanelDivider,
'ai-panel-answer': AIPanelAnswer,
'ai-panel-input': AIPanelInput,
'ai-panel-generating': AIPanelGenerating,
'ai-panel-error': AIPanelError,
'ai-block-diff-options': BlockDiffOptions,
[AFFINE_BLOCK_DIFF_PLAYGROUND]: BlockDiffPlayground,
[AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL]: BlockDiffPlaygroundModal,
[AFFINE_AI_PANEL_WIDGET]: AffineAIPanelWidget,
[AFFINE_EDGELESS_COPILOT_WIDGET]: EdgelessCopilotWidget,
[AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK]: AffineBlockDiffWidgetForBlock,
[AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE]: AffineBlockDiffWidgetForPage,
'edgeless-copilot-panel': EdgelessCopilotPanel,
'edgeless-copilot-toolbar-entry': EdgelessCopilotToolbarEntry,
'transcription-block': LitTranscriptionBlock,
} satisfies Record<EditorEffectElementTag, CustomElementConstructor>;
export function registerAIEditorEffects() {
const registry = customElements;
if (editorRegistries.has(registry)) return;
editorRegistries.add(registry);
registerAISharedEffects();
componentAiItemEffects();
for (const tag of editorEffectElementTags) {
customElements.define(tag, editorElements[tag]);
}
}

View File

@@ -0,0 +1,106 @@
export const sharedEffectElementTags = [
'ai-error-wrapper',
'ai-slides-renderer',
'ai-answer-wrapper',
'chat-content-images',
'chat-content-pure-text',
'chat-content-rich-text',
'chat-content-stream-objects',
'text-renderer',
'tool-call-card',
'tool-result-card',
'tool-call-failed',
'doc-semantic-search-result',
'doc-keyword-search-result',
'doc-read-result',
'doc-write-tool',
'web-crawl-tool',
'web-search-tool',
'section-edit-tool',
'doc-compose-tool',
'code-artifact-tool',
'code-highlighter',
'artifact-preview-panel',
'doc-edit-tool',
'artifact-skeleton',
] as const;
export type SharedEffectElementTag = (typeof sharedEffectElementTags)[number];
export const editorEffectElementTags = [
'ask-ai-icon',
'ask-ai-button',
'ask-ai-toolbar-button',
'ask-ai-panel',
'affine-edgeless-ai-chat',
'affine-ai-chat',
'ai-chat-block-message',
'ai-chat-block-messages',
'ai-scrollable-text-renderer',
'image-placeholder',
'chat-image',
'chat-images',
'user-info',
'generating-placeholder',
'ai-finish-tip',
'ai-panel-divider',
'ai-panel-answer',
'ai-panel-input',
'ai-panel-generating',
'ai-panel-error',
'ai-block-diff-options',
'affine-block-diff-playground',
'affine-block-diff-playground-modal',
'affine-ai-panel-widget',
'affine-edgeless-copilot-widget',
'affine-block-diff-widget-for-block',
'affine-block-diff-widget-for-page',
'edgeless-copilot-panel',
'edgeless-copilot-toolbar-entry',
'transcription-block',
] as const;
export type EditorEffectElementTag = (typeof editorEffectElementTags)[number];
export const appEffectElementTags = [
'chat-action-list',
'chat-copy-more',
'image-preview-grid',
'action-wrapper',
'action-image-to-text',
'action-image',
'action-make-real',
'action-mindmap',
'action-slides',
'action-text',
'ai-loading',
'ai-chat-content',
'ai-chat-toolbar',
'ai-session-history',
'ai-chat-messages',
'ai-chat-input',
'ai-chat-add-context',
'ai-chat-embedding-status-tooltip',
'ai-chat-composer',
'chat-panel-chips',
'ai-history-clear',
'chat-panel-add-popover',
'chat-input-preference',
'chat-panel-candidates-popover',
'chat-panel-doc-chip',
'chat-panel-file-chip',
'chat-panel-tag-chip',
'chat-panel-collection-chip',
'chat-panel-selected-chip',
'chat-panel-attachment-chip',
'chat-panel-chip',
'chat-assistant-avatar',
'chat-message-action',
'chat-message-assistant',
'chat-message-user',
'ai-chat-block-peek-view',
'date-time',
'chat-panel-split-view',
] as const;
export type AppEffectElementTag = (typeof appEffectElementTags)[number];

View File

@@ -0,0 +1,74 @@
import { effects as tooltipEffects } from '@blocksuite/affine-components/tooltip';
import { ArtifactSkeleton } from '../components/ai-artifact-skeleton';
import { ChatContentImages } from '../components/ai-message-content/images';
import { ChatContentPureText } from '../components/ai-message-content/pure-text';
import { ChatContentRichText } from '../components/ai-message-content/rich-text';
import { ChatContentStreamObjects } from '../components/ai-message-content/stream-objects';
import { ArtifactPreviewPanel } from '../components/ai-tools/artifacts-preview-panel';
import {
CodeArtifactTool,
CodeHighlighter,
} from '../components/ai-tools/code-artifact';
import { DocComposeTool } from '../components/ai-tools/doc-compose';
import { DocEditTool } from '../components/ai-tools/doc-edit';
import { DocKeywordSearchResult } from '../components/ai-tools/doc-keyword-search-result';
import { DocReadResult } from '../components/ai-tools/doc-read-result';
import { DocSemanticSearchResult } from '../components/ai-tools/doc-semantic-search-result';
import { DocWriteTool } from '../components/ai-tools/doc-write';
import { SectionEditTool } from '../components/ai-tools/section-edit';
import { ToolCallCard } from '../components/ai-tools/tool-call-card';
import { ToolFailedCard } from '../components/ai-tools/tool-failed-card';
import { ToolResultCard } from '../components/ai-tools/tool-result-card';
import { WebCrawlTool } from '../components/ai-tools/web-crawl';
import { WebSearchTool } from '../components/ai-tools/web-search';
import { TextRenderer } from '../components/text-renderer';
import { AIErrorWrapper } from '../messages/error';
import { AISlidesRenderer } from '../messages/slides-renderer';
import { AIAnswerWrapper } from '../messages/wrapper';
import { registerMiniMindmapBlocks } from '../mini-mindmap';
import {
type SharedEffectElementTag,
sharedEffectElementTags,
} from './registry';
const sharedRegistries = new WeakSet<CustomElementRegistry>();
const sharedElements = {
'ai-error-wrapper': AIErrorWrapper,
'ai-slides-renderer': AISlidesRenderer,
'ai-answer-wrapper': AIAnswerWrapper,
'chat-content-images': ChatContentImages,
'chat-content-pure-text': ChatContentPureText,
'chat-content-rich-text': ChatContentRichText,
'chat-content-stream-objects': ChatContentStreamObjects,
'text-renderer': TextRenderer,
'tool-call-card': ToolCallCard,
'tool-result-card': ToolResultCard,
'tool-call-failed': ToolFailedCard,
'doc-semantic-search-result': DocSemanticSearchResult,
'doc-keyword-search-result': DocKeywordSearchResult,
'doc-read-result': DocReadResult,
'doc-write-tool': DocWriteTool,
'web-crawl-tool': WebCrawlTool,
'web-search-tool': WebSearchTool,
'section-edit-tool': SectionEditTool,
'doc-compose-tool': DocComposeTool,
'code-artifact-tool': CodeArtifactTool,
'code-highlighter': CodeHighlighter,
'artifact-preview-panel': ArtifactPreviewPanel,
'doc-edit-tool': DocEditTool,
'artifact-skeleton': ArtifactSkeleton,
} satisfies Record<SharedEffectElementTag, CustomElementConstructor>;
export function registerAISharedEffects() {
const registry = customElements;
if (sharedRegistries.has(registry)) return;
sharedRegistries.add(registry);
registerMiniMindmapBlocks();
tooltipEffects();
for (const tag of sharedEffectElementTags) {
customElements.define(tag, sharedElements[tag]);
}
}

View File

@@ -1,6 +1,5 @@
export * from './_common/config';
export * from './actions';
export { ChatPanel } from './chat-panel';
export * from './entries';
export * from './entries/edgeless/actions-config';
export * from './messages';

View File

@@ -1,10 +1,10 @@
import { registerAIEffects } from '@affine/core/blocksuite/ai/effects';
import { registerAIEditorEffects } from '@affine/core/blocksuite/ai/effects/editor';
import { editorEffects } from '@affine/core/blocksuite/editors';
import { registerTemplates } from './register-templates';
editorEffects();
registerAIEffects();
registerAIEditorEffects();
registerTemplates();
export * from './blocksuite-editor';

View File

@@ -21,6 +21,7 @@ import {
EventSourceService,
FetchService,
GraphQLService,
ServerService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
@@ -228,6 +229,7 @@ export const Component = () => {
);
content.aiDraftService = framework.get(AIDraftService);
content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.serverService = framework.get(ServerService);
content.subscriptionService = framework.get(SubscriptionService);
content.aiModelService = framework.get(AIModelService);
content.onAISubscribe = handleAISubscribe;

View File

@@ -1,6 +1,6 @@
import { Scrollable } from '@affine/component';
import { PageDetailLoading } from '@affine/component/page-detail-skeleton';
import type { AIChatParams, ChatPanel } from '@affine/core/blocksuite/ai';
import type { AIChatParams } from '@affine/core/blocksuite/ai';
import { AIProvider } from '@affine/core/blocksuite/ai';
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
@@ -103,7 +103,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
const isSideBarOpen = useLiveData(workbench.sidebarOpen$);
const { appSettings } = useAppSettingHelper();
const chatPanelRef = useRef<ChatPanel | null>(null);
const peekView = useService(PeekViewService).peekView;
@@ -373,7 +372,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
icon={<AiIcon />}
unmountOnInactive={false}
>
<EditorChatPanel editor={editorContainer} ref={chatPanelRef} />
<EditorChatPanel editor={editorContainer} />
</ViewSidebarTab>
)}

View File

@@ -0,0 +1,147 @@
/* eslint-disable rxjs/finnish */
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { describe, expect, test, vi } from 'vitest';
import {
resolveInitialSession,
type SessionService,
type WorkbenchLike,
} from './chat-panel-session';
const createWorkbench = (search: string) => {
const updateQueryString = vi.fn();
const workbench = {
location$: { value: { search } },
activeView$: { value: { updateQueryString } },
} satisfies WorkbenchLike;
return { workbench, updateQueryString };
};
const doc = { id: 'doc-1', workspace: { id: 'ws-1' } };
test('returns undefined without session service or doc', async () => {
await expect(
resolveInitialSession({ sessionService: null, doc, workbench: null })
).resolves.toBeUndefined();
await expect(
resolveInitialSession({
sessionService: {
getSessions: vi.fn(),
getSession: vi.fn(),
},
doc: null,
workbench: null,
})
).resolves.toBeUndefined();
});
describe('resolveInitialSession', () => {
test('prefers pinned session and clears sessionId from url', async () => {
const pinnedSession = {
sessionId: 'pinned-session',
pinned: true,
} as CopilotChatHistoryFragment;
const sessionService: SessionService = {
getSessions: vi.fn().mockResolvedValueOnce([pinnedSession]),
getSession: vi.fn(),
};
const { workbench, updateQueryString } = createWorkbench(
'?sessionId=from-url'
);
const result = await resolveInitialSession({
sessionService,
doc,
workbench,
});
expect(result).toBe(pinnedSession);
expect(updateQueryString).toHaveBeenCalledWith(
{ sessionId: undefined },
{ replace: true }
);
expect(sessionService.getSession).not.toHaveBeenCalled();
});
test('loads session from url when no pinned session', async () => {
const sessionFromUrl = {
sessionId: 'url-session',
pinned: false,
} as CopilotChatHistoryFragment;
const sessionService: SessionService = {
getSessions: vi.fn().mockResolvedValueOnce([]),
getSession: vi.fn().mockResolvedValueOnce(sessionFromUrl),
};
const { workbench, updateQueryString } = createWorkbench(
'?sessionId=url-session'
);
const result = await resolveInitialSession({
sessionService,
doc,
workbench,
});
expect(result).toBe(sessionFromUrl);
expect(sessionService.getSession).toHaveBeenCalledWith(
doc.workspace.id,
'url-session'
);
expect(updateQueryString).toHaveBeenCalledWith(
{ sessionId: undefined },
{ replace: true }
);
});
test('falls back to latest doc session', async () => {
const docSession = {
sessionId: 'doc-session',
pinned: false,
} as CopilotChatHistoryFragment;
const sessionService: SessionService = {
getSessions: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([docSession]),
getSession: vi.fn(),
};
const { workbench } = createWorkbench('');
const result = await resolveInitialSession({
sessionService,
doc,
workbench,
});
expect(result).toBe(docSession);
expect(sessionService.getSessions).toHaveBeenCalledWith(
doc.workspace.id,
doc.id,
{ action: false, fork: false, limit: 1 }
);
});
test('returns null when url session is missing', async () => {
const sessionService: SessionService = {
getSessions: vi.fn().mockResolvedValueOnce([]),
getSession: vi.fn().mockResolvedValueOnce(null),
};
const { workbench } = createWorkbench('?sessionId=missing');
const result = await resolveInitialSession({
sessionService,
doc,
workbench,
});
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,108 @@
/* eslint-disable rxjs/finnish */
import type { CopilotChatHistoryFragment } from '@affine/graphql';
type SessionListOptions = {
pinned?: boolean;
action?: boolean;
fork?: boolean;
limit?: number;
};
export interface SessionService {
getSessions: (
workspaceId: string,
docId?: string,
options?: SessionListOptions
) => Promise<CopilotChatHistoryFragment[] | null | undefined>;
getSession: (
workspaceId: string,
sessionId: string
) => Promise<CopilotChatHistoryFragment | null | undefined>;
}
export interface WorkbenchLike {
location$: {
value: {
search: string;
};
};
activeView$: {
value: {
updateQueryString: (
patch: Record<string, unknown>,
options?: { replace?: boolean }
) => void;
};
};
}
export interface DocLike {
id: string;
workspace: {
id: string;
};
}
export const getSessionIdFromUrl = (workbench?: WorkbenchLike | null) => {
if (!workbench) {
return undefined;
}
const searchParams = new URLSearchParams(workbench.location$.value.search);
const sessionId = searchParams.get('sessionId');
if (sessionId) {
workbench.activeView$.value.updateQueryString(
{ sessionId: undefined },
{ replace: true }
);
}
return sessionId ?? undefined;
};
export const resolveInitialSession = async ({
sessionService,
doc,
workbench,
}: {
sessionService?: SessionService | null;
doc?: DocLike | null;
workbench?: WorkbenchLike | null;
}): Promise<CopilotChatHistoryFragment | null | undefined> => {
if (!sessionService || !doc) {
return undefined;
}
const sessionId = getSessionIdFromUrl(workbench);
const pinSessions = await sessionService.getSessions(
doc.workspace.id,
undefined,
{
pinned: true,
limit: 1,
}
);
if (Array.isArray(pinSessions) && pinSessions[0]) {
return pinSessions[0];
}
if (sessionId) {
const session = await sessionService.getSession(
doc.workspace.id,
sessionId
);
return session ?? null;
}
const docSessions = await sessionService.getSessions(
doc.workspace.id,
doc.id,
{
action: false,
fork: false,
limit: 1,
}
);
return docSessions?.[0] ?? null;
};

View File

@@ -1,6 +1,100 @@
import { style } from '@vanilla-extract/css';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
userSelect: 'text',
});
export const container = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
export const header = style({
background: 'var(--affine-background-primary-color)',
position: 'relative',
padding: '8px var(--h-padding, 16px)',
width: '100%',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 1,
});
export const title = style({
fontSize: '14px',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
});
export const playground = style({
cursor: 'pointer',
padding: '2px',
marginLeft: '8px',
marginRight: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const content = style({
flexGrow: 1,
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
});
export const loadingContainer = style({
position: 'relative',
padding: '44px 0 166px 0',
height: '100%',
display: 'flex',
alignItems: 'center',
});
export const loading = style({
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
});
export const loadingTitle = style({
fontWeight: 600,
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
});
export const loadingIcon = style({
width: '44px',
height: '44px',
color: 'var(--affine-icon-secondary)',
});
globalStyle(`${playground} svg`, {
width: '18px',
height: '18px',
color: 'var(--affine-text-secondary-color)',
});
globalStyle(`${playground}:hover svg`, {
color: cssVarV2('icon/activated'),
});
globalStyle(`${content} > ai-chat-content`, {
flexGrow: 1,
height: 0,
minHeight: 0,
width: '100%',
});

View File

@@ -1,5 +1,14 @@
import { useConfirmModal } from '@affine/component';
import { AIProvider, ChatPanel } from '@affine/core/blocksuite/ai';
import { AIProvider } from '@affine/core/blocksuite/ai';
import type { AppSidebarConfig } from '@affine/core/blocksuite/ai/chat-panel/chat-config';
import {
AIChatContent,
type ChatContextValue,
} from '@affine/core/blocksuite/ai/components/ai-chat-content';
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
import { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
import { createPlaygroundModal } from '@affine/core/blocksuite/ai/components/playground/modal';
import { registerAIAppEffects } from '@affine/core/blocksuite/ai/effects/app';
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
@@ -12,48 +21,49 @@ import {
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
import { ServerService, SubscriptionService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { useSignalValue } from '@affine/core/modules/doc-info/utils';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { AppThemeService } from '@affine/core/modules/theme';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type {
ContextEmbedStatus,
CopilotChatHistoryFragment,
UpdateChatSessionInput,
} from '@affine/graphql';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
import { DocModeProvider } from '@blocksuite/affine/shared/services';
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
import { CenterPeekIcon, Logo1Icon } from '@blocksuite/icons/rc';
import type { Signal } from '@preact/signals-core';
import { useFramework, useService } from '@toeverything/infra';
import { forwardRef, useEffect, useRef, useState } from 'react';
import { html } from 'lit';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as styles from './chat.css';
import {
resolveInitialSession,
type WorkbenchLike,
} from './chat-panel-session';
registerAIAppEffects();
export interface SidebarTabProps {
editor: AffineEditorContainer | null;
onLoad?: ((component: HTMLElement) => void) | null;
}
// A wrapper for CopilotPanel
export const EditorChatPanel = forwardRef(function EditorChatPanel(
{ editor, onLoad }: SidebarTabProps,
ref: React.ForwardedRef<ChatPanel>
) {
const chatPanelRef = useRef<ChatPanel | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const workbench = useService(WorkbenchService).workbench;
export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
const framework = useFramework();
const workbench = useService(WorkbenchService).workbench;
useEffect(() => {
if (onLoad && chatPanelRef.current) {
(chatPanelRef.current as ChatPanel).updateComplete
.then(() => {
if (ref) {
if (typeof ref === 'function') {
ref(chatPanelRef.current);
} else {
ref.current = chatPanelRef.current;
}
}
})
.catch(console.error);
}
}, [onLoad, ref]);
const { closeConfirmModal, openConfirmModal } = useConfirmModal();
const notificationService = useMemo(
() => new NotificationServiceImpl(closeConfirmModal, openConfirmModal),
[closeConfirmModal, openConfirmModal]
);
const specs = useAISpecs();
const handleAISubscribe = useAISubscribe();
const {
docDisplayConfig,
@@ -61,101 +71,477 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
reasoningConfig,
playgroundConfig,
} = useAIChatConfig();
const confirmModal = useConfirmModal();
const specs = useAISpecs();
const handleAISubscribe = useAISubscribe();
const playgroundVisible = useSignalValue(playgroundConfig.visible) ?? false;
const [session, setSession] = useState<
CopilotChatHistoryFragment | null | undefined
>(undefined);
const [embeddingProgress, setEmbeddingProgress] = useState<[number, number]>([
0, 0,
]);
const [status, setStatus] = useState<ChatStatus>('idle');
const [hasPinned, setHasPinned] = useState(false);
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const [chatToolbar, setChatToolbar] = useState<AIChatToolbar | null>(null);
const [isBodyProvided, setIsBodyProvided] = useState(false);
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
const contentKeyRef = useRef<string | null>(null);
const lastDocIdRef = useRef<string | null>(null);
const doc = editor?.doc;
const host = editor?.host;
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
return {
getWidth: () =>
createSignalFromObservable<number | undefined>(
workbench.sidebarWidth$.asObservable(),
0
),
isOpen: () =>
createSignalFromObservable<boolean | undefined>(
workbench.sidebarOpen$.asObservable(),
true
),
};
}, [workbench]);
const [sidebarWidthSignal, setSidebarWidthSignal] =
useState<Signal<number | undefined>>();
useEffect(() => {
if (!editor || !editor.host) return;
const { signal, cleanup } = appSidebarConfig.getWidth();
setSidebarWidthSignal(signal);
return cleanup;
}, [appSidebarConfig]);
if (!chatPanelRef.current) {
chatPanelRef.current = new ChatPanel();
chatPanelRef.current.host = editor.host;
chatPanelRef.current.doc = editor.doc;
const resetPanel = useCallback(() => {
setSession(undefined);
setEmbeddingProgress([0, 0]);
setHasPinned(false);
}, []);
const workbench = framework.get(WorkbenchService).workbench;
chatPanelRef.current.appSidebarConfig = {
getWidth: () => {
const width$ = workbench.sidebarWidth$;
return createSignalFromObservable(width$, 0);
},
isOpen: () => {
const open$ = workbench.sidebarOpen$;
return createSignalFromObservable(open$, true);
},
};
const initPanel = useCallback(async () => {
try {
const nextSession = await resolveInitialSession({
sessionService: AIProvider.session ?? undefined,
doc,
workbench: workbench as WorkbenchLike,
});
chatPanelRef.current.docDisplayConfig = docDisplayConfig;
chatPanelRef.current.searchMenuConfig = searchMenuConfig;
chatPanelRef.current.reasoningConfig = reasoningConfig;
chatPanelRef.current.playgroundConfig = playgroundConfig;
chatPanelRef.current.extensions = specs;
chatPanelRef.current.serverService = framework.get(ServerService);
chatPanelRef.current.affineFeatureFlagService =
framework.get(FeatureFlagService);
chatPanelRef.current.affineWorkspaceDialogService = framework.get(
WorkspaceDialogService
);
chatPanelRef.current.affineWorkbenchService =
framework.get(WorkbenchService);
chatPanelRef.current.affineThemeService = framework.get(AppThemeService);
chatPanelRef.current.peekViewService = framework.get(PeekViewService);
chatPanelRef.current.notificationService = new NotificationServiceImpl(
confirmModal.closeConfirmModal,
confirmModal.openConfirmModal
);
chatPanelRef.current.aiDraftService = framework.get(AIDraftService);
chatPanelRef.current.aiToolsConfigService =
framework.get(AIToolsConfigService);
chatPanelRef.current.subscriptionService =
framework.get(SubscriptionService);
chatPanelRef.current.aiModelService = framework.get(AIModelService);
chatPanelRef.current.onAISubscribe = handleAISubscribe;
containerRef.current?.append(chatPanelRef.current);
} else {
chatPanelRef.current.host = editor.host;
chatPanelRef.current.doc = editor.doc;
if (nextSession === undefined) {
return;
}
setSession(nextSession);
setHasPinned(!!nextSession?.pinned);
} catch (error) {
console.error(error);
}
}, [doc, workbench]);
const createSession = useCallback(
async (options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}) => {
if (session || !AIProvider.session || !doc) {
return session ?? undefined;
}
const sessionId = await AIProvider.session.createSession({
docId: doc.id,
workspaceId: doc.workspace.id,
promptName: 'Chat With AFFiNE AI',
reuseLatestChat: false,
...options,
});
if (sessionId) {
const nextSession = await AIProvider.session.getSession(
doc.workspace.id,
sessionId
);
setSession(nextSession ?? null);
return nextSession ?? undefined;
}
return session ?? undefined;
},
[doc, session]
);
const updateSession = useCallback(
async (options: UpdateChatSessionInput) => {
if (!AIProvider.session || !doc) {
return undefined;
}
await AIProvider.session.updateSession(options);
const nextSession = await AIProvider.session.getSession(
doc.workspace.id,
options.sessionId
);
setSession(nextSession ?? null);
return nextSession ?? undefined;
},
[doc]
);
const newSession = useCallback(() => {
resetPanel();
requestAnimationFrame(() => {
setSession(null);
});
}, [resetPanel]);
const openSession = useCallback(
async (sessionId: string) => {
if (session?.sessionId === sessionId || !AIProvider.session || !doc) {
return;
}
resetPanel();
const nextSession = await AIProvider.session.getSession(
doc.workspace.id,
sessionId
);
setSession(nextSession ?? null);
},
[doc, resetPanel, session?.sessionId]
);
const openDoc = useCallback(
async (docId: string, sessionId?: string) => {
if (!doc) {
return;
}
if (doc.id === docId) {
if (session?.sessionId === sessionId || session?.pinned) {
return;
}
if (sessionId) {
await openSession(sessionId);
}
return;
}
if (session?.pinned || !sessionId) {
workbench.open(`/${docId}`, { at: 'active' });
return;
}
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
},
[doc, openSession, session?.pinned, session?.sessionId, workbench]
);
const deleteSession = useCallback(
async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
if (!AIProvider.histories) {
return;
}
const confirm = await notificationService.confirm({
title: 'Delete this history?',
message:
'Do you want to delete this AI conversation history? Once deleted, it cannot be recovered.',
confirmText: 'Delete',
cancelText: 'Cancel',
});
if (confirm) {
await AIProvider.histories.cleanup(
sessionToDelete.workspaceId,
sessionToDelete.docId || undefined,
[sessionToDelete.sessionId]
);
if (sessionToDelete.sessionId === session?.sessionId) {
newSession();
}
}
},
[newSession, notificationService, session?.sessionId]
);
const togglePin = useCallback(async () => {
const pinned = !session?.pinned;
setHasPinned(true);
if (!session) {
await createSession({ pinned });
return;
}
setSession(prev => (prev ? { ...prev, pinned } : prev));
await updateSession({
sessionId: session.sessionId,
pinned,
});
}, [createSession, session, updateSession]);
const rebindSession = useCallback(async () => {
if (!session || !doc) {
return;
}
if (session.docId !== doc.id) {
await updateSession({
sessionId: session.sessionId,
docId: doc.id,
});
}
}, [doc, session, updateSession]);
const onEmbeddingProgressChange = useCallback(
(count: Record<ContextEmbedStatus, number>) => {
const total = count.finished + count.processing + count.failed;
setEmbeddingProgress([count.finished, total]);
},
[]
);
const onContextChange = useCallback(
(context: Partial<ChatContextValue>) => {
setStatus(context.status ?? 'idle');
if (context.status === 'success') {
rebindSession().catch(console.error);
}
},
[rebindSession]
);
useEffect(() => {
if (session !== undefined) {
return;
}
if (chatContent) {
chatContent.remove();
setChatContent(null);
}
if (chatToolbar) {
chatToolbar.remove();
setChatToolbar(null);
}
}, [chatContent, chatToolbar, session]);
useEffect(() => {
const subscription = AIProvider.slots.userInfo.subscribe(() => {
resetPanel();
initPanel().catch(console.error);
});
return () => subscription.unsubscribe();
}, [initPanel, resetPanel]);
useEffect(() => {
const docId = doc?.id;
if (!docId) {
return;
}
if (
lastDocIdRef.current &&
lastDocIdRef.current !== docId &&
!session?.pinned
) {
resetPanel();
}
lastDocIdRef.current = docId;
}, [doc?.id, resetPanel, session?.pinned]);
useEffect(() => {
if (!doc || session !== undefined) {
return;
}
let cancelled = false;
let timerId: ReturnType<typeof setTimeout> | null = null;
const tryInit = () => {
if (cancelled || session !== undefined) {
return;
}
// Session service may be registered after the panel mounts.
if (AIProvider.session) {
initPanel().catch(console.error);
return;
}
timerId = setTimeout(tryInit, 200);
};
tryInit();
return () => {
cancelled = true;
if (timerId) {
clearTimeout(timerId);
}
};
}, [doc, initPanel, session]);
const contentKey = hasPinned
? (session?.sessionId ?? doc?.id ?? 'chat-panel')
: (doc?.id ?? 'chat-panel');
useEffect(() => {
if (!chatContent) {
contentKeyRef.current = contentKey;
return;
}
if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
chatContent.remove();
setChatContent(null);
}
contentKeyRef.current = contentKey;
}, [chatContent, contentKey]);
useEffect(() => {
if (!isBodyProvided || !chatContainerRef.current || !doc || !host) {
return;
}
if (session === undefined) {
return;
}
let content = chatContent;
if (!content) {
content = new AIChatContent();
}
content.host = host;
content.session = session;
content.createSession = createSession;
content.workspaceId = doc.workspace.id;
content.docId = doc.id;
content.reasoningConfig = reasoningConfig;
content.searchMenuConfig = searchMenuConfig;
content.docDisplayConfig = docDisplayConfig;
content.extensions = specs;
content.serverService = framework.get(ServerService);
content.affineFeatureFlagService = framework.get(FeatureFlagService);
content.affineWorkspaceDialogService = framework.get(
WorkspaceDialogService
);
content.affineThemeService = framework.get(AppThemeService);
content.notificationService = notificationService;
content.aiDraftService = framework.get(AIDraftService);
content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.peekViewService = framework.get(PeekViewService);
content.subscriptionService = framework.get(SubscriptionService);
content.aiModelService = framework.get(AIModelService);
content.onAISubscribe = handleAISubscribe;
content.onEmbeddingProgressChange = onEmbeddingProgressChange;
content.onContextChange = onContextChange;
content.width = sidebarWidthSignal;
content.onOpenDoc = (docId: string, sessionId?: string) => {
openDoc(docId, sessionId).catch(console.error);
};
if (!chatContent) {
chatContainerRef.current.append(content);
setChatContent(content);
onLoad?.(content);
}
}, [
chatContent,
createSession,
doc,
docDisplayConfig,
framework,
handleAISubscribe,
host,
isBodyProvided,
notificationService,
onContextChange,
onEmbeddingProgressChange,
onLoad,
openDoc,
reasoningConfig,
searchMenuConfig,
session,
sidebarWidthSignal,
specs,
]);
useEffect(() => {
if (!isHeaderProvided || !chatToolbarContainerRef.current || !doc) {
return;
}
if (session === undefined) {
return;
}
let tool = chatToolbar;
if (!tool) {
tool = new AIChatToolbar();
}
tool.session = session;
tool.workspaceId = doc.workspace.id;
tool.docId = doc.id;
tool.status = status;
tool.docDisplayConfig = docDisplayConfig;
tool.notificationService = notificationService;
tool.onNewSession = newSession;
tool.onTogglePin = togglePin;
tool.onOpenSession = (sessionId: string) => {
openSession(sessionId).catch(console.error);
};
tool.onOpenDoc = (docId: string, sessionId: string) => {
openDoc(docId, sessionId).catch(console.error);
};
tool.onSessionDelete = (
sessionToDelete: BlockSuitePresets.AIRecentSession
) => {
deleteSession(sessionToDelete).catch(console.error);
};
if (!chatToolbar) {
chatToolbarContainerRef.current.append(tool);
setChatToolbar(tool);
}
}, [
chatToolbar,
deleteSession,
doc,
docDisplayConfig,
isHeaderProvided,
newSession,
notificationService,
openDoc,
openSession,
session,
status,
togglePin,
]);
useEffect(() => {
if (!editor?.host || !chatContent) {
return;
}
const docModeService = editor.host.std.get(DocModeProvider);
const refNodeService = editor.host.std.getOptional(RefNodeSlotsProvider);
const disposable = [
refNodeService?.docLinkClicked.subscribe(({ host }) => {
if (host === editor.host) {
(chatPanelRef.current as ChatPanel).doc = editor.doc;
refNodeService?.docLinkClicked.subscribe(({ host: clickedHost }) => {
if (clickedHost === editor.host) {
chatContent.docId = editor.doc.id;
}
}),
docModeService?.onPrimaryModeChange(() => {
if (!editor.host) return;
(chatPanelRef.current as ChatPanel).host = editor.host;
if (!editor.host) {
return;
}
chatContent.host = editor.host;
}, editor.doc.id),
];
return () => disposable.forEach(d => d?.unsubscribe());
}, [
docDisplayConfig,
editor,
framework,
searchMenuConfig,
reasoningConfig,
playgroundConfig,
confirmModal,
specs,
handleAISubscribe,
]);
return () => disposable.forEach(item => item?.unsubscribe());
}, [chatContent, editor]);
const [autoResized, setAutoResized] = useState(false);
useEffect(() => {
// after auto expanded first time, do not auto expand again(even if user manually resized)
if (autoResized) return;
if (autoResized) {
return;
}
const subscription = AIProvider.slots.previewPanelOpenChange.subscribe(
open => {
if (!open) return;
if (!open) {
return;
}
const sidebarWidth = workbench.sidebarWidth$.value;
const MIN_SIDEBAR_WIDTH = 1080;
if (!sidebarWidth || sidebarWidth < MIN_SIDEBAR_WIDTH) {
workbench.setSidebarWidth(MIN_SIDEBAR_WIDTH);
const minSidebarWidth = 1080;
if (!sidebarWidth || sidebarWidth < minSidebarWidth) {
workbench.setSidebarWidth(minSidebarWidth);
setAutoResized(true);
}
}
@@ -165,5 +551,99 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
};
}, [autoResized, workbench]);
return <div className={styles.root} ref={containerRef} />;
});
const openPlayground = useCallback(() => {
if (!doc || !host) {
return;
}
const playgroundContent = html`
<playground-content
.host=${host}
.doc=${doc}
.reasoningConfig=${reasoningConfig}
.playgroundConfig=${playgroundConfig}
.appSidebarConfig=${appSidebarConfig}
.searchMenuConfig=${searchMenuConfig}
.docDisplayConfig=${docDisplayConfig}
.extensions=${specs}
.serverService=${framework.get(ServerService)}
.affineFeatureFlagService=${framework.get(FeatureFlagService)}
.affineThemeService=${framework.get(AppThemeService)}
.notificationService=${notificationService}
.affineWorkspaceDialogService=${framework.get(WorkspaceDialogService)}
.aiToolsConfigService=${framework.get(AIToolsConfigService)}
.subscriptionService=${framework.get(SubscriptionService)}
.aiModelService=${framework.get(AIModelService)}
></playground-content>
`;
createPlaygroundModal(playgroundContent, 'AI Playground');
}, [
appSidebarConfig,
doc,
docDisplayConfig,
framework,
host,
notificationService,
playgroundConfig,
reasoningConfig,
searchMenuConfig,
specs,
]);
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
if (!node) {
return;
}
setIsBodyProvided(true);
chatContainerRef.current = node;
}, []);
const onChatToolContainerRef = useCallback((node: HTMLDivElement) => {
if (!node) {
return;
}
setIsHeaderProvided(true);
chatToolbarContainerRef.current = node;
}, []);
const isEmbedding =
embeddingProgress[1] > 0 && embeddingProgress[0] < embeddingProgress[1];
const [done, total] = embeddingProgress;
const isInitialized = session !== undefined;
return (
<div className={styles.root}>
{!isInitialized ? (
<div className={styles.loadingContainer}>
<div className={styles.loading}>
<Logo1Icon className={styles.loadingIcon} />
<div className={styles.loadingTitle}>
AFFiNE AI is loading history...
</div>
</div>
</div>
) : (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}>
{isEmbedding ? (
<span data-testid="chat-panel-embedding-progress">
Embedding {done}/{total}
</span>
) : (
'AFFiNE AI'
)}
</div>
{playgroundVisible ? (
<div className={styles.playground} onClick={openPlayground}>
<CenterPeekIcon />
</div>
) : null}
<div ref={onChatToolContainerRef} />
</div>
<div className={styles.content} ref={onChatContainerRef} />
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
import { registerAIAppEffects } from '@affine/core/blocksuite/ai/effects/app';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
@@ -15,6 +16,8 @@ import type { EditorHost } from '@blocksuite/affine/std';
import { useFramework } from '@toeverything/infra';
import { useMemo } from 'react';
registerAIAppEffects();
export type AIChatBlockPeekViewProps = {
model: AIChatBlockModel;
host: EditorHost;

View File

@@ -13,5 +13,5 @@ test('Click ai-land icon', async ({ page }) => {
await clickNewPageButton(page);
await page.locator('[data-testid=ai-island]').click();
await expect(page.locator('chat-panel')).toBeVisible();
await expect(page.getByTestId('chat-panel-input-container')).toBeVisible();
});

View File

@@ -457,6 +457,7 @@ __metadata:
fuse.js: "npm:^7.0.0"
graphemer: "npm:^1.4.0"
graphql: "npm:^16.9.0"
happy-dom: "npm:^20.3.0"
history: "npm:^5.3.0"
idb: "npm:^8.0.0"
idb-keyval: "npm:^6.2.2"
@@ -18961,7 +18962,7 @@ __metadata:
languageName: node
linkType: hard
"@types/ws@npm:^8.0.0, @types/ws@npm:^8.5.10":
"@types/ws@npm:^8.0.0, @types/ws@npm:^8.18.1, @types/ws@npm:^8.5.10":
version: 8.18.1
resolution: "@types/ws@npm:8.18.1"
dependencies:
@@ -26694,14 +26695,16 @@ __metadata:
languageName: node
linkType: hard
"happy-dom@npm:^20.0.0":
version: 20.0.7
resolution: "happy-dom@npm:20.0.7"
"happy-dom@npm:^20.0.0, happy-dom@npm:^20.3.0":
version: 20.3.0
resolution: "happy-dom@npm:20.3.0"
dependencies:
"@types/node": "npm:^20.0.0"
"@types/whatwg-mimetype": "npm:^3.0.2"
"@types/ws": "npm:^8.18.1"
whatwg-mimetype: "npm:^3.0.0"
checksum: 10/1161bfe8fcac0fd093b8fbe8af29e8a6525437f17424be97b21401f0741e6473541b30b080066a32990b884eae4b83a1fd1f948a52607975bc98d1b90b394509
ws: "npm:^8.18.3"
checksum: 10/aade5560110eeaad502679a314f4d3b8658fe4697c1914d0de09ec6a176611ad0c8953f0aa559c56d1afabe0ab8465cde7b4811006df6eb3b54c5e5ea4169b29
languageName: node
linkType: hard
@@ -38868,9 +38871,9 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:^8.18.2":
version: 8.18.3
resolution: "ws@npm:8.18.3"
"ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:^8.18.2, ws@npm:^8.18.3":
version: 8.19.0
resolution: "ws@npm:8.19.0"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
@@ -38879,7 +38882,7 @@ __metadata:
optional: true
utf-8-validate:
optional: true
checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6
checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b
languageName: node
linkType: hard